Fix terminal renderer remount loading

This commit is contained in:
Mohamed Boudra
2026-05-03 20:38:12 +07:00
parent ade05607d2
commit b7f9092e93
4 changed files with 171 additions and 2 deletions
@@ -18,6 +18,7 @@ import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages"; import type { TerminalState } from "@server/shared/messages";
import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime"; import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url"; import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus"; import { focusWithRetries } from "../utils/web-focus";
import { import {
@@ -127,6 +128,7 @@ interface TerminalEmulatorProps {
meta: boolean; meta: boolean;
}) => Promise<void> | void; }) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void; onPendingModifiersConsumed?: () => Promise<void> | void;
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
pendingModifiers?: PendingTerminalModifiers; pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number; focusRequestToken?: number;
resizeRequestToken?: number; resizeRequestToken?: number;
@@ -181,6 +183,7 @@ export default function TerminalEmulator({
onResize, onResize,
onTerminalKey, onTerminalKey,
onPendingModifiersConsumed, onPendingModifiersConsumed,
onRendererReadyChange,
pendingModifiers = { ctrl: false, shift: false, alt: false }, pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0, focusRequestToken = 0,
resizeRequestToken = 0, resizeRequestToken = 0,
@@ -198,6 +201,8 @@ export default function TerminalEmulator({
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]); const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
const xtermThemeRef = useRef(xtermTheme); const xtermThemeRef = useRef(xtermTheme);
xtermThemeRef.current = xtermTheme; xtermThemeRef.current = xtermTheme;
const onRendererReadyChangeRef = useRef(onRendererReadyChange);
onRendererReadyChangeRef.current = onRendererReadyChange;
const mountCallbacksRef = useRef({ const mountCallbacksRef = useRef({
onInput, onInput,
onResize, onResize,
@@ -389,9 +394,11 @@ export default function TerminalEmulator({
initialSnapshot: initialSnapshotRef.current, initialSnapshot: initialSnapshotRef.current,
theme: mountedThemeRef.current, theme: mountedThemeRef.current,
}); });
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
return () => { return () => {
runtime.unmount(); runtime.unmount();
onRendererReadyChangeRef.current?.({ streamKey, isReady: false });
if (runtimeRef.current === runtime) { if (runtimeRef.current === runtime) {
runtimeRef.current = null; runtimeRef.current = null;
} }
+32 -2
View File
@@ -28,6 +28,12 @@ import { usePanelStore } from "@/stores/panel-store";
import { toXtermTheme } from "@/utils/to-xterm-theme"; import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator"; import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import {
applyTerminalRendererReadyChange,
shouldReplayTerminalSnapshotForRenderer,
shouldShowTerminalLoadingOverlay,
type TerminalRendererReadyChange,
} from "@/utils/terminal-renderer-readiness";
interface TerminalPaneProps { interface TerminalPaneProps {
serverId: string; serverId: string;
@@ -164,6 +170,7 @@ export function TerminalPane({
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); 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, // Keep the latest measured size for whichever client currently owns the pane,
// but only dedupe resizes that this specific client has already pushed. // but only dedupe resizes that this specific client has already pushed.
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null); const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
@@ -175,6 +182,7 @@ export function TerminalPane({
); );
const [isAttaching, setIsAttaching] = useState(false); const [isAttaching, setIsAttaching] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null); const [streamError, setStreamError] = useState<string | null>(null);
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS); const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
const [focusRequestToken, setFocusRequestToken] = useState(0); const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0); const [resizeRequestToken, setResizeRequestToken] = useState(0);
@@ -195,6 +203,20 @@ export function TerminalPane({
const requestTerminalReflow = useCallback(() => { const requestTerminalReflow = useCallback(() => {
setResizeRequestToken((current) => current + 1); 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(() => { useEffect(() => {
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) { if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
@@ -617,6 +639,13 @@ export function TerminalPane({
if (!swipeGesturesEnabled) return; if (!swipeGesturesEnabled) return;
onOpenFileExplorer(); onOpenFileExplorer();
}, [swipeGesturesEnabled, onOpenFileExplorer]); }, [swipeGesturesEnabled, onOpenFileExplorer]);
const showLoadingOverlay = shouldShowTerminalLoadingOverlay({
isWorkspaceFocused,
hasStreamError: Boolean(streamError),
isAttaching,
rendererReadyStreamKey,
terminalStreamKey,
});
if (!client || !isConnected) { if (!client || !isConnected) {
return ( return (
@@ -634,11 +663,12 @@ export function TerminalPane({
<TerminalEmulator <TerminalEmulator
ref={emulatorRef} ref={emulatorRef}
dom={TERMINAL_EMULATOR_DOM_PROPS} dom={TERMINAL_EMULATOR_DOM_PROPS}
streamKey={`${scopeKey}:${terminalId}`} streamKey={terminalStreamKey}
testId="terminal-surface" testId="terminal-surface"
xtermTheme={xtermTheme} xtermTheme={xtermTheme}
swipeGesturesEnabled={swipeGesturesEnabled} swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot} initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange}
onSwipeRight={handleSwipeRight} onSwipeRight={handleSwipeRight}
onSwipeLeft={handleSwipeLeft} onSwipeLeft={handleSwipeLeft}
onInput={handleTerminalData} onInput={handleTerminalData}
@@ -654,7 +684,7 @@ export function TerminalPane({
<View style={styles.terminalGestureContainer} /> <View style={styles.terminalGestureContainer} />
)} )}
{isAttaching && isWorkspaceFocused ? ( {showLoadingOverlay ? (
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading"> <View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} /> <ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View> </View>
@@ -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);
});
});
@@ -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)
);
}