fix(chat): stop terminal focus recovery from stealing Cmd+C in the Chat UI (#18751)

* fix(chat): preserve message copy focus

* fix(chat): scope covered-xterm focus guard to the chat leaf

Chat view mode is a tab flag, but only the chat leaf's xterm is covered. In a
split chat tab with a terminal leaf active, the tab-level guard skipped the
terminal's resume focus and the tab-wide deferred focus then landed on the
covered chat xterm. Decide per pane: resume and window-wake read the active
pane's container, and the surface focus query skips leaves hosting the chat
root.

* fix(chat): close covered terminal focus fallbacks

---------

Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-09-06 11:31:07 -07:00
committed by GitHub
co-authored by Merge Sim
parent 3be526c5e6
commit c36c23df5f
11 changed files with 288 additions and 23 deletions
@@ -48,6 +48,7 @@ export function TerminalPaneSurface({
dismissTerminalError,
expectedLayoutLeafIdsAttr,
expandedPaneId,
effectiveChatViewMode,
handleCancelClose,
handleConfirmClose,
handleContextMenuToggleNativeChat,
@@ -117,6 +118,7 @@ export function TerminalPaneSurface({
className="absolute inset-0 min-h-0 min-w-0"
data-native-file-drop-target="terminal"
data-terminal-tab-id={tabId}
data-terminal-chat-view={effectiveChatViewMode && activePaneIsChatLeaf ? 'true' : undefined}
data-terminal-layout-leaf-ids={expectedLayoutLeafIdsAttr}
data-pane-title-surface={titleUsesLightSurface ? 'light' : 'dark'}
style={terminalContainerStyle}
@@ -0,0 +1,21 @@
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
const NATIVE_CHAT_COVER_SELECTOR = '.native-chat-pane-shell'
/**
* Leaf container selector that excludes panes whose xterm sits under the native
* chat portal. Chat mode is a tab flag, but only the chat leaf's xterm is
* covered — a split terminal leaf in the same tab must still take focus.
*/
export const UNCOVERED_TERMINAL_LEAF_SELECTOR = `[data-leaf-id]:not(:has(${NATIVE_CHAT_COVER_SELECTOR}))`
export function paneIsCoveredByNativeChat(
pane: { container: Pick<Element, 'querySelector'> } | null | undefined
): boolean {
return pane?.container.querySelector(NATIVE_CHAT_COVER_SELECTOR) != null
}
/** Mirrors focusActivePane's target so the guard tracks exactly the pane that would take focus. */
export function activePaneIsCoveredByNativeChat(manager: PaneManager): boolean {
return paneIsCoveredByNativeChat(manager.getActivePane() ?? manager.getPanes()[0])
}
@@ -70,6 +70,7 @@ function resumeArgs(manager: FakeManager, shouldUseLightTabResume: boolean) {
return {
manager: manager as never as PaneManager,
isActive: true,
isChatViewMode: false,
wasVisible: false,
shouldUseLightTabResume,
captureViewportPositions: vi.fn(() => new Map()),
@@ -202,6 +203,32 @@ describe('resumeTerminalVisibility reveal repaint', () => {
expect(manager.fitAllPanes).not.toHaveBeenCalled()
})
it.each([
['light', true],
['heavy', false]
])('does not focus the covered terminal on a %s chat reveal', async (_path, lightResume) => {
const manager = createManager()
const args = resumeArgs(manager, lightResume)
args.isChatViewMode = true
const { focusActivePane } = vi.mocked(await import('./pane-helpers'))
resumeTerminalVisibility(args)
expect(focusActivePane).not.toHaveBeenCalled()
})
it.each([
['light', true],
['heavy', false]
])('keeps focusing an active terminal on a %s reveal', async (_path, lightResume) => {
const manager = createManager()
const { focusActivePane } = vi.mocked(await import('./pane-helpers'))
resumeTerminalVisibility(resumeArgs(manager, lightResume))
expect(focusActivePane).toHaveBeenCalledWith(manager)
})
it('checks each pane for a stale WebGL backing on a light tab reveal', () => {
const first = { terminal: { name: 'pane-a' } }
const second = { terminal: { name: 'pane-b' } }
@@ -233,6 +260,7 @@ describe('resumeTerminalVisibility reveal repaint', () => {
recoverVisibleTerminalWindowWake({
manager: manager as never as PaneManager,
isActive: true,
isChatViewMode: false,
clearGlyphAtlases: false
})
@@ -240,6 +268,20 @@ describe('resumeTerminalVisibility reveal repaint', () => {
expect(manager.fitAllPanes).not.toHaveBeenCalled()
})
it('does not focus the covered terminal during chat window-wake recovery', async () => {
const manager = createManager()
const { focusActivePane } = vi.mocked(await import('./pane-helpers'))
recoverVisibleTerminalWindowWake({
manager: manager as never as PaneManager,
isActive: true,
isChatViewMode: true,
clearGlyphAtlases: false
})
expect(focusActivePane).not.toHaveBeenCalled()
})
it('repairs WebGL canvas backing-store dpr on window wake', () => {
// Clamshell undock: dpr changes while the pane stayed "visible" with a
// stale backing store; tab-reveal is not in the path.
@@ -252,6 +294,7 @@ describe('resumeTerminalVisibility reveal repaint', () => {
recoverVisibleTerminalWindowWake({
manager: manager as never as PaneManager,
isActive: true,
isChatViewMode: false,
clearGlyphAtlases: false
})
@@ -275,6 +318,7 @@ describe('resumeTerminalVisibility reveal repaint', () => {
recoverVisibleTerminalWindowWake({
manager: manager as never as PaneManager,
isActive: true,
isChatViewMode: false,
clearGlyphAtlases: false
})
@@ -314,6 +358,7 @@ describe('resumeTerminalVisibility reveal repaint', () => {
recoverVisibleTerminalWindowWake({
manager: manager as never as PaneManager,
isActive: true,
isChatViewMode: false,
clearGlyphAtlases: false
})
@@ -330,6 +375,7 @@ describe('resumeTerminalVisibility reveal repaint', () => {
recoverVisibleTerminalWindowWake({
manager: manager as never as PaneManager,
isActive: true,
isChatViewMode: false,
clearGlyphAtlases: false
})
@@ -341,6 +387,7 @@ describe('resumeTerminalVisibility reveal repaint', () => {
recoverVisibleTerminalWindowWake({
manager: manager as never as PaneManager,
isActive: false,
isChatViewMode: false,
clearGlyphAtlases: true
})
@@ -356,6 +403,7 @@ describe('resumeTerminalVisibility reveal repaint', () => {
recoverVisibleTerminalWindowWake({
manager: manager as never as PaneManager,
isActive: false,
isChatViewMode: false,
clearGlyphAtlases: true
})
@@ -376,6 +424,7 @@ describe('resumeTerminalVisibility reveal repaint', () => {
recoverVisibleTerminalWindowWake({
manager: manager as never as PaneManager,
isActive: false,
isChatViewMode: false,
clearGlyphAtlases: false
})
@@ -31,6 +31,7 @@ export type TerminalHiddenReason = 'surface' | 'tab'
type ResumeTerminalVisibilityArgs = {
manager: PaneManager
isActive: boolean
isChatViewMode: boolean
wasVisible: boolean
shouldUseLightTabResume: boolean
captureViewportPositions: (useRememberedSnapshots: boolean) => Map<number, ScrollState>
@@ -54,12 +55,14 @@ type HideTerminalVisibilityResult = {
type RecoverVisibleTerminalWindowWakeArgs = {
manager: PaneManager
isActive: boolean
isChatViewMode: boolean
clearGlyphAtlases: boolean
}
export function resumeTerminalVisibility({
manager,
isActive,
isChatViewMode,
wasVisible,
shouldUseLightTabResume,
captureViewportPositions,
@@ -100,13 +103,13 @@ export function resumeTerminalVisibility({
// cell size — refit so cols/rows match before the overlay settles.
manager.fitAllRevealedPanes()
}
if (isActive) {
if (isActive && !isChatViewMode) {
focusActivePane(manager)
}
} else {
// fitAllRevealedPanes flushes after WebGL reattaches, avoiding a redundant
// full refresh in the suspended DOM renderer while preserving first paint.
repairedDpr = resumeTerminalVisibilityHeavy(manager, isActive)
repairedDpr = resumeTerminalVisibilityHeavy(manager, isActive && !isChatViewMode)
}
enforceTerminalViewportIntents(manager)
if (!shouldUseLightTabResume) {
@@ -174,6 +177,7 @@ export function hideTerminalVisibility({
export function recoverVisibleTerminalWindowWake({
manager,
isActive,
isChatViewMode,
clearGlyphAtlases
}: RecoverVisibleTerminalWindowWakeArgs): void {
// Why: macOS screensaver/display wake can leave xterm visible but with a
@@ -201,7 +205,7 @@ export function recoverVisibleTerminalWindowWake({
manager.resumeRendering()
// Why: wake re-attaches WebGL — same transient cell-metric wobble guard as the heavy resume.
manager.fitAllRevealedPanes()
if (isActive) {
if (isActive && !isChatViewMode) {
focusActivePane(manager)
}
enforceTerminalViewportIntents(manager)
@@ -226,7 +230,7 @@ function requestLightTabBacklogRecovery(manager: PaneManager): void {
}
}
function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean): boolean {
function resumeTerminalVisibilityHeavy(manager: PaneManager, shouldFocus: boolean): boolean {
// Why: hidden panes can accumulate large PTY bursts while Chromium is
// occluded. Drain a bounded slice before fitting; the scheduler keeps
// ordering and continues the rest asynchronously so return-to-app does
@@ -254,7 +258,7 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean):
// from the DOM renderer's; a raw fit here reflows on a transient one-column-off
// grid and garbles diff-painting inline TUIs (grok minimize→restore).
manager.fitAllRevealedPanes()
if (isActive) {
if (shouldFocus) {
focusActivePane(manager)
}
return repairedDpr
@@ -307,6 +307,63 @@ describe('useTerminalPaneGlobalEffects', () => {
vi.advanceTimersByTime(500)
})
it.each([
['skips focus while the chat leaf is active', true],
['keeps focusing an active split terminal leaf', false]
])('chat view mode %s', (_label, covered) => {
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
callback(0)
return 1
})
)
const pane = {
id: 1,
terminal: { name: 'terminal-a' },
container: { querySelector: vi.fn(() => (covered ? {} : null)) }
}
const manager = {
getPanes: vi.fn(() => [pane]),
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
refreshAllPanes: vi.fn(),
suspendRendering: vi.fn(),
fitAllPanes: vi.fn(),
fitAllRevealedPanes: vi.fn(),
getActivePane: vi.fn(() => pane),
setActivePane: vi.fn()
}
registerManagerForReset(manager)
beginHookRender()
useTerminalPaneGlobalEffects({
tabId: 'tab-1',
worktreeId: 'wt-1',
managerRef: { current: manager as never },
containerRef: { current: null },
paneTransportsRef: { current: new Map() },
isActiveRef: { current: false },
isVisibleRef: { current: false },
paneCount: 1,
isSyncFitEnabled: true,
isWorktreeActive: true,
toggleExpandPane: vi.fn(),
isActive: true,
isVisible: true,
isChatViewMode: true
})
expect(pane.container.querySelector).toHaveBeenCalledWith('.native-chat-pane-shell')
if (covered) {
expect(mocks.focusActivePane).not.toHaveBeenCalled()
} else {
expect(mocks.focusActivePane).toHaveBeenCalledWith(manager)
}
})
it('keeps visible active-state updates on the light resume path', () => {
vi.useFakeTimers()
vi.stubGlobal(
@@ -26,6 +26,7 @@ import {
releaseRendererPtyVisibilityClaim,
setRendererPtyVisibilityClaim
} from './pty-renderer-delivery-claims'
import { activePaneIsCoveredByNativeChat } from './native-chat-covered-pane'
type UseTerminalPaneGlobalEffectsArgs = {
tabId: string
@@ -33,6 +34,7 @@ type UseTerminalPaneGlobalEffectsArgs = {
cwd?: string
isActive: boolean
isVisible: boolean
isChatViewMode?: boolean
isWorktreeActive?: boolean
isSyncFitEnabled: boolean
paneCount: number
@@ -66,6 +68,7 @@ export function useTerminalPaneGlobalEffects({
cwd,
isActive,
isVisible,
isChatViewMode = false,
isWorktreeActive = isVisible,
isSyncFitEnabled,
paneCount,
@@ -121,6 +124,7 @@ export function useTerminalPaneGlobalEffects({
})
useTerminalWindowWakeRecovery({
isVisible: rendererVisible,
isChatViewMode,
managerRef,
isActiveRef,
isVisibleRef,
@@ -156,6 +160,9 @@ export function useTerminalPaneGlobalEffects({
resumeTerminalVisibility({
manager,
isActive,
// Why: chat mode is tab-wide, but only the chat leaf's xterm is covered;
// a split terminal leaf that is active must still regain focus on reveal.
isChatViewMode: isChatViewMode && activePaneIsCoveredByNativeChat(manager),
wasVisible,
shouldUseLightTabResume,
captureViewportPositions,
@@ -183,7 +190,7 @@ export function useTerminalPaneGlobalEffects({
wasVisibleRef.current = false
wasWorktreeActiveRef.current = isWorktreeActive
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isActive, isWorktreeActive, rendererVisible])
}, [isActive, isChatViewMode, isWorktreeActive, rendererVisible])
useEffect(() => {
const ptyId = isActive && isVisible && isWorktreeActive ? activeLeafPtyId : null
@@ -25,6 +25,7 @@ export function useTerminalPaneGlobalListeners(controller: TerminalPaneCloseCont
handleRequestClosePane,
handleSearchSelectedText,
handleStartRename,
effectiveChatViewMode,
isActive,
isActiveRef,
isRendererVisible,
@@ -91,6 +92,7 @@ export function useTerminalPaneGlobalListeners(controller: TerminalPaneCloseCont
cwd,
isActive,
isVisible,
isChatViewMode: effectiveChatViewMode,
isWorktreeActive,
isSyncFitEnabled: isRendererVisible || shouldMeasureHiddenStartup,
paneCount,
@@ -61,11 +61,16 @@ describe('useTerminalWindowWakeRecovery', () => {
delete (window as unknown as { api?: unknown }).api
})
function renderWakeRecoveryHook(isVisible = true) {
function renderWakeRecoveryHook(
isVisible = true,
isChatViewMode = false,
wakeManager: PaneManager = manager
) {
return renderHook(() =>
useTerminalWindowWakeRecovery({
isVisible,
managerRef: { current: manager },
isChatViewMode,
managerRef: { current: wakeManager },
isActiveRef: { current: true },
isVisibleRef: { current: true }
})
@@ -83,6 +88,7 @@ describe('useTerminalWindowWakeRecovery', () => {
expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenNthCalledWith(1, {
manager,
isActive: true,
isChatViewMode: false,
clearGlyphAtlases: false
})
@@ -93,6 +99,7 @@ describe('useTerminalWindowWakeRecovery', () => {
expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenNthCalledWith(2, {
manager,
isActive: true,
isChatViewMode: false,
clearGlyphAtlases: true
})
})
@@ -109,6 +116,27 @@ describe('useTerminalWindowWakeRecovery', () => {
expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenLastCalledWith({
manager,
isActive: true,
isChatViewMode: false,
clearGlyphAtlases: false
})
})
it.each([
['covered chat leaf', true],
['split terminal leaf', false]
])('routes chat coverage into wake recovery only for the %s', (_label, covered) => {
const chatManager = {
getActivePane: () => ({ container: { querySelector: () => (covered ? {} : null) } }),
getPanes: () => []
} as unknown as PaneManager
renderWakeRecoveryHook(true, true, chatManager)
window.dispatchEvent(new Event('focus'))
expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenLastCalledWith({
manager: chatManager,
isActive: true,
isChatViewMode: covered,
clearGlyphAtlases: false
})
})
@@ -136,6 +164,7 @@ describe('useTerminalWindowWakeRecovery', () => {
renderHook(() =>
useTerminalWindowWakeRecovery({
isVisible: true,
isChatViewMode: false,
managerRef: { current: manager },
isActiveRef: { current: true },
isVisibleRef: { current: true },
@@ -163,6 +192,7 @@ describe('useTerminalWindowWakeRecovery', () => {
renderHook(() =>
useTerminalWindowWakeRecovery({
isVisible: true,
isChatViewMode: false,
managerRef: { current: manager },
isActiveRef: { current: true },
isVisibleRef: { current: true },
@@ -209,6 +239,7 @@ describe('useTerminalWindowWakeRecovery', () => {
const { unmount } = renderHook(() =>
useTerminalWindowWakeRecovery({
isVisible: true,
isChatViewMode: false,
managerRef: { current: resizeManager },
isActiveRef: { current: true },
isVisibleRef: { current: true }
@@ -238,6 +269,7 @@ describe('useTerminalWindowWakeRecovery', () => {
renderHook(() =>
useTerminalWindowWakeRecovery({
isVisible: true,
isChatViewMode: false,
managerRef,
isActiveRef: { current: true },
isVisibleRef: { current: true }
@@ -265,6 +297,7 @@ describe('useTerminalWindowWakeRecovery', () => {
renderHook(() =>
useTerminalWindowWakeRecovery({
isVisible: true,
isChatViewMode: false,
managerRef: { current: { getPanes: () => [pane] } as unknown as PaneManager },
isActiveRef: { current: true },
isVisibleRef: { current: true }
@@ -295,6 +328,7 @@ describe('useTerminalWindowWakeRecovery', () => {
renderHook(() =>
useTerminalWindowWakeRecovery({
isVisible: true,
isChatViewMode: false,
managerRef: { current: { getPanes: () => [pane] } as unknown as PaneManager },
isActiveRef: { current: true },
isVisibleRef: { current: true }
@@ -5,9 +5,11 @@ import { repairPaneWebglCanvasDpr } from '@/lib/pane-manager/terminal-canvas-dpr
import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer'
import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs'
import type { IDisposable } from '@xterm/xterm'
import { activePaneIsCoveredByNativeChat } from './native-chat-covered-pane'
type UseTerminalWindowWakeRecoveryArgs = {
isVisible: boolean
isChatViewMode: boolean
managerRef: React.RefObject<PaneManager | null>
isActiveRef: React.RefObject<boolean>
isVisibleRef: React.RefObject<boolean>
@@ -22,6 +24,7 @@ const DPR_RECOVERY_RETRY_FRAMES = 16
export function useTerminalWindowWakeRecovery({
isVisible,
isChatViewMode,
managerRef,
isActiveRef,
isVisibleRef,
@@ -85,6 +88,7 @@ export function useTerminalWindowWakeRecovery({
recoverVisibleTerminalWindowWake({
manager,
isActive: isActiveRef.current,
isChatViewMode: isChatViewMode && activePaneIsCoveredByNativeChat(manager),
clearGlyphAtlases
})
if (typeof requestAnimationFrame !== 'function') {
@@ -103,6 +107,7 @@ export function useTerminalWindowWakeRecovery({
recoverVisibleTerminalWindowWake({
manager: settledManager,
isActive: isActiveRef.current,
isChatViewMode: isChatViewMode && activePaneIsCoveredByNativeChat(settledManager),
clearGlyphAtlases: clearGlyphAtlasesOnSettle
})
reassertPanePtySizes()
@@ -199,5 +204,5 @@ export function useTerminalWindowWakeRecovery({
}
unsubscribeSystemResumed?.()
}
}, [isActiveRef, isVisible, isVisibleRef, managerRef, panePtyBindingsRef])
}, [isActiveRef, isChatViewMode, isVisible, isVisibleRef, managerRef, panePtyBindingsRef])
}
@@ -9,6 +9,12 @@ vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', () => (
refreshTerminalImeInputContext: mocks.refreshTerminalImeInputContext
}))
// Why: tab-wide queries skip leaves whose xterm sits under the native chat portal.
const TAB_HELPER_SELECTOR =
'[data-terminal-tab-id="tab-1"] [data-leaf-id]:not(:has(.native-chat-pane-shell)) .xterm-helper-textarea'
const GLOBAL_HELPER_SELECTOR =
'[data-leaf-id]:not(:has(.native-chat-pane-shell)) .xterm-helper-textarea'
describe('focusTerminalTabSurface', () => {
afterEach(() => {
mocks.refreshTerminalImeInputContext.mockClear()
@@ -28,7 +34,7 @@ describe('focusTerminalTabSurface', () => {
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
selector === TAB_HELPER_SELECTOR ? textarea : null
)
})
@@ -42,7 +48,7 @@ describe('focusTerminalTabSurface', () => {
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
selector === TAB_HELPER_SELECTOR ? textarea : null
)
})
@@ -68,7 +74,7 @@ describe('focusTerminalTabSurface', () => {
activeElement: body as unknown,
body,
querySelector: vi.fn((selector: string) =>
selector === '[data-terminal-tab-id="tab-1"] .xterm-helper-textarea' ? textarea : null
selector === TAB_HELPER_SELECTOR ? textarea : null
)
}
vi.stubGlobal('document', documentState)
@@ -89,9 +95,7 @@ describe('focusTerminalTabSurface', () => {
if (selector === '[data-tab-rename-input="true"]') {
return {}
}
return selector === '[data-terminal-tab-id="tab-1"] .xterm-helper-textarea'
? textarea
: null
return selector === TAB_HELPER_SELECTOR ? textarea : null
})
})
@@ -100,6 +104,77 @@ describe('focusTerminalTabSurface', () => {
expect(textarea.focus).not.toHaveBeenCalled()
})
it('does not focus xterm while chat covers the terminal tab', () => {
flushAnimationFrames()
const textarea = { focus: vi.fn() }
vi.stubGlobal('document', {
querySelector: vi.fn((selector: string) => {
if (selector === '[data-terminal-tab-id="tab-1"]') {
return {
getAttribute: (name: string) => (name === 'data-terminal-chat-view' ? 'true' : null)
}
}
return selector === TAB_HELPER_SELECTOR ? textarea : null
})
})
focusTerminalTabSurface('tab-1')
expect(textarea.focus).not.toHaveBeenCalled()
})
it('skips the chat leaf helper when a split chat tab has an active terminal leaf', () => {
flushAnimationFrames()
const coveredTextarea = { focus: vi.fn() }
const terminalTextarea = { focus: vi.fn() }
vi.stubGlobal('document', {
querySelector: vi.fn((selector: string) => {
if (selector === '[data-terminal-tab-id="tab-1"]') {
return { getAttribute: () => null }
}
if (selector === TAB_HELPER_SELECTOR) {
return terminalTextarea
}
return selector === '[data-terminal-tab-id="tab-1"] .xterm-helper-textarea'
? coveredTextarea
: null
})
})
focusTerminalTabSurface('tab-1')
expect(terminalTextarea.focus).toHaveBeenCalledOnce()
expect(coveredTextarea.focus).not.toHaveBeenCalled()
})
it('does not use a covered chat helper as the global mount-race fallback', () => {
flushAnimationFrames()
const coveredTextarea = { focus: vi.fn() }
vi.stubGlobal('document', {
querySelector: vi.fn((selector: string) =>
selector === '.xterm-helper-textarea' ? coveredTextarea : null
)
})
focusTerminalTabSurface('tab-1')
expect(coveredTextarea.focus).not.toHaveBeenCalled()
})
it('keeps the global mount-race fallback for an uncovered terminal helper', () => {
flushAnimationFrames()
const textarea = { focus: vi.fn() }
vi.stubGlobal('document', {
querySelector: vi.fn((selector: string) =>
selector === GLOBAL_HELPER_SELECTOR ? textarea : null
)
})
focusTerminalTabSurface('tab-1')
expect(textarea.focus).toHaveBeenCalledOnce()
})
it('falls back to the single tab helper when an old leaf id was reminted', () => {
flushAnimationFrames()
const textarea = { focus: vi.fn() }
@@ -108,7 +183,7 @@ describe('focusTerminalTabSurface', () => {
selector === '[data-terminal-tab-id="tab-1"]' ? { getAttribute: () => 'new-leaf' } : null
),
querySelectorAll: vi.fn((selector: string) =>
selector === '[data-terminal-tab-id="tab-1"] .xterm-helper-textarea'
selector === TAB_HELPER_SELECTOR
? { length: 1, item: () => textarea }
: { length: 0, item: () => null }
)
@@ -129,7 +204,7 @@ describe('focusTerminalTabSurface', () => {
: null
),
querySelectorAll: vi.fn((selector: string) =>
selector === '[data-terminal-tab-id="tab-1"] .xterm-helper-textarea'
selector === TAB_HELPER_SELECTOR
? { length: 1, item: () => textarea }
: { length: 0, item: () => null }
)
@@ -150,7 +225,7 @@ describe('focusTerminalTabSurface', () => {
: null
),
querySelectorAll: vi.fn((selector: string) =>
selector === '[data-terminal-tab-id="tab-1"] .xterm-helper-textarea'
selector === TAB_HELPER_SELECTOR
? { length: 1, item: () => textarea }
: { length: 0, item: () => null }
)
@@ -172,7 +247,7 @@ describe('focusTerminalTabSurface', () => {
: null
),
querySelectorAll: vi.fn((selector: string) =>
selector === '[data-terminal-tab-id="tab-1"] .xterm-helper-textarea'
selector === TAB_HELPER_SELECTOR
? { length: 2, item: (index: number) => (index === 0 ? first : second) }
: { length: 0, item: () => null }
)
@@ -1,4 +1,5 @@
import { refreshTerminalImeInputContext } from '@/components/terminal-pane/terminal-ime-input-context-refresh'
import { UNCOVERED_TERMINAL_LEAF_SELECTOR } from '@/components/terminal-pane/native-chat-covered-pane'
/**
* Move keyboard focus into the xterm instance for a freshly-mounted terminal
@@ -70,9 +71,15 @@ export function focusTerminalTabSurface(
return
}
const escapedTabId = cssAttributeString(tabId)
const tabElement = document.querySelector(`[data-terminal-tab-id="${escapedTabId}"]`)
if (tabElement?.getAttribute('data-terminal-chat-view') === 'true') {
return
}
// Why: a split chat tab keeps a covered xterm under the chat leaf; the
// tab-wide query must skip it or the deferred focus lands on it.
const scopedSelector = leafId
? `[data-terminal-tab-id="${escapedTabId}"] [data-leaf-id="${cssAttributeString(leafId)}"] .xterm-helper-textarea`
: `[data-terminal-tab-id="${escapedTabId}"] .xterm-helper-textarea`
? `[data-terminal-tab-id="${escapedTabId}"] [data-leaf-id="${cssAttributeString(leafId)}"]${UNCOVERED_TERMINAL_LEAF_SELECTOR} .xterm-helper-textarea`
: `[data-terminal-tab-id="${escapedTabId}"] ${UNCOVERED_TERMINAL_LEAF_SELECTOR} .xterm-helper-textarea`
const scoped = document.querySelector(scopedSelector) as HTMLElement | null
if (scoped) {
focusTerminalHelper(scoped, options)
@@ -87,7 +94,7 @@ export function focusTerminalTabSurface(
// Why: old single-pane remounts could remint the leaf id. Only recover
// after the tab layout no longer expects the requested leaf.
const tabScopedHelpers = document.querySelectorAll(
`[data-terminal-tab-id="${escapedTabId}"] .xterm-helper-textarea`
`[data-terminal-tab-id="${escapedTabId}"] ${UNCOVERED_TERMINAL_LEAF_SELECTOR} .xterm-helper-textarea`
)
if (tabScopedHelpers.length === 1) {
const fallback = tabScopedHelpers.item(0) as HTMLElement | null
@@ -98,7 +105,9 @@ export function focusTerminalTabSurface(
}
return
}
const fallback = document.querySelector('.xterm-helper-textarea') as HTMLElement | null
const fallback = document.querySelector(
`${UNCOVERED_TERMINAL_LEAF_SELECTOR} .xterm-helper-textarea`
) as HTMLElement | null
if (fallback) {
focusTerminalHelper(fallback, options)
}