mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
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:
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user