Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apps/console/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,37 @@ describe('Mac-first application shell', () => {
expect(document.activeElement).toBe(container.querySelector('.cc-toolbar-leading button'))
})

// Real-Chrome regression: SettingsView has its own Escape handler
// (focus-trap Tab cycling lives there) alongside the shell's, and the
// shell's opener button (`.cc-settings-cta`) is also one of
// SETTINGS_FOCUS_FALLBACKS — so opening/closing Settings from that one
// button always "worked" even while the restore ran twice, masking the
// bug. Opening from an unrelated element (as ⌘, from anywhere does)
// exposes it: the second, stale restore fell through to the fallback
// selectors and stole focus back onto the sidebar's Settings button
// instead of leaving it on the real opener.
it('restores focus to the real opener after Escape, even when it is not a settings fallback', async () => {
await act(async () => root.render(
<ThemeModeProvider>
<StoreProvider><App /></StoreProvider>
</ThemeModeProvider>,
))
await act(async () => { await Promise.resolve(); await Promise.resolve() })

await act(async () => button('Sources').click())
const opener = button('Browse files')
opener.focus()

await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: ',', metaKey: true, bubbles: true })))
expect(container.querySelector('.cc-settings-screen')).toBeTruthy()

await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })))
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })

expect(container.querySelector('.cc-settings-screen')).toBeNull()
expect(document.activeElement).toBe(opener)
})

