fix(ssh): repaint background-tab panes revealed after a reconnect

Completes 834a495038, which only fixed the ACTIVE tab. Reported: split panes of
plain shells on another tab were still blank after reconnect until a divider drag
or a sidebar toggle.

The repaint did reach background managers — they stay mounted, only
rendererVisible flips — but it could not land. A tab-hidden pane measures as a
0-size box, so canMeasurePaneForFit bails and the fit is a no-op, and
refreshAllPanes marks rows dirty on a pane with no presented frame, which cannot
repair a grid the reattach's direct terminal.resize left diverged. The reveal
then takes the light resume path, which deliberately does not fit, and
scheduleRevealRepaint only reattaches WebGL. So nothing ever fixed the geometry —
and a divider drag or sidebar toggle is a real fit, which is why those appeared
to work.

Parks the repaint on a hidden manager and replays it on reveal, reusing the
existing reveal-fit machinery rather than adding a mechanism. Flag-gated so the
light path still does not fit in the ordinary case — 'does not fit on a light tab
reveal' stays green.

Splits are not special: the gap is per-manager, so it is identical for 1 or N
panes. Splits just expose it, because users find the workaround (drag a divider)
that a single full-tab pane rarely gets. A never-mounted tab is unaffected — it
has no live manager and fits through the normal initial-fit lifecycle.

Mutation-proved twice: removing the deferral, and reverting the reveal-side
condition. Each reddens only the new tests.
This commit is contained in:
Neil
2026-08-14 16:05:22 -07:00
parent 2fdab478c0
commit 34fc1424f0
5 changed files with 287 additions and 2 deletions
@@ -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<typeof vi.fn>
scheduleRevealRepaint: ReturnType<typeof vi.fn>
} {
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)
})
})
@@ -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) {
@@ -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<void> {
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<void> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -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<object>()
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)
}
@@ -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?.()