Merge pull request #1756 from deepseek-harness/worktree/fix-settings-focus

fix(web): restore focus after closing settings
This commit is contained in:
Yichen Jiang 2026-08-28 17:25:40 +08:00 • committed by GitHub
commit 375af94454
3 changed files with 28 additions and 12 deletions

View file

@ -54,7 +54,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
return () => { document.removeEventListener('keydown', onKeyDown) }
}, [onClose])
// Baseline focus management: entering the dialog lands on the close button.
// Entering the dialog focuses the close button; the root restores its trigger on close.
const closeButton = useRef<HTMLButtonElement | null>(null)
useEffect(() => { closeButton.current?.focus() }, [])
@ -106,10 +106,17 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
const [open, setOpen] = useState(false)
const [activeId, setActiveId] = useState<string | undefined>(undefined)
const [completedOnboarding, setCompletedOnboarding] = useState<ReadonlySet<string>>(() => new Set())
const triggerButton = useRef<HTMLButtonElement | null>(null)
const wasOpen = useRef(open)
const close = useCallback(() => {
setOpen(false)
setActiveId(undefined)
}, [])
// Restore after the close commit, when the dialog can no longer own focus.
useEffect(() => {
if (wasOpen.current && !open) triggerButton.current?.focus()
wasOpen.current = open
}, [open])
const openSection = useCallback((id: string) => {
setActiveId(id)
setOpen(true)
@ -142,6 +149,7 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
return (
<>
<button
ref={triggerButton}
type="button"
className={clsx(css.trigger, !wide && css.rail)}
aria-haspopup="dialog"

View file

@ -81,7 +81,10 @@ function mount({
}
function openPanel() {
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
const trigger = screen.getByRole('button', { name: 'Settings' })
trigger.focus()
fireEvent.click(trigger)
return trigger
}
describe('SettingsRoot trigger', () => {
@ -131,26 +134,29 @@ describe('SettingsPanel chrome seats', () => {
})
describe('SettingsPanel close paths', () => {
it('closes via the header button', () => {
it('closes via the header button and restores trigger focus', async () => {
mount()
openPanel()
const trigger = openPanel()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.queryByRole('dialog')).toBeNull()
await vi.waitFor(() => { expect(document.activeElement).toBe(trigger) })
})
it('closes via a mask click', () => {
it('closes via a mask click and restores trigger focus', async () => {
mount()
openPanel()
const trigger = openPanel()
const dialog = screen.getByRole('dialog')
fireEvent.click(dialog.parentElement!.firstElementChild!)
expect(screen.queryByRole('dialog')).toBeNull()
await vi.waitFor(() => { expect(document.activeElement).toBe(trigger) })
})
it('closes via document-level Escape and unhooks the listener with the panel', () => {
it('closes via document-level Escape, restores trigger focus, and unhooks the listener', async () => {
mount()
openPanel()
const trigger = openPanel()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('dialog')).toBeNull()
await vi.waitFor(() => { expect(document.activeElement).toBe(trigger) })
// Ignored while closed (listener removed with the panel) and non-Escape
// keys are ignored while open.
fireEvent.keyDown(document, { key: 'Escape' })

View file

@ -442,15 +442,17 @@ describe('SessionProjectionCache cold-read seeding', () => {
// Host-only unit: folded but not served; the refreshed row is written
// back (fail-soft, fire-and-forget) once the write lands.
expect(Object.keys(snapshot.values)).not.toContain('cache-test/count')
await settle()
expect((await storedRows(root, meta.id))?.['cache-test/count']?.seq).toBe(4)
await vi.waitFor(async () => {
expect((await storedRows(root, meta.id))?.['cache-test/count']?.seq).toBe(4)
})
// No cached row yet: the first cold read folds from init over the full
// log and creates the cache row (the `?? {}` seed path).
const fresh = headerOf(SessionId('cold-fresh'), 10)
cache.coldSnapshot(fresh, events)
expect(apply).toHaveBeenCalledTimes(7) // 2 tail + 5 full
await settle()
expect((await storedRows(root, fresh.id))?.['cache-test/count']?.seq).toBe(4)
await vi.waitFor(async () => {
expect((await storedRows(root, fresh.id))?.['cache-test/count']?.seq).toBe(4)
})
})
it('coldSnapshot write-back is fail-soft: a failed durable write logs and never throws', async () => {