From 2fdab478c08892246464ddab21a792c289ea8c3f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:37:52 -0700 Subject: [PATCH] fix(ssh): repaint the panes after a reconnect, not just reattach them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../direct-ssh-reconnect-repaint.test.ts | 62 +++++++++++++++++++ src/renderer/src/hooks/useIpcEvents.ts | 14 ++++- 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/hooks/direct-ssh-reconnect-repaint.test.ts diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-repaint.test.ts b/src/renderer/src/hooks/direct-ssh-reconnect-repaint.test.ts new file mode 100644 index 00000000000..69ba9e3ff3b --- /dev/null +++ b/src/renderer/src/hooks/direct-ssh-reconnect-repaint.test.ts @@ -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() + } + }) +}) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index b91a1372430..4b234e7cee0 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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) =>