diff --git a/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts b/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts index d2f9782dc9b..0f103765769 100644 --- a/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts +++ b/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts @@ -180,7 +180,6 @@ function createManager(pane: Record): Record { return { setPaneGpuRendering: vi.fn(), markPaneHasComplexScriptOutput: vi.fn(), - rebuildPaneWebgl: vi.fn(), hasWebglRenderer: vi.fn(() => false), getPanes: vi.fn(() => [pane]), closePane: vi.fn(), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.spec.ts index eb4e253b94a..5208789f701 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.spec.ts @@ -314,7 +314,6 @@ export function registerPtyConnectionReplayPayloadHandlingTests(): void { expect(pane.terminal.write).toHaveBeenCalledWith('remote prompt\r\n$ ', expect.any(Function)) expect(refresh).toHaveBeenCalledWith(0, 39, true) - expect(manager.rebuildPaneWebgl).toHaveBeenCalledWith(1) disposable.dispose() }) @@ -640,7 +639,6 @@ export function registerPtyConnectionReplayPayloadHandlingTests(): void { expect(pane.terminal.write).not.toHaveBeenCalledWith('first replay', expect.any(Function)) expect(pane.terminal.write).toHaveBeenCalledWith('second replay', expect.any(Function)) - expect(manager.rebuildPaneWebgl).toHaveBeenCalledTimes(1) disposable.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts index b040d7f07d8..8b7d9082675 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts @@ -251,7 +251,6 @@ export type MockPaneSummary = { id: number; leafId: string } export type MockPaneManager = { setPaneGpuRendering: Mock markPaneHasComplexScriptOutput: Mock - rebuildPaneWebgl: Mock hasWebglRenderer: Mock<() => boolean> getPanes: Mock<() => MockPaneSummary[]> closePane: Mock @@ -273,7 +272,6 @@ export function createManager( return { setPaneGpuRendering: vi.fn(), markPaneHasComplexScriptOutput: vi.fn(), - rebuildPaneWebgl: vi.fn(), hasWebglRenderer: vi.fn(() => false), getPanes: vi.fn(() => panes), closePane: vi.fn(), diff --git a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts index a4dcd68e3dd..6183642aa1f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts @@ -178,8 +178,6 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { if (!isCurrentPayload()) { continue } - // Rebuild WebGL after replay parses so an atlas attached to an empty buffer gains current glyphs. - session.manager.rebuildPaneWebgl(session.pane.id) appliedCurrentPayload = true } return appliedCurrentPayload diff --git a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts index 2b6a067d62f..5202d88a079 100644 --- a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts @@ -267,7 +267,6 @@ function createManager(paneCount = 1, initialActivePaneId: number | null = null) return { setPaneGpuRendering: vi.fn(), markPaneHasComplexScriptOutput: vi.fn(), - rebuildPaneWebgl: vi.fn(), hasWebglRenderer: vi.fn(() => false), getPanes: vi.fn(() => panes), closePane: vi.fn(), diff --git a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts index 1df15ca5147..874899ea014 100644 --- a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts @@ -267,7 +267,6 @@ function createManager(paneCount = 1, initialActivePaneId: number | null = null) return { setPaneGpuRendering: vi.fn(), markPaneHasComplexScriptOutput: vi.fn(), - rebuildPaneWebgl: vi.fn(), hasWebglRenderer: vi.fn(() => false), getPanes: vi.fn(() => panes), closePane: vi.fn(), diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index f971bcabe3e..9da6e3cd64a 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -17,7 +17,7 @@ import { setInlineImagesEnabled } from './pane-inline-images' import { fitAllPanesInternal } from './pane-tree-ops' import { collectPublicPanes, toPublicPane } from './pane-public-view' import { applyTerminalGpuAcceleration } from './pane-terminal-gpu-acceleration' -import { detachPanesWebglForRebuild, rebuildAttachedWebgl } from './pane-webgl-reattach' +import { detachPanesWebglForRebuild } from './pane-webgl-reattach' import { markPaneComplexScriptOutput, clearPaneWebglTextureAtlases, @@ -259,10 +259,6 @@ export class PaneManager { markPaneComplexScriptOutput(this.panes, paneId) } - rebuildPaneWebgl(paneId: number): void { - this.withPane(paneId, rebuildAttachedWebgl) - } - resetWebglTextureAtlases(): void { resetPaneWebglTextureAtlases(this.panes.values()) } diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts index 6891e65c567..befa7e5e10e 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts @@ -8,6 +8,7 @@ import { } from './pane-webgl-renderer' import { safeFit } from './pane-fit' import { disposePane } from './pane-lifecycle' +import { presentPaneViewport } from './pane-viewport-present' function createPane(options: { loadAddon?: () => void } = {}): ManagedPaneInternal { const leafId = '22222222-2222-4222-8222-222222222222' as never @@ -121,6 +122,85 @@ describe('terminal WebGL addon lifecycle', () => { expect(disposeSpy).toHaveBeenCalledTimes(1) }) + it('paints a newly attached canvas inside a synchronized frame instead of leaving it blank', () => { + const renderRows = vi.fn() + const pane = createPane() + Object.assign(pane.terminal, { + _core: { + _renderService: { + _isPaused: false, + _needsFullRefresh: false, + refreshRows: vi.fn(), + _renderer: { value: { renderRows } } + }, + coreService: { decPrivateModes: { synchronizedOutput: true } } + } + }) + + attachWebgl(pane) + + expect(pane.webglAddon).not.toBeNull() + expect(renderRows).toHaveBeenCalledWith(0, 23) + }) + + it('keeps an attached, already painted renderer tear-free inside a synchronized frame', () => { + const renderRows = vi.fn() + const refreshRows = vi.fn() + const pane = createPane() + Object.assign(pane.container, { getBoundingClientRect: () => ({ width: 800, height: 400 }) }) + const decPrivateModes = { synchronizedOutput: false } + Object.assign(pane.terminal, { + _core: { + _renderService: { + _isPaused: false, + _needsFullRefresh: false, + refreshRows, + _renderer: { value: { renderRows } } + }, + coreService: { decPrivateModes } + } + }) + attachWebgl(pane) + decPrivateModes.synchronizedOutput = true + + presentPaneViewport(pane) + + expect(renderRows).not.toHaveBeenCalled() + expect(refreshRows).toHaveBeenCalledWith(0, 23, true) + }) + + it('paints a canvas attached while paused on its reveal present, inside a synchronized frame', () => { + const renderRows = vi.fn() + const renderService = { + _isPaused: true, + _needsFullRefresh: false, + refreshRows: vi.fn(), + _renderer: { value: { renderRows } } + } + const pane = createPane() + Object.assign(pane.container, { getBoundingClientRect: () => ({ width: 800, height: 400 }) }) + Object.assign(pane.terminal, { + onRender: vi.fn(() => ({ dispose: vi.fn() })), + _core: { + _renderService: renderService, + coreService: { decPrivateModes: { synchronizedOutput: true } } + } + }) + + attachWebgl(pane) + expect(renderRows).not.toHaveBeenCalled() + expect(renderService._isPaused).toBe(true) + + presentPaneViewport(pane) + expect(renderRows).toHaveBeenCalledWith(0, 23) + + // Only that one paint may land mid-frame; later presents wait for the frame to close. + renderRows.mockClear() + presentPaneViewport(pane) + expect(renderRows).not.toHaveBeenCalled() + expect(renderService.refreshRows).toHaveBeenCalledWith(0, 23, true) + }) + it('keeps attaching to healthy panes after another pane fails to attach', () => { // A module-global attach latch once stranded all later panes on wider/bolder DOM rendering after one pane failed. const failing = createPane({ diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index 9c212dd4fba..6abaa8a1bd1 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -14,6 +14,7 @@ import { } from './terminal-canvas-dpr-repair' import { recordPaneWebglContextLoss } from './pane-webgl-context-loss-policy' import { presentPaneViewport } from './pane-viewport-present' +import { presentEmptiedCanvas } from './terminal-render-pause-release' import { releaseWebglCanvasBackingWhileHidden, stopReleasingWebglCanvasBacking @@ -89,7 +90,9 @@ export function shouldUseTerminalWebgl(pane: ManagedPaneInternal): boolean { function refreshTerminalAfterWebglAttach(pane: ManagedPaneInternal): void { try { // New WebGL canvases are empty; repaint immediately so resume/reparent/settings do not look frozen until output. - pane.terminal.refresh(0, pane.terminal.rows - 1) + if (!presentEmptiedCanvas(pane.terminal)) { + pane.terminal.refresh(0, pane.terminal.rows - 1) + } } catch {} } diff --git a/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.ts b/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.ts index 17558728c4b..0130336f71e 100644 --- a/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.ts +++ b/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.ts @@ -1,6 +1,6 @@ import type { ManagedPane } from './pane-manager-types' import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal/diagnostics/terminal-webgl-diagnostics' -import { forceFullViewportPresent } from './terminal-render-pause-release' +import { forceFullViewportPresent, presentEmptiedCanvas } from './terminal-render-pause-release' import { isWebglCanvasBackingReleased } from './terminal-webgl-hidden-canvas-release' // Hidden-time DPR changes can evade WebGL’s device-pixel observer, leaving stale-scale canvas backing (e.g. 2160px for 1080 CSS pixels at DPR 1). Use xterm’s resize path to recompute device dimensions/backing at current DPR. @@ -63,7 +63,10 @@ export function repairPaneWebglCanvasDpr(pane: ManagedPane): PaneWebglCanvasDprR // Refresh cached DPR/dimensions before resize rebuilds backing and layer sizes. renderer.handleDevicePixelRatioChange?.() renderer.handleResize?.(pane.terminal.cols, pane.terminal.rows) - pane.terminal.refresh(0, pane.terminal.rows - 1) + // Resizing the backing wiped it, so DEC 2026 must not leave it blank. + if (!presentEmptiedCanvas(pane.terminal)) { + pane.terminal.refresh(0, pane.terminal.rows - 1) + } } catch { // Pane may be mid-teardown; the next reveal/fit retries the check. return 'deferred' diff --git a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts index 46cec446945..8c015ae2fd9 100644 --- a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts +++ b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts @@ -65,8 +65,45 @@ export function forceRepaintThroughRenderPause(terminal: unknown): boolean { } } +type RenderObservableTerminal = { + onRender?: (listener: () => void) => { dispose: () => void } +} + +// Terminals whose WebGL canvas was emptied while paused, mapped to the render listener that clears the mark. +const unpaintedCanvasTerminals = new WeakMap void }>() + +function hasUnpaintedCanvas(terminal: unknown): terminal is object { + return typeof terminal === 'object' && terminal !== null && unpaintedCanvasTerminals.has(terminal) +} + +function forgetUnpaintedCanvas(terminal: unknown): void { + if (typeof terminal !== 'object' || terminal === null) { + return + } + unpaintedCanvasTerminals.get(terminal)?.dispose() + unpaintedCanvasTerminals.delete(terminal) +} + +function rememberUnpaintedCanvas(terminal: unknown): void { + if (typeof terminal !== 'object' || terminal === null || unpaintedCanvasTerminals.has(terminal)) { + return + } + const observable: RenderObservableTerminal = terminal + if (typeof observable.onRender !== 'function') { + return + } + unpaintedCanvasTerminals.set( + terminal, + observable.onRender(() => forgetUnpaintedCanvas(terminal)) + ) +} + /** Preserve the coherent canvas through DEC 2026; RenderService waits for frame close with xterm’s bounded safety timeout. */ export function requestFullViewportPresent(terminal: unknown): boolean { + if (hasUnpaintedCanvas(terminal)) { + // Nothing coherent to preserve: the canvas was emptied while paused and has not painted since. + return forceFullViewportPresent(terminal) + } const service = getRenderService(terminal) if (!service) { return false @@ -113,7 +150,23 @@ export function isSynchronizedOutputHeld(terminal: unknown): boolean { ) } -/** Force a full present through pause/DEC 2026 only for held keystroke echo, replay hold-cap expiry, or revealing a released canvas (no coherent frame left to keep). Mid-frame painting exposes unfinished TUIs (#32198); reveal/recovery use requestFullViewportPresent. Normal unpaused paint falls back to refresh: direct first-splash renderRows precedes stable cell metrics and leaves a 1px gutter. */ +/** A new or resized WebGL canvas holds no frame to keep, so DEC 2026 must not leave it blank until the frame closes. A paused one paints on its reveal present instead. */ +export function presentEmptiedCanvas(terminal: unknown): boolean { + const service = getRenderService(terminal) + if (!service) { + return false + } + if (service._isPaused === true) { + rememberUnpaintedCanvas(terminal) + return false + } + if (!isSynchronizedOutputHeld(terminal)) { + return false + } + return forceFullViewportPresent(terminal) +} + +/** Force a full present through pause/DEC 2026 only for held keystroke echo, replay hold-cap expiry, or a released, new or resized canvas (no coherent frame left to keep). Mid-frame painting exposes unfinished TUIs (#32198); reveal/recovery use requestFullViewportPresent. Normal unpaused paint falls back to refresh: direct first-splash renderRows precedes stable cell metrics and leaves a 1px gutter. */ export function forceFullViewportPresent(terminal: unknown): boolean { const service = getRenderService(terminal) if (!service) { @@ -139,6 +192,8 @@ export function forceFullViewportPresent(terminal: unknown): boolean { // New tabs can still be observer-paused; use refreshRows for its dimension clamp to avoid a 1px gutter. Only DEC 2026 requires direct renderRows. if (syncHeld && typeof renderer?.renderRows === 'function') { renderer.renderRows(0, rows - 1) + // renderRows skips onRender; any forced paint fills the canvas, so only one may land mid-frame. + forgetUnpaintedCanvas(terminal) return true } service.refreshRows(0, rows - 1, true) diff --git a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts index a9d37341081..789c8eeb930 100644 --- a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts +++ b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts @@ -225,7 +225,6 @@ function createManager() { return { setPaneGpuRendering: vi.fn(), markPaneHasComplexScriptOutput: vi.fn(), - rebuildPaneWebgl: vi.fn(), hasWebglRenderer: vi.fn(() => false), getPanes: vi.fn(() => panes), closePane: vi.fn(), diff --git a/tests/e2e/terminal-reveal-paused-render-repro.spec.ts b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts index b01997f87e6..78bc66aed9f 100644 --- a/tests/e2e/terminal-reveal-paused-render-repro.spec.ts +++ b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts @@ -36,6 +36,7 @@ type RevealProbeWindow = Window & { __revealRenderProbe?: RevealRenderDebug __syncRevealProbe?: { paintFrame: (marker: string, background: number, release: boolean) => Promise + writeSpinnerFrame: (glyph: string, release: boolean) => Promise forceRendererPresent: () => void read: () => { atlasClears: number @@ -240,6 +241,17 @@ async function installSynchronizedRevealProbe(page: Page, tabId: string): Promis originalRenderRows(start, end) } + const writeFrame = (data: string, release: boolean): Promise => + new Promise((resolve) => { + terminal.write(data, () => { + if (!release && service._syncOutputHandler?._timeout !== undefined) { + window.clearTimeout(service._syncOutputHandler._timeout) + service._syncOutputHandler._timeout = undefined + } + resolve() + }) + }) + const paintFrame = (marker: string, background: number, release: boolean): Promise => { const width = Math.max(1, terminal.cols) const rows = Math.max(1, terminal.rows) @@ -248,22 +260,22 @@ async function installSynchronizedRevealProbe(page: Page, tabId: string): Promis { length: rows }, (_, row) => `\x1b[${row + 1};1H\x1b[48;5;${background}m\x1b[38;5;231m${line}` ).join('') - return new Promise((resolve) => { - terminal.write( - `\x1b[?2026h\x1b[?1049h\x1b[2J${frame}\x1b[0m${release ? '\x1b[?2026l' : ''}`, - () => { - if (!release && service._syncOutputHandler?._timeout !== undefined) { - window.clearTimeout(service._syncOutputHandler._timeout) - service._syncOutputHandler._timeout = undefined - } - resolve() - } - ) - }) + return writeFrame( + `\x1b[?2026h\x1b[?1049h\x1b[2J${frame}\x1b[0m${release ? '\x1b[?2026l' : ''}`, + release + ) } + // Codex-like idle tick: title spinner and cursor park, each in its own DEC 2026 frame. + const writeSpinnerFrame = (glyph: string, release: boolean): Promise => + writeFrame( + `\x1b[?2026h\x1b]0;${glyph} work\x07${release ? `\x1b[${terminal.rows};3H\x1b[?2026l` : ''}`, + release + ) + ;(window as RevealProbeWindow).__syncRevealProbe = { paintFrame, + writeSpinnerFrame, forceRendererPresent: () => renderer.renderRows?.(0, terminal.rows - 1), read: () => ({ atlasClears, @@ -278,8 +290,8 @@ async function installSynchronizedRevealProbe(page: Page, tabId: string): Promis async function callSynchronizedRevealProbe( page: Page, - action: 'forceRendererPresent' | 'paintFrame', - frame?: { marker: string; background: number; release: boolean } + action: 'forceRendererPresent' | 'paintFrame' | 'writeSpinnerFrame', + frame?: { marker: string; background?: number; release: boolean } ): Promise { await page.evaluate( async ({ action, frame }) => { @@ -291,7 +303,11 @@ async function callSynchronizedRevealProbe( if (!frame) { throw new Error('Synchronized frame missing') } - await probe.paintFrame(frame.marker, frame.background, frame.release) + await probe.paintFrame(frame.marker, frame.background ?? 0, frame.release) + return + } + if (action === 'writeSpinnerFrame') { + await probe.writeSpinnerFrame(frame?.marker ?? '*', frame?.release ?? true) return } probe.forceRendererPresent() @@ -316,6 +332,33 @@ async function captureFirstRevealedFrame(page: Page, tabId: string): Promise { + const glyphs = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834'] + // ast-grep-ignore: test-no-polling-loop -- models Codex's ~30 Hz spinner cadence; nothing is polled + for (let i = 0; i < 12; i++) { + const marker = glyphs[i % glyphs.length] + await callSynchronizedRevealProbe(page, 'writeSpinnerFrame', { + marker, + release: true + }) + // ast-grep-ignore: test-no-real-time-wait -- models Codex's ~30 Hz spinner cadence + await page.waitForTimeout(33) + } + await callSynchronizedRevealProbe(page, 'writeSpinnerFrame', { + marker: glyphs[0], + release: false + }) +} + +async function replaceWebglRenderer(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + manager?.setTerminalGpuAcceleration('off') + manager?.setTerminalGpuAcceleration('on') + }, tabId) +} + test.describe('terminal reveal paused-render recovery', () => { test("reveal repaint forces a render through xterm's paused gate", async ({ orcaPage }) => { // Use orcaPage: the main renderer owns __store/__paneManagers, unlike Playwright’s first page. @@ -391,6 +434,88 @@ test.describe('terminal reveal paused-render recovery', () => { ).toBe(true) }) + test('a WebGL canvas replaced while spinner frames hold DEC 2026 paints instead of staying blank', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const tabId = (await getActiveTabId(orcaPage))! + await forceWebglOn(orcaPage, tabId) + const hasWebgl = () => + orcaPage + .waitForFunction( + (tabId) => + (window.__paneManagers?.get(tabId)?.getRenderingDiagnostics?.() ?? []).some( + (diagnostic) => diagnostic.hasWebgl + ), + tabId, + { timeout: 15_000 } + ) + .then(() => true) + .catch(() => false) + const webglAttached = await hasWebgl() + test.skip(!webglAttached, 'WebGL renderer is unavailable') + if (!webglAttached) { + return + } + expect(await installSynchronizedRevealProbe(orcaPage, tabId)).toBe(true) + await callSynchronizedRevealProbe(orcaPage, 'paintFrame', { + marker: 'SPINNER_PANE', + background: 17, + release: true + }) + const coherent = await captureStableTabScreenshot(orcaPage, tabId) + + await runSpinnerFramesEndingOpen(orcaPage) + expect((await readSynchronizedRevealProbe(orcaPage)).synchronizedOutput).toBe(true) + + await replaceWebglRenderer(orcaPage, tabId) + expect(await hasWebgl(), 'renderer swapped back to WebGL').toBe(true) + const afterSwap = await captureFirstRevealedFrame(orcaPage, tabId) + const swapDiff = compareTerminalScreenshots(coherent, afterSwap) + + // A painted canvas keeps honoring DEC 2026: the next frame's cells stay hidden until it closes. + await callSynchronizedRevealProbe(orcaPage, 'paintFrame', { + marker: 'PENDING_FRAME', + background: 52, + release: false + }) + await orcaPage.evaluate((tabId) => { + window.__paneManagers?.get(tabId)?.scheduleRevealPresent() + }, tabId) + // Let the settled-frame present run before capturing, so the held check is not vacuous. + await orcaPage.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + ) + const held = await captureFirstRevealedFrame(orcaPage, tabId) + const heldDiff = compareTerminalScreenshots(coherent, held) + await callSynchronizedRevealProbe(orcaPage, 'paintFrame', { + marker: 'PENDING_FRAME', + background: 52, + release: true + }) + const released = await captureStableTabScreenshot(orcaPage, tabId) + + const afterSwapPath = testInfo.outputPath('spinner-canvas-after-swap.png') + await writeFile(afterSwapPath, afterSwap) + await testInfo.attach('spinner-canvas-after-swap.png', { + path: afterSwapPath, + contentType: 'image/png' + }) + expect( + swapDiff.matches, + `new canvas shows the pane, not a blank, while DEC 2026 is open (diffRatio=${swapDiff.diffRatio})` + ).toBe(true) + expect( + heldDiff.matches, + `painted canvas keeps the coherent frame while DEC 2026 is held (diffRatio=${heldDiff.diffRatio})` + ).toBe(true) + expect( + compareTerminalScreenshots(coherent, released).matches, + 'release presents the new frame' + ).toBe(false) + }) + test('@headful atlas recovery keeps the coherent frame until synchronized output releases', async ({ orcaPage }, testInfo) => {