diff --git a/src/renderer/src/components/terminal-pane/hidden-tab-reconnect-repaint.test.ts b/src/renderer/src/components/terminal-pane/hidden-tab-reconnect-repaint.test.ts new file mode 100644 index 00000000000..9b70054bbc2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hidden-tab-reconnect-repaint.test.ts @@ -0,0 +1,151 @@ +/** + * A reconnect repaint must survive until a hidden tab is revealed. + * + * Reported after an SSH disconnect/reconnect with splits open across several tabs: the panes on + * the tab that was NOT active come back blank, and stay blank when the user switches to that tab, + * until a split resize or a sidebar toggle. The reconnect repaint reaches every live manager, but + * for a tab-hidden one it lands on display:none panes — `safeFit` refuses them (no measurable box) + * and the refresh has no presented frame to update. The reveal then takes the light tab path, + * which deliberately does not fit, so nothing ever reflows the grid the reattach left diverged or + * releases the reattach grid push parked on a measurable fit. + * + * Real registry, real deferral, real resume: only the manager is faked. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { + refitAndRefreshAllTerminalPanes, + registerLivePaneManager, + unregisterLivePaneManager +} from '@/lib/pane-manager/pane-manager-registry' +import { resumeTerminalVisibility } from './terminal-visibility-resume' + +vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ + flushTerminalOutput: vi.fn(), + requestTerminalBacklogRecovery: vi.fn() +})) +vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({ + enforceTerminalCurrentScrollIntent: vi.fn(), + syncTerminalScrollIntentFromViewport: vi.fn() +})) +vi.mock('@/lib/pane-manager/terminal-linkifier-hover-reset', () => ({ + resetTerminalLinkifierHoverState: vi.fn(), + isTerminalLinkifierHoverActive: vi.fn(() => false) +})) +vi.mock('@/lib/pane-manager/terminal-canvas-dpr-repair', () => ({ + repairPaneWebglCanvasDprMismatch: vi.fn(() => false) +})) +// Kept false so the deferred repaint is the only thing that can trigger the reveal fit. +vi.mock('@/lib/pane-manager/pane-fit', () => ({ + flushDeferredPaneMetricOptionsIfMeasurable: vi.fn(() => false) +})) +vi.mock('./pane-helpers', () => ({ + fitAndFocusPanes: vi.fn(), + fitPanes: vi.fn(), + focusActivePane: vi.fn() +})) +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTabRevealWebglAtlasRecovery: vi.fn() +})) + +function createHiddenTabManager(): { + manager: PaneManager + visible: { current: boolean } + fitAllRevealedPanes: ReturnType + scheduleRevealRepaint: ReturnType +} { + const visible = { current: false } + const fitAllRevealedPanes = vi.fn() + const scheduleRevealRepaint = vi.fn() + const manager = { + getPanes: vi.fn(() => []), + resetWebglTextureAtlases: vi.fn(), + fitAllPanes: vi.fn(), + fitAllRevealedPanes, + refreshAllPanes: vi.fn(), + scheduleRevealRepaint, + scheduleRevealPresent: vi.fn(), + resumeRendering: vi.fn(), + isVisibleForAtlasRecovery: () => visible.current + } + return { + manager: manager as never as PaneManager, + visible, + fitAllRevealedPanes, + scheduleRevealRepaint + } +} + +function revealTab(manager: PaneManager): void { + resumeTerminalVisibility({ + manager, + isActive: true, + wasVisible: false, + // The intra-worktree tab switch: the tab was hidden, its worktree never was. + shouldUseLightTabResume: true, + captureViewportPositions: vi.fn(() => new Map()), + withSuppressedScrollTracking: (callback: () => void) => callback() + }) +} + +describe('revealing a tab that was hidden during an SSH reconnect', () => { + const registered: object[] = [] + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + for (const manager of registered.splice(0)) { + unregisterLivePaneManager(manager as never) + } + }) + + it('repaints on reveal, because the reconnect repaint could not land while the tab was hidden', () => { + const { manager, visible, fitAllRevealedPanes, scheduleRevealRepaint } = + createHiddenTabManager() + registerLivePaneManager(manager as never) + registered.push(manager) + + // Reconnect finalizes while this tab is in the background. + refitAndRefreshAllTerminalPanes() + expect(fitAllRevealedPanes, 'a hidden pane has no box to fit').not.toHaveBeenCalled() + + // The user switches to the tab. + visible.current = true + revealTab(manager) + + expect( + fitAllRevealedPanes, + 'the reveal never repaired the grid, so the panes stay blank until a resize' + ).toHaveBeenCalledTimes(1) + expect(scheduleRevealRepaint).toHaveBeenCalledTimes(1) + }) + + it('replays the parked repaint once, not on every later tab switch', () => { + const { manager, visible, fitAllRevealedPanes } = createHiddenTabManager() + registerLivePaneManager(manager as never) + registered.push(manager) + + refitAndRefreshAllTerminalPanes() + visible.current = true + revealTab(manager) + revealTab(manager) + + expect(fitAllRevealedPanes).toHaveBeenCalledTimes(1) + }) + + it('leaves an ordinary tab switch fitless, keeping the light path off the overlay geometry race', () => { + const { manager, visible, fitAllRevealedPanes, scheduleRevealRepaint } = + createHiddenTabManager() + registerLivePaneManager(manager as never) + registered.push(manager) + // No reconnect repaint arrived while this tab was hidden. + + visible.current = true + revealTab(manager) + + expect(fitAllRevealedPanes).not.toHaveBeenCalled() + expect(scheduleRevealRepaint).toHaveBeenCalledTimes(1) + }) +}) 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 ee86edeba18..04a55e7f44b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -16,6 +16,7 @@ import { import { focusActivePane } from './pane-helpers' import { scheduleTabRevealWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' import { flushDeferredPaneMetricOptionsIfMeasurable } from '@/lib/pane-manager/pane-fit' +import { consumeDeferredPaneManagerRepaint } from '@/lib/pane-manager/pane-hidden-repaint-deferral' import { repairPaneWebglCanvasDprMismatch } from '@/lib/pane-manager/terminal-canvas-dpr-repair' const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 @@ -67,6 +68,10 @@ export function resumeTerminalVisibility({ resetTerminalLinkifierHoverState(pane.terminal) } syncTerminalViewportIntents(manager) + // Why: a repaint that arrived while this manager was hidden (SSH reconnect, + // desktop restore) could neither fit nor paint then; consume it on both paths + // so the heavy resume, which always fits, does not leave it armed. + const hadDeferredRepaint = consumeDeferredPaneManagerRepaint(manager) // Why: WebGL resume can disturb xterm's viewport bookkeeping before the // post-resume fit runs. Capture numeric viewport positions first; the // restore path avoids content matching so duplicate agent log lines do @@ -90,9 +95,13 @@ export function resumeTerminalVisibility({ requestLightTabBacklogRecovery(manager) // Why: reveal is the lifecycle boundary that owns hidden renderer repair. scheduleTabRevealWebglAtlasRecovery() - if (flushedDeferredMetrics) { + if (flushedDeferredMetrics || hadDeferredRepaint) { // Why: the light path normally skips fitting, but flushed metrics changed - // cell size — refit so cols/rows match before the overlay settles. + // cell size — refit so cols/rows match before the overlay settles. A + // repaint parked while hidden needs the same fit: it is the only reveal + // step that reflows a grid an SSH reattach left diverged and releases the + // reattach grid push parked on a measurable fit. fitRevealedPane still + // leaves an already-correct grid alone, so this cannot reflow-garble. manager.fitAllRevealedPanes() } if (isActive) { diff --git a/src/renderer/src/lib/pane-manager/painted-pane-fixture.ts b/src/renderer/src/lib/pane-manager/painted-pane-fixture.ts new file mode 100644 index 00000000000..39413e13081 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/painted-pane-fixture.ts @@ -0,0 +1,99 @@ +/** + * Test fixture: a real PaneManager driving real @xterm/xterm panes whose + * PAINTED layer is observable, not just their buffer. + * + * Why it exists: every other pane test stubs `terminal` as `{ cols, rows, refresh: vi.fn() }`, + * so "the pane repainted" degrades to "we called a spy". The bugs that keep shipping are + * blank-but-attached panes — the buffer is right and the presented frame is not. Under + * happy-dom xterm falls back to its DOM renderer, whose `.xterm-rows` subtree IS the + * presented frame: what the user would see. `blankPaintedLayer` models a renderer that is + * compositing nothing (stale/blank pixels) while the buffer is intact, which is exactly the + * reported reconnect/reveal symptom; production repaint paths must bring it back. + */ +import { Terminal } from '@xterm/xterm' +import { vi } from 'vitest' +import { PaneManager } from './pane-manager' +import type { ManagedPane } from './pane-manager-types' + +/** xterm's WidthCache needs a 2D context to open a terminal at all. */ +export function stubTerminalTextMeasurement(): void { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) +} + +export type PaneTab = { + manager: PaneManager + root: HTMLElement +} + +const liveTabs: PaneTab[] = [] + +/** One tab = one PaneManager, exactly as TerminalPane mounts them. */ +export function createPaneTab(opts: { background?: boolean } = {}): PaneTab { + const root = document.createElement('div') + document.body.appendChild(root) + const manager = new PaneManager(root, { + linkOpenHint: () => 'open', + ...(opts.background ? { initialRenderingSuspended: true } : {}) + }) + const tab: PaneTab = { manager, root } + liveTabs.push(tab) + return tab +} + +/** Destroys every tab created by the fixture; PaneManager.destroy unregisters + * it from the module-global live-manager registry the repaint paths walk. */ +export function destroyPaneTabs(): void { + for (const tab of liveTabs.splice(0)) { + tab.manager.destroy() + tab.root.remove() + } +} + +export function writeToPane(pane: ManagedPane, data: string): Promise { + return new Promise((resolve) => pane.terminal.write(data, resolve)) +} + +/** Enter the alternate screen and paint a frame, as an agent TUI does. Alt-screen + * content has no scrollback and does not reflow: only a repaint or a fresh write + * from the far side can restore it. */ +export async function writeAlternateScreenFrame(pane: ManagedPane, lines: string[]): Promise { + await writeToPane(pane, `\x1b[?1049h\x1b[2J\x1b[H${lines.join('\r\n')}`) +} + +function rowsElement(pane: ManagedPane): Element | null { + return pane.terminal.element?.querySelector('.xterm-rows') ?? null +} + +/** What the user sees: the DOM renderer's presented rows. */ +export function paintedText(pane: ManagedPane): string { + return (rowsElement(pane)?.textContent ?? '').trim() +} + +/** Models a renderer presenting nothing while the buffer is untouched — the + * blank-but-attached pane users report after a reconnect or a tab reveal. */ +export function blankPaintedLayer(pane: ManagedPane): void { + for (const row of Array.from(rowsElement(pane)?.children ?? [])) { + row.textContent = '' + } +} + +/** Buffer text including scrollback, so paint assertions can be separated from + * content loss (a repaint that scrolls or clears is not a repaint). */ +export function bufferText(pane: ManagedPane): string { + const buffer = pane.terminal.buffer.active + const lines: string[] = [] + for (let y = 0; y < buffer.length; y++) { + lines.push(buffer.getLine(y)?.translateToString(true) ?? '') + } + while (lines.length > 0 && lines.at(-1) === '') { + lines.pop() + } + return lines.join('\n') +} + +/** Lets the double-rAF settled-frame schedulers and the 100ms settled pass run. */ +export function settleFrames(ms = 150): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/renderer/src/lib/pane-manager/pane-hidden-repaint-deferral.ts b/src/renderer/src/lib/pane-manager/pane-hidden-repaint-deferral.ts new file mode 100644 index 00000000000..0fa649e9462 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-hidden-repaint-deferral.ts @@ -0,0 +1,19 @@ +/** + * Carries a repaint request across a pane manager's hidden window. + * + * Why: a repaint that arrives while a terminal tab is hidden is simply lost — + * `safeFit` refuses a display:none pane (no measurable box) and `refresh` has no + * presented frame to update. The tab's reveal is the next moment those panes can + * be repaired, so park the request on the manager and let the reveal replay it + * instead of leaving the panes unpainted until the user resizes something. + */ +const managersAwaitingRevealRepaint = new WeakSet() + +export function deferPaneManagerRepaintUntilReveal(manager: object): void { + managersAwaitingRevealRepaint.add(manager) +} + +/** Returns true once per parked repaint; the reveal owns the repair from here. */ +export function consumeDeferredPaneManagerRepaint(manager: object): boolean { + return managersAwaitingRevealRepaint.delete(manager) +} diff --git a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts index 588970436de..d0cdcccce18 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts @@ -1,5 +1,6 @@ import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' import { registerRendererMemoryProfileContributor } from '../renderer-memory-profile' +import { deferPaneManagerRepaintUntilReveal } from './pane-hidden-repaint-deferral' import type { PaneRenderingDiagnostics } from './pane-manager-types' type RegisteredPaneManager = { @@ -164,6 +165,12 @@ export function forEachLivePaneForDesyncSentinel( export function refitAndRefreshAllTerminalPanes(): void { for (const manager of liveManagers) { try { + if (manager.isVisibleForAtlasRecovery?.() === false) { + // Why: a hidden tab's panes have no measurable box, so the fit below + // refuses them and the refresh presents nothing. Park the repaint on the + // manager's reveal, which is the next moment it can actually land. + deferPaneManagerRepaintUntilReveal(manager) + } // Why: after bulk desktop restore, background panes may have correct // cols/rows but a stale xterm renderer until focus forces a repaint. manager.fitAllPanes?.()