diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 2feeadd4b..031749cb4 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -18,6 +18,7 @@ import type { ITheme } from "@xterm/xterm"; import type { TerminalState } from "@server/shared/messages"; import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime"; +import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; import { openExternalUrl } from "../utils/open-external-url"; import { focusWithRetries } from "../utils/web-focus"; import { @@ -127,6 +128,7 @@ interface TerminalEmulatorProps { meta: boolean; }) => Promise | void; onPendingModifiersConsumed?: () => Promise | void; + onRendererReadyChange?: (change: TerminalRendererReadyChange) => void; pendingModifiers?: PendingTerminalModifiers; focusRequestToken?: number; resizeRequestToken?: number; @@ -181,6 +183,7 @@ export default function TerminalEmulator({ onResize, onTerminalKey, onPendingModifiersConsumed, + onRendererReadyChange, pendingModifiers = { ctrl: false, shift: false, alt: false }, focusRequestToken = 0, resizeRequestToken = 0, @@ -198,6 +201,8 @@ export default function TerminalEmulator({ const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]); const xtermThemeRef = useRef(xtermTheme); xtermThemeRef.current = xtermTheme; + const onRendererReadyChangeRef = useRef(onRendererReadyChange); + onRendererReadyChangeRef.current = onRendererReadyChange; const mountCallbacksRef = useRef({ onInput, onResize, @@ -389,9 +394,11 @@ export default function TerminalEmulator({ initialSnapshot: initialSnapshotRef.current, theme: mountedThemeRef.current, }); + onRendererReadyChangeRef.current?.({ streamKey, isReady: true }); return () => { runtime.unmount(); + onRendererReadyChangeRef.current?.({ streamKey, isReady: false }); if (runtimeRef.current === runtime) { runtimeRef.current = null; } diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index afcec86ff..ca139b484 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -28,6 +28,12 @@ import { usePanelStore } from "@/stores/panel-store"; import { toXtermTheme } from "@/utils/to-xterm-theme"; import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { + applyTerminalRendererReadyChange, + shouldReplayTerminalSnapshotForRenderer, + shouldShowTerminalLoadingOverlay, + type TerminalRendererReadyChange, +} from "@/utils/terminal-renderer-readiness"; interface TerminalPaneProps { serverId: string; @@ -164,6 +170,7 @@ export function TerminalPane({ const isConnected = useHostRuntimeIsConnected(serverId); const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); + const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]); // Keep the latest measured size for whichever client currently owns the pane, // but only dedupe resizes that this specific client has already pushed. const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null); @@ -175,6 +182,7 @@ export function TerminalPane({ ); const [isAttaching, setIsAttaching] = useState(false); const [streamError, setStreamError] = useState(null); + const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState(null); const [modifiers, setModifiers] = useState(EMPTY_MODIFIERS); const [focusRequestToken, setFocusRequestToken] = useState(0); const [resizeRequestToken, setResizeRequestToken] = useState(0); @@ -195,6 +203,20 @@ export function TerminalPane({ const requestTerminalReflow = useCallback(() => { setResizeRequestToken((current) => current + 1); }, []); + const handleRendererReadyChange = useCallback( + (change: TerminalRendererReadyChange) => { + setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change)); + if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) { + return; + } + + const snapshot = workspaceTerminalSession.snapshots.get({ terminalId }); + if (snapshot) { + emulatorRef.current?.renderSnapshot(snapshot); + } + }, + [terminalId, terminalStreamKey, workspaceTerminalSession.snapshots], + ); useEffect(() => { if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) { @@ -617,6 +639,13 @@ export function TerminalPane({ if (!swipeGesturesEnabled) return; onOpenFileExplorer(); }, [swipeGesturesEnabled, onOpenFileExplorer]); + const showLoadingOverlay = shouldShowTerminalLoadingOverlay({ + isWorkspaceFocused, + hasStreamError: Boolean(streamError), + isAttaching, + rendererReadyStreamKey, + terminalStreamKey, + }); if (!client || !isConnected) { return ( @@ -634,11 +663,12 @@ export function TerminalPane({ )} - {isAttaching && isWorkspaceFocused ? ( + {showLoadingOverlay ? ( diff --git a/packages/app/src/utils/terminal-renderer-readiness.test.ts b/packages/app/src/utils/terminal-renderer-readiness.test.ts new file mode 100644 index 000000000..664b57c2c --- /dev/null +++ b/packages/app/src/utils/terminal-renderer-readiness.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { + applyTerminalRendererReadyChange, + shouldReplayTerminalSnapshotForRenderer, + shouldShowTerminalLoadingOverlay, +} from "./terminal-renderer-readiness"; + +describe("terminal-renderer-readiness", () => { + it("preserves the attach loader even after the renderer is ready", () => { + expect( + shouldShowTerminalLoadingOverlay({ + isWorkspaceFocused: true, + hasStreamError: false, + isAttaching: true, + rendererReadyStreamKey: "scope:terminal-1", + terminalStreamKey: "scope:terminal-1", + }), + ).toBe(true); + }); + + it("keeps the loader visible until the current renderer is ready", () => { + expect( + shouldShowTerminalLoadingOverlay({ + isWorkspaceFocused: true, + hasStreamError: false, + isAttaching: false, + rendererReadyStreamKey: null, + terminalStreamKey: "scope:terminal-1", + }), + ).toBe(true); + }); + + it("hides the loader only after attach completes and the current renderer is ready", () => { + expect( + shouldShowTerminalLoadingOverlay({ + isWorkspaceFocused: true, + hasStreamError: false, + isAttaching: false, + rendererReadyStreamKey: "scope:terminal-1", + terminalStreamKey: "scope:terminal-1", + }), + ).toBe(false); + }); + + it("does not cover stream errors", () => { + expect( + shouldShowTerminalLoadingOverlay({ + isWorkspaceFocused: true, + hasStreamError: true, + isAttaching: true, + rendererReadyStreamKey: null, + terminalStreamKey: "scope:terminal-1", + }), + ).toBe(false); + }); + + it("ignores stale unready events from an old renderer", () => { + const current = applyTerminalRendererReadyChange("scope:terminal-2", { + streamKey: "scope:terminal-1", + isReady: false, + }); + + expect(current).toBe("scope:terminal-2"); + }); + + it("clears readiness when the current renderer unmounts", () => { + const current = applyTerminalRendererReadyChange("scope:terminal-1", { + streamKey: "scope:terminal-1", + isReady: false, + }); + + expect(current).toBeNull(); + }); + + it("replays snapshots only for ready events from the current renderer", () => { + expect( + shouldReplayTerminalSnapshotForRenderer({ + change: { streamKey: "scope:terminal-1", isReady: true }, + terminalStreamKey: "scope:terminal-1", + }), + ).toBe(true); + expect( + shouldReplayTerminalSnapshotForRenderer({ + change: { streamKey: "scope:terminal-1", isReady: false }, + terminalStreamKey: "scope:terminal-1", + }), + ).toBe(false); + expect( + shouldReplayTerminalSnapshotForRenderer({ + change: { streamKey: "scope:terminal-1", isReady: true }, + terminalStreamKey: "scope:terminal-2", + }), + ).toBe(false); + }); +}); diff --git a/packages/app/src/utils/terminal-renderer-readiness.ts b/packages/app/src/utils/terminal-renderer-readiness.ts new file mode 100644 index 000000000..c1cbda26f --- /dev/null +++ b/packages/app/src/utils/terminal-renderer-readiness.ts @@ -0,0 +1,36 @@ +export interface TerminalRendererReadyChange { + streamKey: string; + isReady: boolean; +} + +export function applyTerminalRendererReadyChange( + currentReadyStreamKey: string | null, + change: TerminalRendererReadyChange, +): string | null { + if (change.isReady) { + return change.streamKey; + } + + return currentReadyStreamKey === change.streamKey ? null : currentReadyStreamKey; +} + +export function shouldReplayTerminalSnapshotForRenderer(input: { + change: TerminalRendererReadyChange; + terminalStreamKey: string; +}): boolean { + return input.change.isReady && input.change.streamKey === input.terminalStreamKey; +} + +export function shouldShowTerminalLoadingOverlay(input: { + isWorkspaceFocused: boolean; + hasStreamError: boolean; + isAttaching: boolean; + rendererReadyStreamKey: string | null; + terminalStreamKey: string; +}): boolean { + return ( + input.isWorkspaceFocused && + !input.hasStreamError && + (input.isAttaching || input.rendererReadyStreamKey !== input.terminalStreamKey) + ); +}