diff --git a/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts index 7c64b7d1468..5bb1412c42a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.test.ts @@ -341,6 +341,63 @@ describe('connectPanePty', () => { expect(transport.connect).toHaveBeenCalledWith(expect.objectContaining({ cols: 120, rows: 50 })) }) + it.each(['before', 'during'] as const)( + 'spawns a startup command without waiting for frames when the window is hidden %s the grid settle', + async (hiddenWhen) => { + // A hidden window produces no animation frames, so only the queued ones below ever run. + const frameCallbacks: FrameRequestCallback[] = [] + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const visibilityListeners: (() => void)[] = [] + const fakeDocument = { + visibilityState: hiddenWhen === 'before' ? 'hidden' : 'visible', + addEventListener: vi.fn((type: string, listener: () => void) => { + if (type === 'visibilitychange') { + visibilityListeners.push(listener) + } + }), + removeEventListener: vi.fn() + } + vi.stubGlobal('document', fakeDocument) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.container = createMeasuredElement({ rect: () => createRect(1200, 800) }) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + pane.fitAddon.fit = vi.fn(() => { + pane.terminal.cols = 120 + pane.terminal.rows = 40 + }) + const deps = createDeps({ startup: { command: 'claude' } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pane, manager and deps doubles implement every member connectPanePty reads on this startup path. + const args = [pane, createManager(1), deps] as unknown as Parameters + connectPanePty(...args) + + if (hiddenWhen === 'before') { + vi.advanceTimersByTime(250) + } else { + for (let i = 0; i < 3; i++) { + frameCallbacks.shift()?.(0) + } + expect(transport.connect).not.toHaveBeenCalled() + fakeDocument.visibilityState = 'hidden' + for (const listener of visibilityListeners) { + listener() + } + } + + expect(createdTransportOptions[0]?.command).toBe('claude') + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ cols: 120, rows: 40 }) + ) + } + ) + it('spawns the delayed main setup-split pane when its sibling owns the tab PTY fallback', async () => { const frameCallbacks: FrameRequestCallback[] = [] globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts index 441141069c5..095668d4de6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts @@ -8,6 +8,7 @@ import { reconcilePtySizeAcrossFrames } from '../pty-size-reconcile' import { shouldClaimRemoteDesktopViewport } from '../remote-desktop-viewport-claim' import { deferTerminalGeometryMutationDuringRebuild } from '@/lib/pane-manager/terminal-scroll-intent-rebuild' import { waitForStableStartupGrid } from '../terminal-startup-grid-settle' +import { getWindowParkVisible, subscribeWindowParkVisibility } from '@/lib/window-park-visibility' import { isSetupSplitGeometryReady } from './setup-split-geometry' @@ -207,9 +208,12 @@ export function installPtyResizeGeometry(session: ConnectPanePtySession): void { const rows = session.pane.terminal.rows return cols > 0 && rows > 0 ? { cols, rows } : null } + // Why: the settle only prevents visible artifacts, and a hidden pane or window paints none + // (nor produces the frames the settle counts). session.shouldSettleStartupGridBeforeConnect = (): boolean => Boolean(session.paneStartup?.command) && session.deps.isVisibleRef.current && + getWindowParkVisible() && !session.unservedSshTargetId && session.runtimeEnvironmentId === null session.isStartupGridReadyForConnect = (): boolean => { @@ -223,7 +227,14 @@ export function installPtyResizeGeometry(session: ConnectPanePtySession): void { } session.settleStartupGridBeforeConnect = (connect: () => void): void => { session.startupGridSettleHandle?.cancel() - let settledSynchronously = false + let settled = false + let unsubscribeWindowVisibility = (): void => {} + const finish = (): void => { + settled = true + unsubscribeWindowVisibility() + session.startupGridSettleHandle = null + connect() + } // Why: local startup commands can launch a TUI before the split-pane grid // has settled; spawn from a briefly stable grid so the TUI paints cleanly. const handle = waitForStableStartupGrid({ @@ -232,11 +243,7 @@ export function installPtyResizeGeometry(session: ConnectPanePtySession): void { ? session.isStartupGridReadyForConnect : undefined, measure: session.measureStartupGrid, - onSettled: () => { - settledSynchronously = true - session.startupGridSettleHandle = null - connect() - }, + onSettled: finish, requestFrame: (callback) => requestAnimationFrame(callback), cancelFrame: (handle) => { if (typeof cancelAnimationFrame === 'function') { @@ -244,8 +251,21 @@ export function installPtyResizeGeometry(session: ConnectPanePtySession): void { } } }) - if (!settledSynchronously) { - session.startupGridSettleHandle = handle + if (settled) { + return + } + // Why: a window hidden mid-settle stops producing frames; spawn now, not when it is shown again. + unsubscribeWindowVisibility = subscribeWindowParkVisibility(() => { + if (!getWindowParkVisible()) { + handle.cancel() + finish() + } + }) + session.startupGridSettleHandle = { + cancel: () => { + handle.cancel() + unsubscribeWindowVisibility() + } } } }