Add window wake recovery hook for visible terminal panes

- Extract and enhance window focus and visibility change recovery logic
  into a dedicated `useTerminalWindowWakeRecovery` hook.
- Recover the xterm renderer, input surface, and WebGL texture atlases
  when macOS/display wakes or the window/tab regains focus.
- Schedule recovery steps with requestAnimationFrame to ensure the
  terminal layout settles correctly.
This commit is contained in:
Jinjing
2026-06-27 21:46:15 -07:00
parent ca4294a032
commit 7e3ba42a5e
5 changed files with 157 additions and 30 deletions
@@ -1342,7 +1342,6 @@ export default function TerminalPane({
setCacheTimerStartedAt,
setRuntimePaneTitle,
suppressPtyExit,
clearExitedPanePtyLayoutBinding,
syncPanePtyLayoutBinding,
tabId,
updateTabPtyId,
@@ -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)
@@ -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<string, EventListenerOrEventListenerObject>()
@@ -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
@@ -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<PaneManager | null>
isActiveRef: React.RefObject<boolean>
isVisibleRef: React.RefObject<boolean>
}
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])
}