fix(quick-open): restore panel focus when dismissing the Go to file dialog

Restore focus to the active panel when dismissing QuickOpen, including terminal, editor, simulator, local browser, and remote browser surfaces.

Reviewed-by: Codex
This commit is contained in:
Devin345458 2026-06-19 19:47:27 -04:00 committed by GitHub
parent 21d0391881
commit bb0e98fcd1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 591 additions and 1 deletions

View File

@ -15,6 +15,7 @@ import {
} from '@/components/ui/command'
import { prepareQuickOpenFiles, rankQuickOpenFiles } from '@/components/quick-open-search'
import { useRuntimeFileListForWorktree } from '@/components/quick-open-file-list'
import { useModalReturnFocus } from '@/hooks/useModalReturnFocus'
import { translate } from '@/i18n/i18n'
/**
@ -176,6 +177,11 @@ export default function QuickOpen(): React.JSX.Element | null {
const worktreePath = activeWorktree?.path ?? null
// Why: Radix's onCloseAutoFocus restore is suppressed below, so dismissing
// the dialog (Esc / click-away) would otherwise leave the active panel
// unfocused. This returns focus to the surface that was active on open.
const { captureReturnFocus, skipReturnFocus } = useModalReturnFocus(visible)
// Why: reset input only on open. Keeping this out of the file-load effect
// prevents unrelated store updates (which can produce a new excludePaths
// array reference) from wiping a query the user is currently typing.
@ -198,6 +204,9 @@ export default function QuickOpen(): React.JSX.Element | null {
if (!activeWorktreeId || !worktreePath) {
return
}
// Why: opening a file moves focus into the editor; don't restore focus to
// the surface that was active before QuickOpen opened.
skipReturnFocus()
closeModal()
openFile({
filePath: joinPath(worktreePath, relativePath),
@ -207,7 +216,7 @@ export default function QuickOpen(): React.JSX.Element | null {
mode: 'edit'
})
},
[activeWorktreeId, worktreePath, openFile, closeModal]
[activeWorktreeId, worktreePath, openFile, closeModal, skipReturnFocus]
)
const handleOpenChange = useCallback(
@ -224,11 +233,16 @@ export default function QuickOpen(): React.JSX.Element | null {
e.preventDefault()
}, [])
const handleOpenAutoFocus = useCallback(() => {
captureReturnFocus()
}, [captureReturnFocus])
return (
<CommandDialog
open={visible}
onOpenChange={handleOpenChange}
shouldFilter={false}
onOpenAutoFocus={handleOpenAutoFocus}
onCloseAutoFocus={handleCloseAutoFocus}
title={translate('auto.components.QuickOpen.ec31e058f7', 'Go to file')}
description={translate('auto.components.QuickOpen.9e97f08d0f', 'Search for a file to open')}

View File

@ -1863,6 +1863,32 @@ function RemoteBrowserPagePane({
})
}, [isActive])
useEffect(() => {
if (!isActive) {
return
}
const handleBrowserFocusRequest = (event: Event): void => {
const detail = (event as CustomEvent<BrowserFocusRequestDetail>).detail
if (!detail || detail.pageId !== browserTab.id) {
return
}
const focusTarget = consumeBrowserFocusRequest(browserTab.id)
if (!focusTarget) {
return
}
if (focusTarget === 'address-bar') {
addressBarInputRef.current?.focus()
addressBarInputRef.current?.select()
return
}
const target = imageRef.current ?? remoteViewportRef.current
target?.focus()
}
window.addEventListener(ORCA_BROWSER_FOCUS_REQUEST_EVENT, handleBrowserFocusRequest)
return () =>
window.removeEventListener(ORCA_BROWSER_FOCUS_REQUEST_EVENT, handleBrowserFocusRequest)
}, [browserTab.id, isActive])
const runRemoteNavigation = useCallback(
async (
method: 'browser.goto' | 'browser.back' | 'browser.forward' | 'browser.reload',
@ -2515,6 +2541,7 @@ function RemoteBrowserPagePane({
</div>
<div
ref={remoteViewportRef}
tabIndex={-1}
className="relative min-h-0 flex-1 overflow-hidden bg-background"
>
{frameUrl ? (

View File

@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { resolveModalReturnFocusAction } from './modal-return-focus-action'
describe('resolveModalReturnFocusAction', () => {
it('returns none when nothing was captured', () => {
expect(resolveModalReturnFocusAction(null)).toEqual({ kind: 'none' })
})
it('routes browser surfaces through the browser focus channel', () => {
expect(
resolveModalReturnFocusAction({
tabType: 'browser',
worktreeId: 'wt-1',
browserPageId: 'page-1',
browserTarget: 'address-bar',
terminalTabId: null,
terminalLeafId: null
})
).toEqual({ kind: 'browser', pageId: 'page-1', target: 'address-bar' })
})
it('falls back to the generic surface when a browser tab has no active page', () => {
expect(
resolveModalReturnFocusAction({
tabType: 'browser',
worktreeId: 'wt-1',
browserPageId: null,
browserTarget: 'webview',
terminalTabId: null,
terminalLeafId: null
})
).toEqual({ kind: 'surface' })
})
it('restores a terminal tab through the scoped terminal focus path', () => {
expect(
resolveModalReturnFocusAction({
tabType: 'terminal',
worktreeId: 'wt-1',
browserPageId: null,
browserTarget: 'webview',
terminalTabId: 'terminal-1',
terminalLeafId: 'leaf-1'
})
).toEqual({ kind: 'terminal', tabId: 'terminal-1', leafId: 'leaf-1' })
})
it('restores the editor surface before falling back to terminal focus', () => {
expect(
resolveModalReturnFocusAction({
tabType: 'editor',
worktreeId: 'wt-1',
browserPageId: null,
browserTarget: 'webview',
terminalTabId: null,
terminalLeafId: null
})
).toEqual({ kind: 'editor' })
})
it('restores the simulator surface without using the terminal fallback', () => {
expect(
resolveModalReturnFocusAction({
tabType: 'simulator',
worktreeId: 'wt-1',
browserPageId: null,
browserTarget: 'webview',
terminalTabId: null,
terminalLeafId: null
})
).toEqual({ kind: 'simulator' })
})
it('returns none when there is no worktree to restore into', () => {
expect(
resolveModalReturnFocusAction({
tabType: 'terminal',
worktreeId: null,
browserPageId: null,
browserTarget: 'webview',
terminalTabId: null,
terminalLeafId: null
})
).toEqual({ kind: 'none' })
})
})

View File

@ -0,0 +1,48 @@
import type { BrowserFocusTarget } from '../components/browser-pane/browser-focus'
// The surface that held focus before a modal (QuickOpen, Cmd+J, ...) opened.
// Captured at open time because Radix steals document focus once the dialog
// mounts, so the raw activeElement is gone by close time.
export type ModalReturnFocusSurface = {
tabType: 'browser' | 'editor' | 'terminal' | 'simulator'
worktreeId: string | null
browserPageId: string | null
browserTarget: BrowserFocusTarget
terminalTabId: string | null
terminalLeafId: string | null
}
export type ModalReturnFocusAction =
| { kind: 'browser'; pageId: string; target: BrowserFocusTarget }
| { kind: 'terminal'; tabId: string; leafId: string | null }
| { kind: 'editor' }
| { kind: 'simulator' }
| { kind: 'surface' }
| { kind: 'none' }
// Why: a browser page lives in a separate webContents, so focus must route
// through the browser focus request channel. Other surfaces need type-specific
// DOM focus so a hidden xterm cannot steal focus from the active editor.
export function resolveModalReturnFocusAction(
captured: ModalReturnFocusSurface | null
): ModalReturnFocusAction {
if (!captured) {
return { kind: 'none' }
}
if (captured.tabType === 'browser' && captured.browserPageId) {
return { kind: 'browser', pageId: captured.browserPageId, target: captured.browserTarget }
}
if (captured.tabType === 'terminal' && captured.terminalTabId) {
return { kind: 'terminal', tabId: captured.terminalTabId, leafId: captured.terminalLeafId }
}
if (captured.tabType === 'editor' && captured.worktreeId) {
return { kind: 'editor' }
}
if (captured.tabType === 'simulator' && captured.worktreeId) {
return { kind: 'simulator' }
}
if (captured.worktreeId) {
return { kind: 'surface' }
}
return { kind: 'none' }
}

View File

@ -0,0 +1,218 @@
// @vitest-environment happy-dom
import { act, useEffect } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '../store'
import { focusTerminalTabSurface } from '../lib/focus-terminal-tab-surface'
import { ORCA_BROWSER_FOCUS_REQUEST_EVENT } from '../components/browser-pane/browser-focus'
import { useModalReturnFocus } from './useModalReturnFocus'
vi.mock('../lib/focus-terminal-tab-surface', () => ({
focusTerminalTabSurface: vi.fn()
}))
let root: Root | null = null
let container: HTMLDivElement | null = null
let latestCaptureReturnFocus: (() => void) | null = null
let latestSkipReturnFocus: (() => void) | null = null
let nextAnimationFrameId = 1
let animationFrames = new Map<number, FrameRequestCallback>()
function installAnimationFrameStubs(): void {
nextAnimationFrameId = 1
animationFrames = new Map()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback): number => {
const id = nextAnimationFrameId
nextAnimationFrameId += 1
animationFrames.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', (id: number): void => {
animationFrames.delete(id)
})
}
function flushAnimationFrames(): void {
for (let i = 0; i < 10 && animationFrames.size > 0; i += 1) {
const pending = Array.from(animationFrames.values())
animationFrames.clear()
for (const callback of pending) {
callback(0)
}
}
}
function Probe({ visible }: { visible: boolean }): null {
const { captureReturnFocus, skipReturnFocus } = useModalReturnFocus(visible)
useEffect(() => {
latestCaptureReturnFocus = captureReturnFocus
latestSkipReturnFocus = skipReturnFocus
}, [captureReturnFocus, skipReturnFocus])
return null
}
async function renderProbe(visible: boolean): Promise<void> {
if (!container) {
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
}
await act(async () => {
root?.render(<Probe visible={visible} />)
})
}
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount()
})
}
root = null
container?.remove()
container = null
latestCaptureReturnFocus = null
latestSkipReturnFocus = null
document.body.innerHTML = ''
useAppStore.setState({
activeWorktreeId: null,
activeTabType: 'terminal',
activeTabId: null,
activeTabIdByWorktree: {},
activeBrowserTabId: null,
browserTabsByWorktree: {},
terminalLayoutsByTabId: {}
})
vi.unstubAllGlobals()
vi.clearAllMocks()
})
describe('useModalReturnFocus', () => {
it('returns focus to the editor even when a terminal textarea is mounted first', async () => {
installAnimationFrameStubs()
useAppStore.setState({ activeWorktreeId: 'wt-1', activeTabType: 'editor' })
const terminalTextarea = document.createElement('textarea')
terminalTextarea.className = 'xterm-helper-textarea'
document.body.append(terminalTextarea)
const monaco = document.createElement('div')
monaco.className = 'monaco-editor'
const editorTextarea = document.createElement('textarea')
monaco.append(editorTextarea)
document.body.append(monaco)
await renderProbe(true)
await renderProbe(false)
flushAnimationFrames()
expect(document.activeElement).toBe(editorTextarea)
expect(focusTerminalTabSurface).not.toHaveBeenCalled()
})
it('returns focus to the captured editor when multiple editors are mounted', async () => {
installAnimationFrameStubs()
useAppStore.setState({ activeWorktreeId: 'wt-1', activeTabType: 'editor' })
const firstEditor = document.createElement('textarea')
const firstMonaco = document.createElement('div')
firstMonaco.className = 'monaco-editor'
firstMonaco.append(firstEditor)
document.body.append(firstMonaco)
const secondEditor = document.createElement('textarea')
const secondMonaco = document.createElement('div')
secondMonaco.className = 'monaco-editor'
secondMonaco.append(secondEditor)
document.body.append(secondMonaco)
await renderProbe(false)
secondEditor.focus()
latestCaptureReturnFocus?.()
await renderProbe(true)
firstEditor.focus()
await renderProbe(false)
flushAnimationFrames()
expect(document.activeElement).toBe(secondEditor)
})
it('captures browser address-bar focus before dialog autofocus moves focus', async () => {
installAnimationFrameStubs()
useAppStore.setState({
activeWorktreeId: 'wt-1',
activeTabType: 'browser',
activeBrowserTabId: 'browser-1',
browserTabsByWorktree: {
'wt-1': [
{
id: 'browser-1',
worktreeId: 'wt-1',
activePageId: 'page-1',
pageIds: ['page-1'],
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
]
}
})
const addressBar = document.createElement('input')
addressBar.dataset.orcaBrowserAddressBar = 'true'
document.body.append(addressBar)
const dialogInput = document.createElement('input')
document.body.append(dialogInput)
const focusRequests: unknown[] = []
window.addEventListener(ORCA_BROWSER_FOCUS_REQUEST_EVENT, (event) => {
focusRequests.push((event as CustomEvent).detail)
})
await renderProbe(false)
addressBar.focus()
latestCaptureReturnFocus?.()
await renderProbe(true)
dialogInput.focus()
await renderProbe(false)
expect(focusRequests).toEqual([{ pageId: 'page-1', target: 'address-bar' }])
})
it('uses the scoped terminal focus helper for terminal surfaces', async () => {
installAnimationFrameStubs()
useAppStore.setState({
activeWorktreeId: 'wt-1',
activeTabType: 'terminal',
activeTabId: 'terminal-global',
activeTabIdByWorktree: { 'wt-1': 'terminal-1' },
terminalLayoutsByTabId: {
'terminal-1': { root: null, activeLeafId: 'leaf-1', expandedLeafId: null }
}
})
await renderProbe(true)
await renderProbe(false)
expect(focusTerminalTabSurface).toHaveBeenCalledWith('terminal-1', 'leaf-1')
})
it('skips return focus when the close action already moved focus', async () => {
installAnimationFrameStubs()
useAppStore.setState({ activeWorktreeId: 'wt-1', activeTabType: 'editor' })
const monaco = document.createElement('div')
monaco.className = 'monaco-editor'
const editorTextarea = document.createElement('textarea')
monaco.append(editorTextarea)
document.body.append(monaco)
await renderProbe(true)
latestSkipReturnFocus?.()
await renderProbe(false)
flushAnimationFrames()
expect(document.activeElement).not.toBe(editorTextarea)
expect(focusTerminalTabSurface).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,196 @@
import { useCallback, useEffect, useRef } from 'react'
import { useAppStore } from '../store'
import { focusTerminalTabSurface } from '../lib/focus-terminal-tab-surface'
import {
ORCA_BROWSER_FOCUS_REQUEST_EVENT,
queueBrowserFocusRequest,
type BrowserFocusRequestDetail
} from '../components/browser-pane/browser-focus'
import {
resolveModalReturnFocusAction,
type ModalReturnFocusSurface
} from './modal-return-focus-action'
function isRestorableFocusedElement(element: HTMLElement | null): element is HTMLElement {
return element !== null && element !== document.body && element !== document.documentElement
}
/**
* Restores keyboard focus to the surface that was active before a modal opened.
*
* Why: Radix dialogs (QuickOpen, Cmd+J) prevent the default close-time focus
* restoration to avoid landing on a stale trigger, but must then return focus
* themselves otherwise dismissing the dialog with Esc leaves the active
* terminal/editor/browser panel unfocused. Capture happens on open because
* Radix moves document focus into the dialog before the close fires.
*/
export function useModalReturnFocus(visible: boolean): {
captureReturnFocus: () => void
skipReturnFocus: () => void
} {
const capturedRef = useRef<ModalReturnFocusSurface | null>(null)
const capturedElementRef = useRef<HTMLElement | null>(null)
const skipRef = useRef(false)
const wasVisibleRef = useRef(false)
const outerFrameRef = useRef<number | null>(null)
const innerFrameRef = useRef<number | null>(null)
const cancelFrames = useCallback((): void => {
if (outerFrameRef.current !== null) {
cancelAnimationFrame(outerFrameRef.current)
outerFrameRef.current = null
}
if (innerFrameRef.current !== null) {
cancelAnimationFrame(innerFrameRef.current)
innerFrameRef.current = null
}
}, [])
useEffect(() => cancelFrames, [cancelFrames])
const focusCapturedElement = useCallback((): boolean => {
const target = capturedElementRef.current
if (!isRestorableFocusedElement(target) || !target.isConnected) {
return false
}
target.focus()
return document.activeElement === target || target.contains(document.activeElement)
}, [])
const focusFirstMatchingSurface = useCallback(
(selectors: string[]): void => {
cancelFrames()
outerFrameRef.current = requestAnimationFrame(() => {
outerFrameRef.current = null
innerFrameRef.current = requestAnimationFrame(() => {
innerFrameRef.current = null
for (const selector of selectors) {
const target = document.querySelector(selector) as HTMLElement | null
if (!target) {
continue
}
target.focus()
if (document.activeElement === target || target.contains(document.activeElement)) {
return
}
}
})
})
},
[cancelFrames]
)
// Why: a double rAF lets the dialog finish unmounting and the destination
// surface settle before we focus it; editor surfaces own varied focusable DOM.
const focusEditorSurface = useCallback((): void => {
if (focusCapturedElement()) {
return
}
focusFirstMatchingSurface([
'.monaco-editor textarea',
'.rich-markdown-editor[contenteditable="true"]',
'.markdown-preview'
])
}, [focusCapturedElement, focusFirstMatchingSurface])
const focusSimulatorSurface = useCallback((): void => {
if (focusCapturedElement()) {
return
}
focusFirstMatchingSurface(['[data-orca-emulator-frame="true"] [tabindex]'])
}, [focusCapturedElement, focusFirstMatchingSurface])
const focusFallbackSurface = useCallback((): void => {
focusFirstMatchingSurface(['.xterm-helper-textarea', '.monaco-editor textarea'])
}, [focusFirstMatchingSurface])
const requestBrowserFocus = useCallback((detail: BrowserFocusRequestDetail): void => {
queueBrowserFocusRequest(detail)
window.dispatchEvent(new CustomEvent(ORCA_BROWSER_FOCUS_REQUEST_EVENT, { detail }))
}, [])
const captureReturnFocus = useCallback((): void => {
const state = useAppStore.getState()
const worktreeId = state.activeWorktreeId
const tabType = state.activeTabType
const activeElement =
document.activeElement instanceof HTMLElement ? document.activeElement : null
const browserPageId =
worktreeId && tabType === 'browser'
? ((state.browserTabsByWorktree[worktreeId] ?? []).find(
(workspace) => workspace.id === state.activeBrowserTabId
)?.activePageId ?? null)
: null
const terminalTabId =
worktreeId && tabType === 'terminal'
? (state.activeTabIdByWorktree[worktreeId] ?? state.activeTabId)
: null
const terminalLeafId = terminalTabId
? (state.terminalLayoutsByTabId[terminalTabId]?.activeLeafId ?? null)
: null
// Why: this can be called from Radix onOpenAutoFocus, before focus moves
// into the dialog, preserving address-bar/editor/simulator identity.
const browserTarget =
tabType === 'browser' && activeElement?.closest('[data-orca-browser-address-bar="true"]')
? 'address-bar'
: 'webview'
capturedElementRef.current = isRestorableFocusedElement(activeElement) ? activeElement : null
capturedRef.current = {
tabType,
worktreeId,
browserPageId,
browserTarget,
terminalTabId,
terminalLeafId
}
skipRef.current = false
}, [])
useEffect(() => {
if (visible && !wasVisibleRef.current) {
cancelFrames()
if (!capturedRef.current) {
captureReturnFocus()
}
skipRef.current = false
}
if (!visible && wasVisibleRef.current) {
const action = resolveModalReturnFocusAction(skipRef.current ? null : capturedRef.current)
capturedRef.current = null
if (action.kind === 'browser') {
cancelFrames()
requestBrowserFocus({ pageId: action.pageId, target: action.target })
} else if (action.kind === 'terminal') {
cancelFrames()
focusTerminalTabSurface(action.tabId, action.leafId)
} else if (action.kind === 'editor') {
focusEditorSurface()
} else if (action.kind === 'simulator') {
focusSimulatorSurface()
} else if (action.kind === 'surface') {
focusFallbackSurface()
}
capturedElementRef.current = null
}
wasVisibleRef.current = visible
}, [
visible,
cancelFrames,
captureReturnFocus,
focusEditorSurface,
focusFallbackSurface,
focusSimulatorSurface,
requestBrowserFocus
])
// Why: callers invoke this when the close itself moves focus (e.g. opening a
// file focuses the editor) so we don't yank focus back to the prior surface.
const skipReturnFocus = useCallback((): void => {
skipRef.current = true
}, [])
return { captureReturnFocus, skipReturnFocus }
}