fix(chat): close covered terminal focus fallbacks

This commit is contained in:
Merge Sim
2026-09-04 18:11:36 -07:00
parent f62c31b01c
commit f689e2c607
5 changed files with 40 additions and 7 deletions
@@ -48,6 +48,7 @@ export function TerminalPaneSurface({
dismissTerminalError,
expectedLayoutLeafIdsAttr,
expandedPaneId,
effectiveChatViewMode,
handleCancelClose,
handleConfirmClose,
handleContextMenuToggleNativeChat,
@@ -117,7 +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={activePaneIsChatLeaf ? 'true' : undefined}
data-terminal-chat-view={effectiveChatViewMode && activePaneIsChatLeaf ? 'true' : undefined}
data-terminal-layout-leaf-ids={expectedLayoutLeafIdsAttr}
data-pane-title-surface={titleUsesLightSurface ? 'light' : 'dark'}
style={terminalContainerStyle}
@@ -1,18 +1,18 @@
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
const NATIVE_CHAT_ROOT_SELECTOR = '[data-native-chat-root]'
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_ROOT_SELECTOR}))`
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_ROOT_SELECTOR) != null
return pane?.container.querySelector(NATIVE_CHAT_COVER_SELECTOR) != null
}
/** Mirrors focusActivePane's target so the guard tracks exactly the pane that would take focus. */
@@ -356,7 +356,7 @@ describe('useTerminalPaneGlobalEffects', () => {
isChatViewMode: true
})
expect(pane.container.querySelector).toHaveBeenCalledWith('[data-native-chat-root]')
expect(pane.container.querySelector).toHaveBeenCalledWith('.native-chat-pane-shell')
if (covered) {
expect(mocks.focusActivePane).not.toHaveBeenCalled()
} else {
@@ -11,7 +11,9 @@ vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', () => (
// 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([data-native-chat-root])) .xterm-helper-textarea'
'[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(() => {
@@ -145,6 +147,34 @@ describe('focusTerminalTabSurface', () => {
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() }
@@ -105,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)
}