mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
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:
co-authored by
Merge Sim
parent
3be526c5e6
commit
c36c23df5f
@@ -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
|
||||
|
||||
+57
@@ -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,
|
||||
|
||||
+36
-2
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user