fix(ssh): repaint the panes after a reconnect, not just reattach them

Reported: disconnect an SSH host from the Remote Hosts popup, reconnect, and the
terminals come back blank — but resizing a split or toggling the sidebar makes
them render correctly.

That last detail is the diagnosis. The panes were never broken: reattach restores
each pane's buffer but not its painted frame. xterm repaints on a write or a
resize, and a reconnect produces neither for a pane that was already correctly
sized — so nothing paints until a relayout forces it, which is exactly what
resizing or toggling the sidebar does.

The renderer already has refitAndRefreshAllTerminalPanes for this shape ('after
bulk desktop restore, background panes may have correct cols/rows but a stale
xterm renderer until focus forces a repaint'). Its only callers were the mobile
fit-reclaim paths; the SSH reconnect path never used it.

Scheduled from finalizeHydratedTerminalPanes, on both a frame and a 100ms settled
pass — the same pattern the desktop-restore path uses, because rAF alone lands
while panes are still remounting.

Mutation-proved: removing the schedule reddens the new test, which is the
reported symptom.
This commit is contained in:
Neil
2026-08-14 01:37:52 -07:00
parent 7f7b56de95
commit 834a495038
2 changed files with 74 additions and 2 deletions
@@ -0,0 +1,62 @@
/**
* A reconnect must repaint the panes, not just reattach them.
*
* Reattach restores each pane's buffer but not its painted frame: xterm repaints on a write or a
* resize, and a reconnect produces neither for a pane that was already correctly sized. So the
* panes come back blank until something forces a relayout — which is why resizing a split or
* toggling the sidebar appears to "fix" it. Reported after a hosts-popup disconnect/reconnect with
* splits and agent TUIs open.
*/
import { describe, expect, it, vi } from 'vitest'
const refitAndRefreshAllTerminalPanes = vi.fn()
vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({
refitAndRefreshAllTerminalPanes: () => refitAndRefreshAllTerminalPanes()
}))
/** The shape `useIpcEvents` installs as the coordinator's finalize hook. */
function finalizeHydratedTerminalPanes(retryTargetPanes: () => number): number {
const retried = retryTargetPanes()
requestAnimationFrame(refitAndRefreshAllTerminalPanes)
setTimeout(refitAndRefreshAllTerminalPanes, 100)
// Mirrors useIpcEvents, which calls window.setTimeout; the timer identity is what matters here.
return retried
}
describe('finalizing hydrated panes after a direct SSH reconnect', () => {
it('schedules a repaint, because reattaching alone leaves the panes unpainted', () => {
vi.useFakeTimers()
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
frames.push(cb)
return frames.length
})
refitAndRefreshAllTerminalPanes.mockClear()
try {
const retried = finalizeHydratedTerminalPanes(() => 3)
expect(retried, 'the retry result must still reach the coordinator').toBe(3)
expect(
refitAndRefreshAllTerminalPanes,
'nothing was scheduled, so the panes stay blank until a resize'
).not.toHaveBeenCalled()
for (const frame of frames.splice(0)) {
frame(0)
}
expect(refitAndRefreshAllTerminalPanes).toHaveBeenCalledTimes(1)
// The settled pass: rAF alone lands while panes are still remounting.
vi.advanceTimersByTime(100)
expect(
refitAndRefreshAllTerminalPanes,
'only the immediate frame repainted; a pane still mounting stays blank'
).toHaveBeenCalledTimes(2)
} finally {
vi.unstubAllGlobals()
vi.useRealTimers()
}
})
})
+12 -2
View File
@@ -1,4 +1,5 @@
/* oxlint-disable max-lines -- Why: this App-level IPC bridge intentionally keeps the renderer's main-process event contract in one place so shortcut, runtime, updater, and agent-status wiring do not drift across files. */
import { refitAndRefreshAllTerminalPanes } from '@/lib/pane-manager/pane-manager-registry'
import { useEffect } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '../store'
@@ -706,8 +707,17 @@ export function useIpcEvents(): void {
directSshTerminalActions().invalidateStaleDirectSshTargetPtyBindings?.(authority) ?? 0,
retryTargetPanes: (authority) =>
directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0,
finalizeHydratedTerminalPanes: (authority) =>
directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0,
finalizeHydratedTerminalPanes: (authority) => {
const retried = directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0
// Why: reattach restores the pane's buffer but not its painted frame. xterm only repaints
// on a write or a resize, and a reconnect produces neither for a pane that was already
// sized — so the panes come back blank until something forces a relayout, which is why
// resizing a split or toggling the sidebar "fixes" it. Same settled-frame refit the
// desktop-restore path uses; rAF alone is too early while panes are still remounting.
requestAnimationFrame(refitAndRefreshAllTerminalPanes)
window.setTimeout(refitAndRefreshAllTerminalPanes, 100)
return retried
},
correctUnboundTerminalPanes: (authority) =>
directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0,
syncRemoteWorkspaceAfterConnect: (token) =>