it('does not open Settings over the Connect Agent dialog', async () => {
window.__CC_DESKTOP = {
getApiToken: async () => 'test',
Expand Down
51 changes: 21 additions & 30 deletions apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,17 @@ import { ConnectAgentDialog } from './components/ConnectAgentDialog'
import { SettingsView } from './components/SettingsView'
import type { LiveErrorKind } from './api'
import { CommandPalette, type PaletteCommand } from './components/CommandPalette'
import { useOpenerFocus } from './components/useOpenerFocus'
import { readBrowserGroupedViews, SEARCHABLE_VIEWS, viewForDestination } from './shell-navigation'

// Stable across renders (useOpenerFocus's `restore` keys off this array's
// identity) — must live outside the component, not be re-literaled inline.
const SETTINGS_FOCUS_FALLBACKS = ['.cc-settings-cta', '.cc-toolbar-leading button']
// The wizard can auto-open with no trigger at all (first run) or be reopened
// from a button that only exists in one view (Sources' "Add Source"); the
// sidebar toggle is the one control guaranteed to be on screen in every view.
const WIZARD_FOCUS_FALLBACKS = ['.cc-toolbar-leading button']

const ERROR_COPY: Record<LiveErrorKind, (msg: string) => string> = {
unreachable: () => "Can't reach the ContextCake server. Start it with `npm run console:live`, or view the demo.",
'bad-status': (msg) => msg,
Expand Down Expand Up @@ -83,7 +92,8 @@ export function App() {
const [drawerOpen, setDrawerOpen] = useState(false)
const [paletteOpen, setPaletteOpen] = useState(false)
const [backgroundAnnouncement, setBackgroundAnnouncement] = useState('')
const settingsOpener = useRef<HTMLElement | null>(null)
const settingsFocus = useOpenerFocus(SETTINGS_FOCUS_FALLBACKS)
const wizardFocus = useOpenerFocus(WIZARD_FOCUS_FALLBACKS)
const paletteOpener = useRef<HTMLElement | null>(null)
const askOpener = useRef<HTMLElement | null>(null)
const drawerOpener = useRef<HTMLElement | null>(null)
Expand All @@ -102,22 +112,23 @@ export function App() {
const isDesktop = typeof window !== 'undefined' && Boolean(window.__CC_DESKTOP)

useEffect(() => {
if (needsSetup && wizardOpen === undefined) setWizardOpen(true)
}, [needsSetup, wizardOpen])
if (needsSetup && wizardOpen === undefined) { wizardFocus.capture(); setWizardOpen(true) }
}, [needsSetup, wizardOpen, wizardFocus])

const showWizard = wizardOpen === true
const closeWizard = () => setWizardOpen(false)
const closeWizard = () => { setWizardOpen(false); wizardFocus.restore() }
// Handlers that reach a memoized child (Sidebar, Header, Sources, ChatPanel)
// are stable identities. A fresh arrow function per render would re-render
// the child through its memo and give the whole split back.
const reopenWizard = useCallback(() => setWizardOpen(true), [])
const reopenWizard = useCallback(() => { wizardFocus.capture(); setWizardOpen(true) }, [wizardFocus])
const openConnect = useCallback(() => {
if (sources.length === 0 && !sourceSetupComplete) {
wizardFocus.capture()
setWizardOpen(true)
return
}
setConnectOpen(true)
}, [sources.length, sourceSetupComplete])
}, [sources.length, sourceSetupComplete, wizardFocus])
const openSettings = useCallback(() => {
if (window.__CC_DESKTOP?.windows) {
setDrawerOpen(false)
Expand All @@ -126,10 +137,10 @@ export function App() {
window.__CC_DESKTOP.windows.openSettings().catch(() => {})
return
}
settingsOpener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
settingsFocus.capture()
setDrawerOpen(false)
setSettingsOpen(true)
}, [])
}, [settingsFocus])
const openPalette = () => {
paletteOpener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
setDrawerOpen(false)
Expand Down Expand Up @@ -198,27 +209,7 @@ export function App() {
{ id: 'settings', label: 'Open Settings', shortcut: '⌘,', run: openSettings },
{ id: 'sidebar', label: 'Toggle Sidebar', run: toggleSidebar },
], [isDesktop, mode, openAskFromPalette, openConnect, openFilesScope, openSettings, reopenWizard, setView, sources, toggleSidebar])
const closeSettings = () => {
const opener = settingsOpener.current
setSettingsOpen(false)
window.requestAnimationFrame(() => {
const candidates = [
opener,
document.querySelector<HTMLElement>('.cc-settings-cta'),
document.querySelector<HTMLElement>('.cc-toolbar-leading button'),
]
candidates.find((candidate) => {
if (!candidate?.isConnected) return false
if (!candidate.matches('button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])')) return false
const rect = candidate.getBoundingClientRect()
const hasNoLayout = rect.width === 0 && rect.height === 0
const visible = hasNoLayout || (rect.width > 0 && rect.height > 0 && rect.right > 0 && rect.bottom > 0 && rect.left < window.innerWidth && rect.top < window.innerHeight)
if (visible) candidate.focus()
return visible
})
settingsOpener.current = null
})
}
const closeSettings = () => { setSettingsOpen(false); settingsFocus.restore() }

// Announce transitions, not ticks. A live region that re-read a progress
// counter every 900ms would make the app unusable with a screen reader; the
Expand Down Expand Up @@ -442,7 +433,7 @@ export function App() {

return (
<>
<div className="cc-app-layer" aria-hidden={(settingsOpen || paletteOpen) || undefined} inert={(settingsOpen || paletteOpen) || undefined}>{body}</div>
<div className="cc-app-layer" aria-hidden={(settingsOpen || paletteOpen || showWizard) || undefined} inert={(settingsOpen || paletteOpen || showWizard) || undefined}>{body}</div>
{settingsOpen && <SettingsView appMode={mode} onClose={closeSettings} onIndexingChange={reload} />}
{paletteOpen && <CommandPalette commands={paletteCommands} onClose={closePalette} />}
{showWizard && <SetupWizard addingSource={sources.length > 0} onClose={closeWizard} onConnectAgent={isDesktop ? () => {
Expand Down
124 changes: 124 additions & 0 deletions apps/console/src/App.wizard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// @vitest-environment jsdom
// The setup wizard's dialog contract (F25, F26): closing it returns focus to
// whatever opened it, and the app shell behind it is inert while it is open —
// the same contract Settings already had. Live-shaped data source so the
// wizard opens in "add a source" mode (one source already present) rather
// than the first-run narrative.
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { App } from './App'
import { StoreProvider } from './store'
import { ThemeModeProvider } from './theme-mode'

const mocks = vi.hoisted(() => ({
graph: vi.fn(), resolveAll: vi.fn(), status: vi.fn(), conflictResolutions: vi.fn(),
}))

vi.mock('./api', async () => {
const actual = await vi.importActual<typeof import('./api')>('./api')
return {
...actual,
createDataSource: () => ({
mode: 'live' as const,
graph: mocks.graph,
resolveAll: mocks.resolveAll,
resolve: vi.fn(),
listConcepts: vi.fn(),
status: mocks.status,
conflictResolutions: mocks.conflictResolutions,
resolveConflict: vi.fn(),
}),
}
})

let container: HTMLDivElement
let root: Root

function readyGraph() {
return {
totals: { sourceTokens: 0, resolvedTokens: 0, concepts: 0, sources: 1 },
indexing: false,
indexingSources: [],
generation: 1,
sources: [{
name: 'personal', level: 3, kind: 'files', conceptCount: 0, tokens: 0, latestUpdated: null,
status: 'ok', error: null,
}],
concepts: [],
}
}

function button(label: string): HTMLButtonElement {
const match = Array.from(container.querySelectorAll('button')).find((item) => item.textContent?.trim() === label)
if (!match) throw new Error(`Button not found: ${label}`)
return match
}

beforeEach(() => {
;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
window.history.replaceState(null, '', '/#/sources')
window.localStorage.clear()
delete window.__CC_DESKTOP
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => window.setTimeout(() => cb(0), 0))
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
for (const mock of Object.values(mocks)) mock.mockReset()
mocks.graph.mockResolvedValue(readyGraph())
mocks.resolveAll.mockResolvedValue({ concepts: [], errors: [], indexing: false })
mocks.conflictResolutions.mockResolvedValue([])
mocks.status.mockResolvedValue(null)
})

afterEach(async () => {
await act(async () => root.unmount())
container.remove()
vi.unstubAllGlobals()
})

describe('the setup wizard as a dialog', () => {
it('inerts the app shell while open, and lifts it once closed', async () => {
await act(async () => root.render(<ThemeModeProvider><StoreProvider><App /></StoreProvider></ThemeModeProvider>))
await act(async () => { await Promise.resolve(); await Promise.resolve() })

const opener = button('Add Source')
opener.focus()
await act(async () => opener.click())

expect(container.querySelector('[aria-label="ContextCake setup"]')).toBeTruthy()
expect(container.querySelector('.cc-app-layer')?.hasAttribute('inert')).toBe(true)

await act(async () => button('Cancel').click())
expect(container.querySelector('[aria-label="ContextCake setup"]')).toBeNull()
expect(container.querySelector('.cc-app-layer')?.hasAttribute('inert')).toBe(false)
})

it('restores focus to the button that opened it', async () => {
await act(async () => root.render(<ThemeModeProvider><StoreProvider><App /></StoreProvider></ThemeModeProvider>))
await act(async () => { await Promise.resolve(); await Promise.resolve() })

const opener = button('Add Source')
opener.focus()
await act(async () => opener.click())
expect(container.querySelector('[aria-label="ContextCake setup"]')).toBeTruthy()

await act(async () => button('Cancel').click())
await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 0)) })
expect(document.activeElement).toBe(opener)
})

it('closes on Escape and still restores focus to the opener', async () => {
await act(async () => root.render(<ThemeModeProvider><StoreProvider><App /></StoreProvider></ThemeModeProvider>))
await act(async () => { await Promise.resolve(); await Promise.resolve() })

const opener = button('Add Source')
opener.focus()
await act(async () => opener.click())

await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })))
await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 0)) })
expect(container.querySelector('[aria-label="ContextCake setup"]')).toBeNull()
expect(document.activeElement).toBe(opener)
})
})
Loading