Stop wiping the shared WebGL glyph atlas on plain window refocus (#7604)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-07-07 12:43:53 -07:00 committed by GitHub
parent 599f9f2138
commit fed545d330
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 369 additions and 25 deletions

View File

@ -151,6 +151,7 @@ import { AutomationService } from './automations/service'
import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch'
import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create'
import { AgentAwakeService } from './agent-awake-service'
import { registerSystemResumeBroadcast } from './system-resume-broadcast'
import {
getCrashBreadcrumbSnapshot,
recordCoalescedCrashBreadcrumb,
@ -212,6 +213,7 @@ let starNag: StarNagService | null = null
let agentAwakeService: AgentAwakeService | null = null
let crashReports: CrashReportStore | null = null
let unsubscribeAgentAwakeStatusChanges: (() => void) | null = null
let unsubscribeSystemResumeBroadcast: (() => void) | null = null
let watcherShutdownPromise: Promise<void> | null = null
let watcherShutdownDone = false
let automations: AutomationService | null = null
@ -1653,6 +1655,7 @@ app.whenReady().then(async () => {
// Why: browser sessions are used by desktop webviews and runtime profile
// commands, so initialize them at app startup instead of a renderer IPC path.
initializeBrowserSessionsForApp()
unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast()
agentAwakeService = new AgentAwakeService()
agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun)
// Why: disk-hydrated status rows are UI continuity only. The service starts
@ -2170,6 +2173,8 @@ app.whenReady().then(async () => {
app.on('before-quit', () => {
isQuitting = true
unsubscribeSystemResumeBroadcast?.()
unsubscribeSystemResumeBroadcast = null
unsubscribeAgentAwakeStatusChanges?.()
unsubscribeAgentAwakeStatusChanges = null
agentAwakeService?.dispose()

View File

@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest'
import { registerSystemResumeBroadcast, SYSTEM_RESUMED_CHANNEL } from './system-resume-broadcast'
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: vi.fn(() => []) },
powerMonitor: { on: vi.fn(), off: vi.fn() }
}))
type ResumeListener = () => void
function createResumeSource() {
const state: { listener: ResumeListener | null } = { listener: null }
const source = {
on: vi.fn((_event: 'resume', callback: ResumeListener) => {
state.listener = callback
}),
off: vi.fn((_event: 'resume', _callback: ResumeListener) => {
state.listener = null
})
}
return { source, fireResume: () => state.listener?.() }
}
function createWindow(destroyed = false): {
isDestroyed: () => boolean
webContents: { send: ReturnType<typeof vi.fn<(channel: string) => void>> }
} {
return {
isDestroyed: () => destroyed,
webContents: { send: vi.fn<(channel: string) => void>() }
}
}
describe('registerSystemResumeBroadcast', () => {
it('broadcasts the resume channel to every live window', () => {
const { source, fireResume } = createResumeSource()
const liveWindow = createWindow()
const destroyedWindow = createWindow(true)
registerSystemResumeBroadcast({
resumeSource: source,
getWindows: () => [liveWindow, destroyedWindow]
})
fireResume()
expect(liveWindow.webContents.send).toHaveBeenCalledWith(SYSTEM_RESUMED_CHANNEL)
expect(destroyedWindow.webContents.send).not.toHaveBeenCalled()
})
it('stops broadcasting after unsubscribe', () => {
const { source, fireResume } = createResumeSource()
const window = createWindow()
const unsubscribe = registerSystemResumeBroadcast({
resumeSource: source,
getWindows: () => [window]
})
unsubscribe()
fireResume()
expect(source.off).toHaveBeenCalledTimes(1)
expect(window.webContents.send).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,39 @@
import { BrowserWindow, powerMonitor } from 'electron'
export const SYSTEM_RESUMED_CHANNEL = 'system:resumed'
type ResumeEventSource = {
on(event: 'resume', listener: () => void): unknown
off(event: 'resume', listener: () => void): unknown
}
type ResumeBroadcastWindow = {
isDestroyed(): boolean
webContents: { send(channel: string): void }
}
type SystemResumeBroadcastOptions = {
resumeSource?: ResumeEventSource
getWindows?: () => ResumeBroadcastWindow[]
}
// Why: renderers cannot observe OS sleep/wake directly, and Linux has no
// window-occlusion tracking so visibilitychange never fires around suspend.
// Wake-sensitive renderer recovery needs this explicit resume signal.
export function registerSystemResumeBroadcast(
options: SystemResumeBroadcastOptions = {}
): () => void {
const resumeSource = options.resumeSource ?? powerMonitor
const getWindows = options.getWindows ?? (() => BrowserWindow.getAllWindows())
const onResume = (): void => {
for (const window of getWindows()) {
if (!window.isDestroyed()) {
window.webContents.send(SYSTEM_RESUMED_CHANNEL)
}
}
}
resumeSource.on('resume', onResume)
return () => {
resumeSource.off('resume', onResume)
}
}

View File

@ -2679,6 +2679,7 @@ export type PreloadApi = {
) => () => void
onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
onSystemResumed: (callback: () => void) => () => void
readClipboardText: (options?: ReadClipboardTextOptions) => Promise<string>
readSelectionClipboardText: (options?: ReadClipboardTextOptions) => Promise<string>
saveClipboardImageAsTempFile: (args?: {

View File

@ -3454,6 +3454,11 @@ const api = {
ipcRenderer.on('terminal:zoom', listener)
return () => ipcRenderer.removeListener('terminal:zoom', listener)
},
onSystemResumed: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('system:resumed', listener)
return () => ipcRenderer.removeListener('system:resumed', listener)
},
readClipboardText: (options?: ReadClipboardTextOptions): Promise<string> =>
ipcRenderer.invoke('clipboard:readText', options),
readSelectionClipboardText: (options?: ReadClipboardTextOptions): Promise<string> =>

View File

@ -31,13 +31,15 @@ type FakeManager = {
getPanes: ReturnType<typeof vi.fn>
resumeRendering: ReturnType<typeof vi.fn>
scheduleRevealRepaint: ReturnType<typeof vi.fn>
scheduleRevealPresent: ReturnType<typeof vi.fn>
}
function createManager(order: string[] = []): FakeManager {
return {
getPanes: vi.fn(() => []),
resumeRendering: vi.fn(() => order.push('resume-rendering')),
scheduleRevealRepaint: vi.fn(() => order.push('reveal-repaint'))
scheduleRevealRepaint: vi.fn(() => order.push('reveal-repaint')),
scheduleRevealPresent: vi.fn(() => order.push('reveal-present'))
}
}
@ -79,13 +81,52 @@ describe('resumeTerminalVisibility reveal repaint', () => {
expect(order).toEqual(['resume-rendering', 'reveal-repaint'])
})
it('schedules the repaint on window-wake recovery', () => {
it('schedules the atlas-clearing repaint on genuine wake recovery', () => {
const manager = createManager()
recoverVisibleTerminalWindowWake({
manager: manager as never as PaneManager,
isActive: false
isActive: false,
clearGlyphAtlases: true
})
expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1)
expect(manager.scheduleRevealPresent).not.toHaveBeenCalled()
})
it('clears shared glyph atlases only on genuine wake recovery', async () => {
const { resetAndRefreshAllTerminalWebglAtlases } = vi.mocked(
await import('@/lib/pane-manager/pane-manager-registry')
)
const manager = createManager()
recoverVisibleTerminalWindowWake({
manager: manager as never as PaneManager,
isActive: false,
clearGlyphAtlases: true
})
expect(resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledTimes(1)
})
it('keeps the warm glyph atlas on plain-refocus recovery', async () => {
// Deliberate reversal of the #6354 focus-clear: wiping the shared atlas on
// every refocus forces a mass re-rasterization that can hit xterm's atlas
// page-merge race (#4480) and garble streaming panes. Focus recovery must
// resume rendering and present WITHOUT the atlas-clearing reveal repaint —
// scheduleRevealRepaint clears each pane's (shared) atlas, so the refocus
// path must route to the atlas-preserving present instead.
const { resetAndRefreshAllTerminalWebglAtlases } = vi.mocked(
await import('@/lib/pane-manager/pane-manager-registry')
)
const manager = createManager()
recoverVisibleTerminalWindowWake({
manager: manager as never as PaneManager,
isActive: false,
clearGlyphAtlases: false
})
expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled()
expect(manager.resumeRendering).toHaveBeenCalledTimes(1)
expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1)
expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled()
})
})

View File

@ -40,6 +40,7 @@ type HideTerminalVisibilityResult = {
type RecoverVisibleTerminalWindowWakeArgs = {
manager: PaneManager
isActive: boolean
clearGlyphAtlases: boolean
}
export function resumeTerminalVisibility({
@ -123,7 +124,8 @@ export function hideTerminalVisibility({
export function recoverVisibleTerminalWindowWake({
manager,
isActive
isActive,
clearGlyphAtlases
}: 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.
@ -138,8 +140,19 @@ export function recoverVisibleTerminalWindowWake({
fitPanes(manager)
}
enforceTerminalViewportIntents(manager)
resetAndRefreshAllTerminalWebglAtlases()
manager.scheduleRevealRepaint()
if (clearGlyphAtlases) {
// Why: only a genuine wake may wipe the shared glyph atlas. The wipe makes
// every same-config pane re-rasterize at once, and xterm's atlas page-merge
// clear-model flag is consumed by one renderer (xterm.js #4480), so panes
// that lose that race paint garbled glyphs mid-stream.
resetAndRefreshAllTerminalWebglAtlases()
manager.scheduleRevealRepaint()
} else {
// Why: the reveal repaint clears each pane's texture atlas (a shared,
// same-config wipe), so a plain refocus must use the atlas-preserving
// present instead — otherwise it re-arms the same mid-stream garble race.
manager.scheduleRevealPresent()
}
}
function requestLightTabBacklogRecovery(manager: PaneManager): void {

View File

@ -131,6 +131,7 @@ function useMountForFileDrop(
resumeRendering: ReturnType<typeof vi.fn>
resetWebglTextureAtlases: ReturnType<typeof vi.fn>
scheduleRevealRepaint: ReturnType<typeof vi.fn>
scheduleRevealPresent: ReturnType<typeof vi.fn>
suspendRendering: ReturnType<typeof vi.fn>
getActivePane: ReturnType<typeof vi.fn>
}
@ -148,6 +149,7 @@ function useMountForFileDrop(
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
suspendRendering: vi.fn(),
getActivePane: vi.fn(() => null)
}
@ -225,6 +227,7 @@ describe('useTerminalPaneGlobalEffects', () => {
resumeRendering: vi.fn(() => order.push('resume')),
resetWebglTextureAtlases: vi.fn(() => order.push('reset-atlas')),
scheduleRevealRepaint: vi.fn(() => order.push('reveal-repaint')),
scheduleRevealPresent: vi.fn(() => order.push('reveal-present')),
refreshAllPanes: vi.fn(() => order.push('refresh')),
suspendRendering: vi.fn(),
fitAllPanes: vi.fn(),
@ -313,6 +316,7 @@ describe('useTerminalPaneGlobalEffects', () => {
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
refreshAllPanes: vi.fn(),
suspendRendering: vi.fn(),
fitAllPanes: vi.fn(),
@ -393,6 +397,7 @@ describe('useTerminalPaneGlobalEffects', () => {
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
refreshAllPanes: vi.fn(),
suspendRendering: vi.fn(),
fitAllPanes: vi.fn(),
@ -455,6 +460,7 @@ describe('useTerminalPaneGlobalEffects', () => {
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
refreshAllPanes: vi.fn(),
suspendRendering: vi.fn(),
fitAllPanes: vi.fn(),
@ -509,6 +515,7 @@ describe('useTerminalPaneGlobalEffects', () => {
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
refreshAllPanes: vi.fn(),
suspendRendering: vi.fn(),
fitAllPanes: vi.fn(),
@ -587,6 +594,7 @@ describe('useTerminalPaneGlobalEffects', () => {
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
suspendRendering: vi.fn(),
getActivePane: vi.fn(() => ({ id: 1, terminal: { name: 'terminal-a' } }))
}
@ -619,6 +627,7 @@ describe('useTerminalPaneGlobalEffects', () => {
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
suspendRendering: vi.fn(),
fitAllPanes: vi.fn(),
getActivePane: vi.fn(() => null),
@ -672,18 +681,22 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(mocks.enforceTerminalCurrentScrollIntent).toHaveBeenLastCalledWith(terminalA)
})
it('clears WebGL texture atlases when the active visible terminal regains focus', () => {
it('keeps the shared glyph atlas warm on plain window refocus', () => {
const manager = {
getPanes: vi.fn(() => []),
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
suspendRendering: vi.fn(),
getActivePane: vi.fn(() => null)
}
// Why: focus recovery resets every registered manager (shared glyph
// atlas), so the fake manager observes the reset through the registry.
// Why: deliberate reversal of the #6354 focus-clear. A refocus atlas wipe
// forces every pane to re-rasterize at once, and xterm's page-merge
// clear-model flag is consumed by a single renderer (#4480), so panes that
// lose the race paint garbled glyphs while an agent streams. Focus must
// stay a WebGL-retry + pane-scoped repaint boundary only.
registerManagerForReset(manager)
beginHookRender()
useTerminalPaneGlobalEffects({
@ -711,9 +724,18 @@ describe('useTerminalPaneGlobalEffects', () => {
throw new Error('expected focus listener')
}
manager.resetWebglTextureAtlases.mockClear()
manager.scheduleRevealRepaint.mockClear()
manager.scheduleRevealPresent.mockClear()
listener(new Event('focus'))
listener(new Event('focus'))
listener(new Event('focus'))
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
// Count proof: repeated refocus performs zero shared-atlas wipes. It routes
// to the atlas-preserving present (scheduleRevealPresent), never the
// atlas-clearing reveal repaint, which would clear each pane's shared atlas.
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled()
expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(3)
})
it('recovers visible terminal rendering and input when the window regains focus', () => {
@ -723,6 +745,7 @@ describe('useTerminalPaneGlobalEffects', () => {
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
refreshAllPanes: vi.fn(),
suspendRendering: vi.fn(),
getActivePane: vi.fn(() => ({ id: 1, terminal }))
@ -757,6 +780,10 @@ describe('useTerminalPaneGlobalEffects', () => {
manager.resumeRendering.mockClear()
manager.resetWebglTextureAtlases.mockClear()
manager.refreshAllPanes.mockClear()
// Clear the mount-time reveal spies so the assertions measure only the
// focus event, not the initial visibility resume.
manager.scheduleRevealRepaint.mockClear()
manager.scheduleRevealPresent.mockClear()
mocks.fitAndFocusPanes.mockClear()
mocks.flushTerminalOutput.mockClear()
mocks.requestTerminalBacklogRecovery.mockClear()
@ -767,6 +794,59 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, { maxChars: 64 * 1024 })
expect(manager.resumeRendering).toHaveBeenCalledTimes(1)
expect(mocks.fitAndFocusPanes).toHaveBeenCalledWith(manager)
// Why: refocus recovery is atlas-preserving — no shared-atlas reset, no
// registry-wide repaint, and no atlas-clearing reveal repaint; the
// atlas-preserving present covers stale pixels.
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
expect(manager.refreshAllPanes).not.toHaveBeenCalled()
expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled()
expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1)
})
it('clears WebGL texture atlases when the OS resumes', () => {
const manager = {
getPanes: vi.fn(() => []),
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
refreshAllPanes: vi.fn(),
suspendRendering: vi.fn(),
getActivePane: vi.fn(() => null)
}
const captured: { onSystemResumed: (() => void) | null } = { onSystemResumed: null }
const unsubscribeSystemResumed = vi.fn()
;(
window.api.ui as unknown as { onSystemResumed: (callback: () => void) => () => void }
).onSystemResumed = vi.fn((callback: () => void) => {
captured.onSystemResumed = callback
return unsubscribeSystemResumed
})
registerManagerForReset(manager)
beginHookRender()
useTerminalPaneGlobalEffects({
tabId: 'tab-1',
worktreeId: 'wt-1',
isActive: true,
isVisible: true,
isSyncFitEnabled: true,
paneCount: 0,
managerRef: { current: manager as never },
containerRef: { current: null },
paneTransportsRef: { current: new Map() },
isActiveRef: { current: false },
isVisibleRef: { current: false },
toggleExpandPane: vi.fn()
})
expect(captured.onSystemResumed).toBeTypeOf('function')
manager.resetWebglTextureAtlases.mockClear()
manager.refreshAllPanes.mockClear()
captured.onSystemResumed?.()
// Why: OS resume is a genuine wake — GPU state may be stale without a
// context-loss event, so the shared-atlas clear and full repaint still run.
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1)
})
@ -788,6 +868,7 @@ describe('useTerminalPaneGlobalEffects', () => {
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
suspendRendering: vi.fn(),
getActivePane: vi.fn(() => null)
}
@ -843,6 +924,7 @@ describe('useTerminalPaneGlobalEffects', () => {
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
suspendRendering: vi.fn(),
getActivePane: vi.fn(() => null)
}
@ -889,6 +971,7 @@ describe('useTerminalPaneGlobalEffects', () => {
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
suspendRendering: vi.fn(),
getActivePane: vi.fn(() => pane)
}
@ -945,6 +1028,7 @@ describe('useTerminalPaneGlobalEffects', () => {
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
suspendRendering: vi.fn(),
getActivePane: vi.fn(() => pane)
}
@ -1061,6 +1145,7 @@ describe('useTerminalPaneGlobalEffects', () => {
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
suspendRendering: vi.fn(),
fitAllPanes: vi.fn(),
getActivePane: vi.fn(() => null)
@ -1095,6 +1180,7 @@ describe('useTerminalPaneGlobalEffects', () => {
resumeRendering: vi.fn(),
resetWebglTextureAtlases: vi.fn(),
scheduleRevealRepaint: vi.fn(),
scheduleRevealPresent: vi.fn(),
suspendRendering: vi.fn(),
fitAllPanes: vi.fn(),
getActivePane: vi.fn(() => null)

View File

@ -20,6 +20,7 @@ export function useTerminalWindowWakeRecovery({
return
}
let wakeRecoveryFrameId: number | null = null
let settledClearGlyphAtlases = false
const cancelScheduledWakeRecovery = (): void => {
if (wakeRecoveryFrameId === null || typeof cancelAnimationFrame !== 'function') {
wakeRecoveryFrameId = null
@ -28,9 +29,12 @@ export function useTerminalWindowWakeRecovery({
cancelAnimationFrame(wakeRecoveryFrameId)
wakeRecoveryFrameId = null
}
const recoverVisibleWake = (): void => {
const recoverVisibleWake = (clearGlyphAtlases: boolean): void => {
// Focus and visibility often fire together; keep one immediate recovery and one settled RAF pass.
if (wakeRecoveryFrameId !== null) {
// Why: a pending settled pass may only upgrade in strength — a plain
// focus that lands after a genuine wake must not skip its atlas clear.
settledClearGlyphAtlases ||= clearGlyphAtlases
return
}
const manager = managerRef.current
@ -39,39 +43,60 @@ export function useTerminalWindowWakeRecovery({
}
recoverVisibleTerminalWindowWake({
manager,
isActive: isActiveRef.current
isActive: isActiveRef.current,
clearGlyphAtlases
})
if (typeof requestAnimationFrame !== 'function') {
return
}
settledClearGlyphAtlases = clearGlyphAtlases
wakeRecoveryFrameId = requestAnimationFrame(() => {
wakeRecoveryFrameId = null
const clearGlyphAtlasesOnSettle = settledClearGlyphAtlases
settledClearGlyphAtlases = false
const settledManager = managerRef.current
if (!settledManager || !isVisibleRef.current) {
return
}
recoverVisibleTerminalWindowWake({
manager: settledManager,
isActive: isActiveRef.current
isActive: isActiveRef.current,
clearGlyphAtlases: clearGlyphAtlasesOnSettle
})
})
}
const onFocus = (): void => recoverVisibleWake()
// Why: plain refocus (alt-tab, devtools) is frequent and often lands while
// an agent streams; wiping the shared glyph atlas then provokes xterm's
// page-merge race and paints garbled glyphs. Focus recovery keeps the warm
// atlas: it only retries WebGL attach, refits, and repaints pane-scoped.
const onFocus = (): void => recoverVisibleWake(false)
const onVisibilityChange = (): void => {
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
recoverVisibleWake()
recoverVisibleWake(true)
}
}
// Why: Linux has no window-occlusion tracking, so visibilitychange never
// fires around system suspend; the main process broadcasts OS resume.
const onSystemResumed = (): void => {
if (typeof document === 'undefined' || document.visibilityState === 'visible') {
recoverVisibleWake(true)
}
}
window.addEventListener('focus', onFocus)
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
document.addEventListener('visibilitychange', onVisibilityChange)
}
const unsubscribeSystemResumed =
typeof window.api?.ui?.onSystemResumed === 'function'
? window.api.ui.onSystemResumed(onSystemResumed)
: null
return () => {
cancelScheduledWakeRecovery()
window.removeEventListener('focus', onFocus)
if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
document.removeEventListener('visibilitychange', onVisibilityChange)
}
unsubscribeSystemResumed?.()
}
}, [isActiveRef, isVisible, isVisibleRef, managerRef])
}

View File

@ -41,7 +41,7 @@ import {
} from './pane-rendering-control'
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
import { registerLivePaneManager, unregisterLivePaneManager } from './pane-manager-registry'
import { schedulePaneRevealRepaint } from './pane-reveal-repaint'
import { schedulePaneRevealPresent, schedulePaneRevealRepaint } from './pane-reveal-repaint'
import { PaneIdentityRegistry } from './pane-identity-registry'
import {
closeManagedPane,
@ -329,6 +329,12 @@ export class PaneManager {
schedulePaneRevealRepaint(() => (this.destroyed ? [] : this.panes.values()))
}
scheduleRevealPresent(): void {
// Why: same destroy guard as scheduleRevealRepaint, but presents without
// clearing the shared glyph atlas — used by the plain-refocus recovery.
schedulePaneRevealPresent(() => (this.destroyed ? [] : this.panes.values()))
}
suspendRendering(): void {
this.renderingSuspended = true
suspendPaneRendering(this.panes.values())

View File

@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ManagedPaneInternal } from './pane-manager-types'
import { schedulePaneRevealRepaint } from './pane-reveal-repaint'
import { schedulePaneRevealPresent, schedulePaneRevealRepaint } from './pane-reveal-repaint'
import { resetTerminalWebglSuggestion } from './pane-webgl-renderer'
type FakeWebglAddon = { clearTextureAtlas: ReturnType<typeof vi.fn> }
@ -144,4 +144,32 @@ describe('schedulePaneRevealRepaint', () => {
expect(webglAddon.clearTextureAtlas).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
describe('schedulePaneRevealPresent', () => {
it('presents the settled buffer without wiping the shared glyph atlas', () => {
// The plain-refocus path must NOT clear the atlas — the clear is a
// same-config shared wipe that re-arms the mid-stream page-merge race.
const webglAddon = { clearTextureAtlas: vi.fn() }
const pane = createPane({ webglAddon })
schedulePaneRevealPresent(() => [pane])
flushFrame()
expect(pane.terminal.refresh).not.toHaveBeenCalled()
flushFrame()
expect(webglAddon.clearTextureAtlas).not.toHaveBeenCalled()
expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23)
})
it('still retries a missing WebGL attach on the settled frame', () => {
const pane = createPane()
schedulePaneRevealPresent(() => [pane])
flushFrame()
flushFrame()
expect(pane.webglAddon).not.toBeNull()
expect(pane.terminal.refresh).toHaveBeenCalled()
})
})
})

View File

@ -15,6 +15,21 @@ function scheduleSettledFrame(callback: () => void): void {
})
}
function forEachPaneOnSettledFrame(
getPanes: () => Iterable<ManagedPaneInternal>,
visit: (pane: ManagedPaneInternal) => void
): void {
scheduleSettledFrame(() => {
for (const pane of getPanes()) {
try {
visit(pane)
} catch {
/* ignore — one pane's failure must not block repaint of the rest */
}
}
})
}
/**
* Repaints a revealed tab's panes from their xterm buffers.
*
@ -26,14 +41,27 @@ function scheduleSettledFrame(callback: () => void): void {
* settled forces a full rebuild from the buffer without any PTY resize.
*/
export function schedulePaneRevealRepaint(getPanes: () => Iterable<ManagedPaneInternal>): void {
scheduleSettledFrame(() => {
for (const pane of getPanes()) {
try {
reattachWebglIfNeeded(pane)
resetWebglTextureAtlas(pane)
} catch {
/* ignore — one pane's failure must not block repaint of the rest */
}
forEachPaneOnSettledFrame(getPanes, (pane) => {
reattachWebglIfNeeded(pane)
resetWebglTextureAtlas(pane)
})
}
/**
* Presents already-visible panes without clearing the shared glyph atlas.
*
* Why: a plain window refocus never hid its panes, so their WebGL model is
* already current a `refresh` re-presents the live buffer (covering a
* compositor that dropped frames while occluded). Using the atlas-clearing
* reveal repaint here would wipe the atlas shared by every same-config pane and
* re-arm the mid-stream page-merge garble race (xterm.js issue 4480); this path
* must stay texture-atlas-preserving.
*/
export function schedulePaneRevealPresent(getPanes: () => Iterable<ManagedPaneInternal>): void {
forEachPaneOnSettledFrame(getPanes, (pane) => {
reattachWebglIfNeeded(pane)
if (pane.terminal.rows > 0) {
pane.terminal.refresh(0, pane.terminal.rows - 1)
}
})
}

View File

@ -2223,6 +2223,9 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onCloseTerminal: () => noopUnsubscribe,
onSleepWorktree: () => noopUnsubscribe,
onTerminalZoom: () => noopUnsubscribe,
// Why: a paired web client has no OS sleep signal; occlusion-driven
// visibilitychange already covers its wake recovery.
onSystemResumed: () => noopUnsubscribe,
onFileDrop: () => noopUnsubscribe,
syncTrafficLights: () => {},
setMarkdownEditorFocused: () => {},