fix(terminal): recover floating input after app refocus (#9233)

* fix(terminal): recover floating input after app refocus

* fix(terminal): preserve newer focus ownership
This commit is contained in:
Brennan Benson 2026-07-17 15:10:08 -07:00 committed by GitHub
parent e6933fed84
commit e70842fe2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 471 additions and 14 deletions

View File

@ -106,12 +106,14 @@ const mocks = vi.hoisted(() => ({
getFloatingMarkdownDirectory: vi.fn(),
getFloatingTerminalCwd: vi.fn(),
getInstallStatus: vi.fn(),
isTerminalImeInputContextRefreshing: vi.fn(),
isWebRuntimeSessionActive: vi.fn(),
markFileDirty: vi.fn(),
makePreviewFilePermanent: vi.fn(),
openFile: vi.fn(),
pickFloatingMarkdownDocument: vi.fn(),
pinFile: vi.fn(),
setFloatingTerminalInputFocused: vi.fn(),
setActiveTab: vi.fn(),
setRenamingTabId: vi.fn(),
setTabColor: vi.fn(),
@ -185,6 +187,10 @@ vi.mock('@/components/terminal-pane/TerminalPane', () => ({
}
}))
vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', () => ({
isTerminalImeInputContextRefreshing: mocks.isTerminalImeInputContextRefreshing
}))
vi.mock('@/components/browser-pane/BrowserPane', () => ({
default: function BrowserPane() {
return null
@ -744,6 +750,7 @@ describe('FloatingTerminalPanel close behavior', () => {
mocks.getFloatingMarkdownDirectory.mockResolvedValue('/tmp/orca/floating-notes')
mocks.getFloatingTerminalCwd.mockResolvedValue('/tmp/orca')
mocks.getInstallStatus.mockResolvedValue({ state: 'installed', pathConfigured: true })
mocks.isTerminalImeInputContextRefreshing.mockReturnValue(false)
mocks.isWebRuntimeSessionActive.mockReturnValue(false)
mocks.pickFloatingMarkdownDocument.mockResolvedValue(null)
const localStorage = {
@ -762,7 +769,7 @@ describe('FloatingTerminalPanel close behavior', () => {
},
browser: { notifyActiveTabChanged: vi.fn() },
cli: { getInstallStatus: mocks.getInstallStatus },
ui: { setFloatingTerminalInputFocused: vi.fn() }
ui: { setFloatingTerminalInputFocused: mocks.setFloatingTerminalInputFocused }
},
innerHeight: 800,
innerWidth: 1200,
@ -799,6 +806,110 @@ describe('FloatingTerminalPanel close behavior', () => {
expect(getPanelClassName(element)).toContain('z-30')
})
it('refreshes terminal native input focus when the floating panel opens', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' })])
await renderPanel(true)
runEffects()
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith(
'tab-1',
null,
expect.objectContaining({
onImeRefocusSkipped: expect.any(Function),
refreshImeContext: true
})
)
mocks.setFloatingTerminalInputFocused.mockClear()
mocks.focusTerminalTabSurface.mock.calls[0]?.[2].onImeRefocusSkipped()
expect(mocks.setFloatingTerminalInputFocused).toHaveBeenCalledWith(false)
const newerFloatingInput = {
classList: { contains: (token: string) => token === 'xterm-helper-textarea' },
closest: vi.fn().mockReturnValue({})
}
Object.setPrototypeOf(newerFloatingInput, HTMLElement.prototype)
mocks.focusTerminalTabSurface.mock.calls[0]?.[2].onImeRefocusSkipped(newerFloatingInput)
expect(mocks.setFloatingTerminalInputFocused).toHaveBeenLastCalledWith(true)
})
it('preserves and reclaims terminal input ownership across window blur', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' })])
const element = await renderPanel(true)
const panel = findByProp(element, 'data-floating-terminal-panel')
const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() }
const terminalInput = {
blur: vi.fn(),
classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') },
closest: vi.fn((selector: string) => {
if (selector === '[data-floating-terminal-panel]') {
return panelElement
}
return selector === '[data-leaf-id]'
? {
getAttribute: (attribute: string) => (attribute === 'data-leaf-id' ? 'leaf-1' : null)
}
: null
}),
isConnected: true
}
Object.setPrototypeOf(panelElement, HTMLElement.prototype)
Object.setPrototypeOf(terminalInput, HTMLElement.prototype)
attachRef(panel.props.ref, panelElement)
mocks.isTerminalImeInputContextRefreshing.mockReturnValueOnce(true)
const onBlurCapture = panel.props.onBlurCapture as (event: unknown) => void
onBlurCapture({
relatedTarget: null,
target: terminalInput
})
expect(mocks.setFloatingTerminalInputFocused).not.toHaveBeenCalled()
const documentState = {
activeElement: terminalInput as unknown as HTMLElement | null,
addEventListener: vi.fn(),
body: {} as HTMLElement,
removeEventListener: vi.fn()
}
vi.stubGlobal('document', documentState)
runEffects()
const blurListener = vi
.mocked(window.addEventListener)
.mock.calls.findLast(([type]) => type === 'blur')?.[1] as (() => void) | undefined
const focusListener = vi
.mocked(window.addEventListener)
.mock.calls.find(([type]) => type === 'focus')?.[1] as (() => void) | undefined
if (!blurListener || !focusListener) {
throw new Error('floating terminal window focus listeners not registered')
}
blurListener()
expect(terminalInput.blur).not.toHaveBeenCalled()
focusListener()
expect(mocks.setFloatingTerminalInputFocused).toHaveBeenCalledWith(true)
documentState.activeElement = terminalInput as unknown as HTMLElement
blurListener()
documentState.activeElement = documentState.body
mocks.focusTerminalTabSurface.mockClear()
focusListener()
expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled()
documentState.activeElement = terminalInput as unknown as HTMLElement
blurListener()
terminalInput.isConnected = false
documentState.activeElement = documentState.body
focusListener()
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith(
'tab-1',
'leaf-1',
expect.objectContaining({
onlyIfFocusUnclaimed: true,
onImeRefocusSkipped: expect.any(Function),
refreshImeContext: true
})
)
})
it('falls back to default bounds when persisted geometry is malformed', async () => {
getMockedLocalStorage().getItem.mockImplementation((key: string) =>
key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY

View File

@ -12,6 +12,7 @@ import { useContextualTour } from '@/components/contextual-tours/use-contextual-
import TabBar from '@/components/tab-bar/TabBar'
import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id'
import TerminalPane from '@/components/terminal-pane/TerminalPane'
import { isTerminalImeInputContextRefreshing } from '@/components/terminal-pane/terminal-ime-input-context-refresh'
import { Button } from '@/components/ui/button'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useShortcutKeyDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel'
@ -222,6 +223,10 @@ export function FloatingTerminalPanel({
}
const shortcutFocusFrameRef = useRef<number | null>(null)
const shortcutFocusTimeoutRef = useRef<number | null>(null)
const reclaimTerminalInputOnWindowFocusRef = useRef<{
helper: HTMLElement
leafId: string | null
} | null>(null)
const mountedRef = useMountedRef()
const dragRef = useRef<{
pointerId: number
@ -578,7 +583,11 @@ export function FloatingTerminalPanel({
if (!open || !activeTerminalId) {
return
}
focusTerminalTabSurface(activeTerminalId)
focusTerminalTabSurface(activeTerminalId, null, {
onImeRefocusSkipped: (active) =>
setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(active)),
refreshImeContext: true
})
}, [activeTerminalId, open])
useEffect(() => {
@ -1313,22 +1322,69 @@ export function FloatingTerminalPanel({
const handleWindowBlur = (): void => {
const panel = panelRef.current
const active = document.activeElement
reclaimTerminalInputOnWindowFocusRef.current = null
if (!panel || !(active instanceof HTMLElement) || !panel.contains(active)) {
return
}
// Why: browser webviews focus out-of-process and do not emit renderer
// pointerdown events, so release floating ownership on renderer blur too.
setFloatingTerminalInputFocusedInMain(false)
if (isFloatingWorkspaceTerminalInputTarget(active)) {
// Why: the terminal focus lifecycle preserves this exact helper across
// app blur so macOS can rebuild its native input context on return.
reclaimTerminalInputOnWindowFocusRef.current = {
helper: active,
leafId: active.closest('[data-leaf-id]')?.getAttribute('data-leaf-id') ?? null
}
return
}
active.blur()
}
const handleWindowFocus = (): void => {
const reclaim = reclaimTerminalInputOnWindowFocusRef.current
if (!reclaim) {
return
}
reclaimTerminalInputOnWindowFocusRef.current = null
const panel = panelRef.current
const active = document.activeElement
if (
panel &&
active instanceof HTMLElement &&
panel.contains(active) &&
isFloatingWorkspaceTerminalInputTarget(active)
) {
setFloatingTerminalInputFocusedInMain(true)
return
}
if ((active === null || active === document.body) && activeTerminalId) {
if (reclaim.helper.isConnected && panel?.contains(reclaim.helper)) {
// Why: TerminalPane owns exact-helper reclaim and IME refresh. Avoid
// racing it with a second floating-layer blur/refocus cycle.
return
}
// Why: only a helper that genuinely remounted while backgrounded needs
// tab/leaf recovery; the shared TerminalPane owner cannot reclaim it.
focusTerminalTabSurface(activeTerminalId, reclaim.leafId, {
onlyIfFocusUnclaimed: true,
onImeRefocusSkipped: (active) =>
setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(active)),
refreshImeContext: true
})
}
}
document.addEventListener('pointerdown', handleOutsidePointerDown, true)
window.addEventListener('blur', handleWindowBlur)
window.addEventListener('focus', handleWindowFocus)
return () => {
reclaimTerminalInputOnWindowFocusRef.current = null
document.removeEventListener('pointerdown', handleOutsidePointerDown, true)
window.removeEventListener('blur', handleWindowBlur)
window.removeEventListener('focus', handleWindowFocus)
}
}, [open])
}, [activeTerminalId, open])
const handleDragStart = (event: React.PointerEvent<HTMLDivElement>): void => {
if (maximized) {
return
@ -1423,7 +1479,13 @@ export function FloatingTerminalPanel({
commitUserBounds({ ...stagedBoundsRef.current, width: rect.width, height: rect.height })
}}
onFocusCapture={(event) => setFloatingTerminalInputFocused(event.target)}
onBlurCapture={(event) => setFloatingTerminalInputFocused(event.relatedTarget)}
onBlurCapture={(event) => {
// Why: keep terminal-first shortcut ownership latched during the
// synchronous macOS IME refresh blur; refocus or its skip callback settles it.
if (!isTerminalImeInputContextRefreshing(event.target)) {
setFloatingTerminalInputFocused(event.relatedTarget)
}
}}
onKeyDownCapture={handleShortcutSurfaceKeyDown}
>
<div className="relative flex h-full w-full min-h-0 flex-col overflow-hidden rounded-lg border border-black/14 bg-card dark:border-white/14">

View File

@ -154,12 +154,13 @@ describe('regular terminal focus ownership', () => {
})
expect(synced).toBe(true)
expect(syncFocused).toHaveBeenCalledWith(true)
expect(syncFocused).not.toHaveBeenCalled()
// Why: reclaim is deferred so a newer focus owner during reactivation wins.
expect(focus).not.toHaveBeenCalled()
for (const run of scheduled) {
run()
}
expect(syncFocused).toHaveBeenCalledWith(true)
expect(focus).toHaveBeenCalledOnce()
expect(document.activeElement).toBe(helper)
})
@ -203,6 +204,39 @@ describe('regular terminal focus ownership', () => {
expect(document.activeElement).toBe(secondHelper)
})
it('keeps ownership clear when the released helper cannot accept focus', () => {
const pane = appendPane()
const helper = appendHelper(pane)
const syncFocused = vi.fn()
helper.focus()
const releasedHelper = releaseTerminalFocusForWindowBlur({
container: pane,
activeElement: helper,
syncFocused
})
document.body.focus()
syncFocused.mockClear()
vi.spyOn(helper, 'focus').mockImplementation(() => undefined)
const scheduled: (() => void)[] = []
resyncTerminalFocusForWindowFocus({
container: pane,
activeElement: document.activeElement,
syncFocused,
releasedHelper,
isMac: false,
scheduleRefocus: (callback) => scheduled.push(callback)
})
for (const run of scheduled) {
run()
}
expect(document.activeElement).toBe(document.body)
expect(syncFocused).toHaveBeenCalledOnce()
expect(syncFocused).toHaveBeenCalledWith(false)
})
it('does not yank focus back into the terminal if the user clicked elsewhere during reactivation', () => {
// Why: the Linux reclaim path must honor the same "newer focus owner wins"
// guard the macOS path uses, so a click into the sidebar/dialog isn't stolen.
@ -220,6 +254,7 @@ describe('regular terminal focus ownership', () => {
syncFocused
})
document.body.focus()
syncFocused.mockClear()
focus.mockClear()
const scheduled: (() => void)[] = []
@ -238,9 +273,44 @@ describe('regular terminal focus ownership', () => {
}
expect(focus).not.toHaveBeenCalled()
expect(syncFocused).toHaveBeenCalledWith(false)
expect(document.activeElement).toBe(outside)
})
it('does not clear ownership published by a newer terminal during deferred reclaim', () => {
const pane = appendPane()
const helper = appendHelper(pane)
const newerPane = appendPane()
const newerHelper = appendHelper(newerPane)
const syncFocused = vi.fn()
helper.focus()
const releasedHelper = releaseTerminalFocusForWindowBlur({
container: pane,
activeElement: helper,
syncFocused
})
document.body.focus()
syncFocused.mockClear()
const scheduled: (() => void)[] = []
resyncTerminalFocusForWindowFocus({
container: pane,
activeElement: document.activeElement,
syncFocused,
releasedHelper,
isMac: false,
scheduleRefocus: (callback) => scheduled.push(callback)
})
newerHelper.focus()
for (const run of scheduled) {
run()
}
expect(document.activeElement).toBe(newerHelper)
expect(syncFocused).not.toHaveBeenCalled()
})
it('does not reclaim a released helper that was detached from the DOM before refocus', () => {
const pane = appendPane()
const helper = appendHelper(pane)
@ -355,9 +425,35 @@ describe('regular terminal focus ownership', () => {
run()
}
expect(focus).not.toHaveBeenCalled()
expect(syncFocused).toHaveBeenLastCalledWith(false)
expect(document.activeElement).toBe(outside)
})
it('does not clear ownership published by a newer terminal during IME refresh', () => {
const pane = appendPane()
const helper = appendHelper(pane)
const newerHelper = appendHelper(appendPane())
const syncFocused = vi.fn()
helper.focus()
const scheduled: (() => void)[] = []
resyncTerminalFocusForWindowFocus({
container: pane,
activeElement: document.activeElement,
syncFocused,
isMac: true,
scheduleRefocus: (callback) => scheduled.push(callback)
})
syncFocused.mockClear()
newerHelper.focus()
for (const run of scheduled) {
run()
}
expect(document.activeElement).toBe(newerHelper)
expect(syncFocused).not.toHaveBeenCalled()
})
it('skips the blur/refocus cycle on non-macOS platforms', () => {
const pane = appendPane()
const helper = appendHelper(pane)

View File

@ -102,8 +102,6 @@ export function resyncTerminalFocusForWindowFocus(args: {
}
}
args.syncFocused(true)
const reclaimedHelper = helper
// Why: defer the reclaim refocus to the next frame and only take focus if
@ -115,6 +113,7 @@ export function resyncTerminalFocusForWindowFocus(args: {
const schedule = args.scheduleRefocus ?? scheduleNextFrame
schedule(() => {
if (!reclaimedHelper.isConnected) {
syncFocusAfterFailedReclaim(reclaimedHelper.ownerDocument.activeElement, args.syncFocused)
return
}
const active = reclaimedHelper.ownerDocument.activeElement
@ -123,21 +122,44 @@ export function resyncTerminalFocusForWindowFocus(args: {
isDocumentBodyOrNull(active, reclaimedHelper.ownerDocument)
) {
reclaimedHelper.focus()
if (reclaimedHelper.ownerDocument.activeElement === reclaimedHelper) {
args.syncFocused(true)
} else {
syncFocusAfterFailedReclaim(reclaimedHelper.ownerDocument.activeElement, args.syncFocused)
}
return
}
syncFocusAfterFailedReclaim(active, args.syncFocused)
})
return true
}
args.syncFocused(true)
// Why: macOS app reactivation leaves a stale NSTextInputContext on the
// still-focused helper (electron#32307/#34952); non-mac returns false inside.
refreshTerminalImeInputContext(reclaimedHelper, {
isMac: args.isMac,
// Why: if another control wins during the refresh frame, the terminal
// mirror must follow that owner instead of remaining latched true.
onRefocusSkipped: (active) => syncFocusAfterFailedReclaim(active, args.syncFocused),
scheduleRefocus: args.scheduleRefocus
})
return true
}
function syncFocusAfterFailedReclaim(
activeElement: Element | null,
syncFocused: TerminalInputFocusSync
): void {
// Why: a later xterm focusin already published terminal ownership; an older
// deferred reclaim must not overwrite that newer process-wide mirror.
if (!isXtermHelperTextarea(activeElement)) {
syncFocused(false)
}
}
function isNode(value: EventTarget | null): value is Node {
return typeof Node !== 'undefined' && value instanceof Node
}

View File

@ -1,6 +1,9 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { refreshTerminalImeInputContext } from './terminal-ime-input-context-refresh'
import {
isTerminalImeInputContextRefreshing,
refreshTerminalImeInputContext
} from './terminal-ime-input-context-refresh'
describe('refreshTerminalImeInputContext', () => {
beforeEach(() => {
@ -38,6 +41,23 @@ describe('refreshTerminalImeInputContext', () => {
expect(document.activeElement).toBe(helper)
})
it('marks only the synchronous refresh blur so focus ownership can stay latched', () => {
const helper = appendHelper()
let refreshingDuringBlur = false
helper.addEventListener('blur', (event) => {
refreshingDuringBlur = isTerminalImeInputContextRefreshing(event.target)
})
helper.focus()
refreshTerminalImeInputContext(helper, {
isMac: true,
scheduleRefocus: vi.fn()
})
expect(refreshingDuringBlur).toBe(true)
expect(isTerminalImeInputContextRefreshing(helper)).toBe(false)
})
it('does not steal focus if another element grabbed it before the refocus frame', () => {
const helper = appendHelper()
const outside = document.createElement('input')
@ -60,6 +80,47 @@ describe('refreshTerminalImeInputContext', () => {
expect(document.activeElement).toBe(outside)
})
it('reports when a newer focus owner wins the refocus guard', () => {
const helper = appendHelper()
const outside = document.createElement('input')
document.body.appendChild(outside)
const onRefocusSkipped = vi.fn()
const scheduled: (() => void)[] = []
helper.focus()
refreshTerminalImeInputContext(helper, {
isMac: true,
onRefocusSkipped,
scheduleRefocus: (callback) => scheduled.push(callback)
})
outside.focus()
for (const run of scheduled) {
run()
}
expect(onRefocusSkipped).toHaveBeenCalledWith(outside)
})
it('reports when a connected helper cannot accept the scheduled focus', () => {
const helper = appendHelper()
const onRefocusSkipped = vi.fn()
const scheduled: (() => void)[] = []
helper.focus()
refreshTerminalImeInputContext(helper, {
isMac: true,
onRefocusSkipped,
scheduleRefocus: (callback) => scheduled.push(callback)
})
vi.spyOn(helper, 'focus').mockImplementation(() => undefined)
for (const run of scheduled) {
run()
}
expect(document.activeElement).toBe(document.body)
expect(onRefocusSkipped).toHaveBeenCalledWith(document.body)
})
it('skips non-macOS platforms', () => {
const helper = appendHelper()
const blur = vi.spyOn(helper, 'blur')

View File

@ -3,10 +3,18 @@ export type TerminalImeInputContextRefocusScheduler = (callback: () => void) =>
export type TerminalImeInputContextRefreshOptions = {
/** Override the macOS check (tests). Defaults to the navigator user agent. */
isMac?: boolean
/** Called with the settled owner when the scheduled refocus does not land. */
onRefocusSkipped?: (activeElement: Element | null) => void
/** Override the refocus scheduler (tests). Defaults to requestAnimationFrame. */
scheduleRefocus?: TerminalImeInputContextRefocusScheduler
}
const refreshingHelpers = new WeakSet<HTMLElement>()
export function isTerminalImeInputContextRefreshing(target: EventTarget | null): boolean {
return target instanceof HTMLElement && refreshingHelpers.has(target)
}
function isMacUserAgent(): boolean {
return typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
}
@ -38,17 +46,28 @@ export function refreshTerminalImeInputContext(
const ownerDocument = helper.ownerDocument
// Why: Electron/Chromium can keep a stale NSTextInputContext on the xterm
// helper after focus handoffs; blur/refocus rebuilds it so CJK IMEs work.
helper.blur()
refreshingHelpers.add(helper)
try {
helper.blur()
} finally {
refreshingHelpers.delete(helper)
}
const schedule = options.scheduleRefocus ?? scheduleNextFrame
schedule(() => {
const active = ownerDocument.activeElement
if (!helper.isConnected) {
options.onRefocusSkipped?.(ownerDocument.activeElement)
return
}
const active = ownerDocument.activeElement
if (active === helper || isDocumentBodyOrNull(active, ownerDocument)) {
helper.focus()
if (ownerDocument.activeElement !== helper) {
options.onRefocusSkipped?.(ownerDocument.activeElement)
}
return
}
options.onRefocusSkipped?.(active)
})
return true

View File

@ -1,8 +1,17 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { focusTerminalTabSurface } from './focus-terminal-tab-surface'
const mocks = vi.hoisted(() => ({
refreshTerminalImeInputContext: vi.fn()
}))
vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', () => ({
refreshTerminalImeInputContext: mocks.refreshTerminalImeInputContext
}))
describe('focusTerminalTabSurface', () => {
afterEach(() => {
mocks.refreshTerminalImeInputContext.mockClear()
vi.unstubAllGlobals()
})
@ -28,6 +37,50 @@ describe('focusTerminalTabSurface', () => {
expect(textarea.focus).toHaveBeenCalled()
})
it('optionally refreshes the focused helper native input context', () => {
flushAnimationFrames()
const textarea = { focus: vi.fn() }
vi.stubGlobal('document', {
querySelector: vi.fn((selector: string) =>
selector === '[data-terminal-tab-id="tab-1"] .xterm-helper-textarea' ? textarea : null
)
})
focusTerminalTabSurface('tab-1', null, { refreshImeContext: true })
expect(textarea.focus).toHaveBeenCalledOnce()
expect(mocks.refreshTerminalImeInputContext).toHaveBeenCalledWith(textarea, {
onRefocusSkipped: undefined
})
})
it('does not steal focus from a newer owner during guarded remount recovery', () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const textarea = { focus: vi.fn() }
const body = {}
const outside = {}
const documentState = {
activeElement: body as unknown,
body,
querySelector: vi.fn((selector: string) =>
selector === '[data-terminal-tab-id="tab-1"] .xterm-helper-textarea' ? textarea : null
)
}
vi.stubGlobal('document', documentState)
focusTerminalTabSurface('tab-1', null, { onlyIfFocusUnclaimed: true })
frames.shift()?.(0)
documentState.activeElement = outside
frames.shift()?.(0)
expect(textarea.focus).not.toHaveBeenCalled()
})
it('does not steal focus while inline tab rename is open', () => {
flushAnimationFrames()
const textarea = { focus: vi.fn() }

View File

@ -1,3 +1,5 @@
import { refreshTerminalImeInputContext } from '@/components/terminal-pane/terminal-ime-input-context-refresh'
/**
* Move keyboard focus into the xterm instance for a freshly-mounted terminal
* tab. Handles the two-step race where React must first mount the new
@ -11,6 +13,29 @@ function cssAttributeString(value: string): string {
let pendingFocusFrameIds: number[] = []
type FocusTerminalTabSurfaceOptions = {
onlyIfFocusUnclaimed?: boolean
onImeRefocusSkipped?: (activeElement: Element | null) => void
refreshImeContext?: boolean
}
function focusTerminalHelper(helper: HTMLElement, options: FocusTerminalTabSurfaceOptions): void {
if (options.onlyIfFocusUnclaimed) {
const active = document.activeElement
if (active !== helper && active !== null && active !== document.body) {
return
}
}
helper.focus()
if (options.refreshImeContext) {
// Why: a CSS-hidden, long-lived xterm can retain a stale macOS native text
// input context even after DOM focus returns; blur/refocus rebuilds it.
refreshTerminalImeInputContext(helper, {
onRefocusSkipped: options.onImeRefocusSkipped
})
}
}
function cancelPendingFocusFrames(): void {
if (typeof cancelAnimationFrame === 'function') {
for (const frameId of pendingFocusFrameIds) {
@ -29,7 +54,11 @@ function canUseSinglePaneStaleLeafFallback(tabId: string, leafId: string): boole
return expectedLeafIds?.length === 1 && !expectedLeafIds.includes(leafId)
}
export function focusTerminalTabSurface(tabId: string, leafId?: string | null): void {
export function focusTerminalTabSurface(
tabId: string,
leafId?: string | null,
options: FocusTerminalTabSurfaceOptions = {}
): void {
cancelPendingFocusFrames()
const firstFrameId = requestAnimationFrame(() => {
pendingFocusFrameIds = pendingFocusFrameIds.filter((frameId) => frameId !== firstFrameId)
@ -46,7 +75,7 @@ export function focusTerminalTabSurface(tabId: string, leafId?: string | null):
: `[data-terminal-tab-id="${escapedTabId}"] .xterm-helper-textarea`
const scoped = document.querySelector(scopedSelector) as HTMLElement | null
if (scoped) {
scoped.focus()
focusTerminalHelper(scoped, options)
return
}
if (leafId) {
@ -62,13 +91,17 @@ export function focusTerminalTabSurface(tabId: string, leafId?: string | null):
)
if (tabScopedHelpers.length === 1) {
const fallback = tabScopedHelpers.item(0) as HTMLElement | null
fallback?.focus()
if (fallback) {
focusTerminalHelper(fallback, options)
}
return
}
return
}
const fallback = document.querySelector('.xterm-helper-textarea') as HTMLElement | null
fallback?.focus()
if (fallback) {
focusTerminalHelper(fallback, options)
}
})
pendingFocusFrameIds.push(secondFrameId)
})