diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 2d8745e71e9..40ec91bddce 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -1342,7 +1342,6 @@ export default function TerminalPane({ setCacheTimerStartedAt, setRuntimePaneTitle, suppressPtyExit, - clearExitedPanePtyLayoutBinding, syncPanePtyLayoutBinding, tabId, updateTabPtyId, diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 5feee00f707..a11dc4bb117 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -9,6 +9,7 @@ import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal- import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers' const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 +const WINDOW_WAKE_FLUSH_CHARS = 64 * 1024 export type TerminalHiddenReason = 'surface' | 'tab' @@ -35,6 +36,11 @@ type HideTerminalVisibilityResult = { renderingSuspended: boolean } +type RecoverVisibleTerminalWindowWakeArgs = { + manager: PaneManager + isActive: boolean +} + export function resumeTerminalVisibility({ manager, isActive, @@ -107,6 +113,27 @@ export function hideTerminalVisibility({ return { hiddenReason: null, renderingSuspended: false } } +export function recoverVisibleTerminalWindowWake({ + manager, + isActive +}: RecoverVisibleTerminalWindowWakeArgs): void { + // Why: macOS screensaver/display wake can leave xterm visible but with a + // stale renderer/input surface; Orca's own hidden-state resume never runs. + for (const pane of manager.getPanes()) { + requestTerminalBacklogRecovery(pane.terminal) + flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS }) + } + manager.resumeRendering() + if (isActive) { + fitAndFocusPanes(manager) + } else { + fitPanes(manager) + } + enforceTerminalViewportIntents(manager) + resetAllTerminalWebglAtlases() + manager.refreshAllPanes?.() +} + function requestLightTabBacklogRecovery(manager: PaneManager): void { for (const pane of manager.getPanes()) { requestTerminalBacklogRecovery(pane.terminal) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 75245c1dddd..2917039133e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -674,6 +674,60 @@ describe('useTerminalPaneGlobalEffects', () => { expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) }) + it('recovers visible terminal rendering and input when the window regains focus', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + refreshAllPanes: vi.fn(), + suspendRendering: vi.fn(), + getActivePane: vi.fn(() => ({ id: 1, terminal })) + } + + registerManagerForReset(manager) + beginHookRender() + useTerminalPaneGlobalEffects({ + tabId: 'tab-1', + worktreeId: 'wt-1', + isActive: true, + isVisible: true, + isSyncFitEnabled: true, + paneCount: 1, + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + toggleExpandPane: vi.fn() + }) + + const focusListener = vi + .mocked(window.addEventListener) + .mock.calls.find(([eventName]) => eventName === 'focus') + + expect(focusListener).toBeDefined() + const listener = focusListener?.[1] + if (typeof listener !== 'function') { + throw new Error('expected focus listener') + } + manager.resumeRendering.mockClear() + manager.resetWebglTextureAtlases.mockClear() + manager.refreshAllPanes.mockClear() + mocks.fitAndFocusPanes.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + listener(new Event('focus')) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, { maxChars: 64 * 1024 }) + expect(manager.resumeRendering).toHaveBeenCalledTimes(1) + expect(mocks.fitAndFocusPanes).toHaveBeenCalledWith(manager) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) + }) + it('clears WebGL texture atlases when the active visible terminal document becomes visible', () => { let visibilityState: DocumentVisibilityState = 'hidden' const documentListeners = new Map() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 23a03049dc1..4f7a3366457 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -7,7 +7,6 @@ import { type PasteTerminalTextDetail } from '@/constants/terminal' import type { PaneManager } from '@/lib/pane-manager/pane-manager' -import { resetAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry' import type { PtyTransport } from './pty-transport' import { handleTerminalFileDrop } from './terminal-drop-handler' import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event' @@ -21,6 +20,7 @@ import { resumeTerminalVisibility, type TerminalHiddenReason } from './terminal-visibility-resume' +import { useTerminalWindowWakeRecovery } from './use-terminal-window-wake-recovery' type UseTerminalPaneGlobalEffectsArgs = { tabId: string @@ -80,6 +80,7 @@ export function useTerminalPaneGlobalEffects({ paneCount }) useTerminalContainerFitSync({ isVisible, isSyncFitEnabled, managerRef, containerRef }) + useTerminalWindowWakeRecovery({ isVisible, managerRef, isActiveRef, isVisibleRef }) useEffect(() => { const manager = managerRef.current @@ -128,34 +129,6 @@ export function useTerminalPaneGlobalEffects({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isActive, isVisible, isWorktreeActive]) - useEffect(() => { - if (!isVisible) { - return - } - const recoverWebglAtlases = (): void => { - // Why: WebGL atlas corruption does not always raise context loss; window - // foregrounding is a low-cost recovery point. Visible terminals can be - // inactive in split groups, and same-config terminals share the atlas. - resetAllTerminalWebglAtlases() - } - const onFocus = (): void => recoverWebglAtlases() - const onVisibilityChange = (): void => { - if (typeof document !== 'undefined' && document.visibilityState === 'visible') { - recoverWebglAtlases() - } - } - window.addEventListener('focus', onFocus) - if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { - document.addEventListener('visibilitychange', onVisibilityChange) - } - return () => { - window.removeEventListener('focus', onFocus) - if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') { - document.removeEventListener('visibilitychange', onVisibilityChange) - } - } - }, [isVisible]) - useEffect(() => { const manager = managerRef.current const activePane = isActive && isVisible ? manager?.getActivePane() : null diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts new file mode 100644 index 00000000000..46b0099369c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts @@ -0,0 +1,74 @@ +import { useEffect } from 'react' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { recoverVisibleTerminalWindowWake } from './terminal-visibility-resume' + +type UseTerminalWindowWakeRecoveryArgs = { + isVisible: boolean + managerRef: React.RefObject + isActiveRef: React.RefObject + isVisibleRef: React.RefObject +} + +export function useTerminalWindowWakeRecovery({ + isVisible, + managerRef, + isActiveRef, + isVisibleRef +}: UseTerminalWindowWakeRecoveryArgs): void { + useEffect(() => { + if (!isVisible) { + return + } + let wakeRecoveryFrameId: number | null = null + const cancelScheduledWakeRecovery = (): void => { + if (wakeRecoveryFrameId === null || typeof cancelAnimationFrame !== 'function') { + wakeRecoveryFrameId = null + return + } + cancelAnimationFrame(wakeRecoveryFrameId) + wakeRecoveryFrameId = null + } + const recoverVisibleWake = (): void => { + const manager = managerRef.current + if (!manager) { + return + } + recoverVisibleTerminalWindowWake({ + manager, + isActive: isActiveRef.current + }) + cancelScheduledWakeRecovery() + if (typeof requestAnimationFrame !== 'function') { + return + } + wakeRecoveryFrameId = requestAnimationFrame(() => { + wakeRecoveryFrameId = null + const settledManager = managerRef.current + if (!settledManager || !isVisibleRef.current) { + return + } + recoverVisibleTerminalWindowWake({ + manager: settledManager, + isActive: isActiveRef.current + }) + }) + } + const onFocus = (): void => recoverVisibleWake() + const onVisibilityChange = (): void => { + if (typeof document !== 'undefined' && document.visibilityState === 'visible') { + recoverVisibleWake() + } + } + window.addEventListener('focus', onFocus) + if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { + document.addEventListener('visibilitychange', onVisibilityChange) + } + return () => { + cancelScheduledWakeRecovery() + window.removeEventListener('focus', onFocus) + if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') { + document.removeEventListener('visibilitychange', onVisibilityChange) + } + } + }, [isActiveRef, isVisible, isVisibleRef, managerRef]) +}