fix(app): keep retained terminal streams attached

A hidden tab in the focused workspace must keep consuming output, but only the presented pane may claim focus or terminal geometry.
This commit is contained in:
Mohamed Boudra
2026-08-17 21:15:16 +02:00
parent 6ea1067bff
commit 1e50f895f0
3 changed files with 79 additions and 16 deletions
@@ -0,0 +1,62 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
interface AttachOverlayProbeWindow extends Window {
__terminalAttachOverlaySeen?: boolean;
__terminalAttachOverlayObserver?: MutationObserver;
}
async function watchForTerminalAttachOverlay(page: Page): Promise<void> {
await page.evaluate(() => {
const win = window as AttachOverlayProbeWindow;
win.__terminalAttachOverlaySeen = false;
win.__terminalAttachOverlayObserver?.disconnect();
win.__terminalAttachOverlayObserver = new MutationObserver(() => {
if (document.querySelector('[data-testid="terminal-attach-loading"]')) {
win.__terminalAttachOverlaySeen = true;
}
});
win.__terminalAttachOverlayObserver.observe(document.body, {
childList: true,
subtree: true,
});
});
}
async function terminalAttachOverlayWasSeen(page: Page): Promise<boolean> {
return page.evaluate(() => {
const win = window as AttachOverlayProbeWindow;
win.__terminalAttachOverlayObserver?.disconnect();
return win.__terminalAttachOverlaySeen === true;
});
}
test.describe("retained terminal tab streams", () => {
let harness: TerminalE2EHarness;
test.beforeEach(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-retained-tab-stream-" });
});
test.afterEach(async () => {
await harness.cleanup();
});
test("switching back to a retained terminal does not reattach its stream", async ({ page }) => {
const first = await harness.createTerminal({ name: "retained-first" });
const second = await harness.createTerminal({ name: "retained-second" });
await harness.openTerminal(page, { terminalId: first.id });
const secondTab = page.getByTestId(`workspace-tab-terminal_${second.id}`).first();
await secondTab.click();
await expect(page.getByTestId("terminal-attach-loading")).toBeHidden({ timeout: 10_000 });
await watchForTerminalAttachOverlay(page);
await page.getByTestId(`workspace-tab-terminal_${first.id}`).first().click();
await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toHaveCount(1);
await page.waitForTimeout(100);
expect(await terminalAttachOverlayWasSeen(page)).toBe(false);
});
});
+16 -16
View File
@@ -232,7 +232,7 @@ export function TerminalPane({
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isTerminalActive = retainedPanelActive && isWorkspaceFocused;
const isTerminalPresented = retainedPanelActive && isWorkspaceFocused;
const supportsTerminalRestoreModes = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-restore-modes"] === true,
);
@@ -268,8 +268,8 @@ export function TerminalPane({
useBlockMobilePanelOpenGestures(isMobile && isWorkspaceFocused && isPaneFocused && hasSelection);
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
const terminalIdRef = useRef<string>(terminalId);
const terminalActiveRef = useRef(isTerminalActive);
terminalActiveRef.current = isTerminalActive;
const terminalPresentedRef = useRef(isTerminalPresented);
terminalPresentedRef.current = isTerminalPresented;
const inputModeRef = useRef<TerminalInputModeState>(DEFAULT_TERMINAL_INPUT_MODE_STATE);
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
@@ -356,7 +356,7 @@ export function TerminalPane({
useEffect(() => {
const canRequest = canRequestFocusClaim({
isWorkspaceFocused: isTerminalActive,
isWorkspaceFocused: isTerminalPresented,
isPaneFocused,
isAppActivelyVisible,
isClientReady: client !== null,
@@ -378,7 +378,7 @@ export function TerminalPane({
isAppActivelyVisible,
isConnected,
isPaneFocused,
isTerminalActive,
isTerminalPresented,
rendererReadyStreamKey,
requestTerminalReflow,
scopeKey,
@@ -450,7 +450,7 @@ export function TerminalPane({
);
useEffect(() => {
if (!client || !isConnected || !isTerminalActive) {
if (!client || !isConnected || !isWorkspaceFocused) {
return;
}
@@ -473,7 +473,7 @@ export function TerminalPane({
});
setModifiers({ ...EMPTY_MODIFIERS });
});
}, [client, isConnected, isTerminalActive, workspaceTerminalSession.snapshots]);
}, [client, isConnected, isWorkspaceFocused, workspaceTerminalSession.snapshots]);
useEffect(() => {
measuredTerminalSizeRef.current = null;
@@ -488,7 +488,7 @@ export function TerminalPane({
const getPreferredStreamSize = useStableEvent(() => {
if (
!canRequestFocusClaim({
isWorkspaceFocused: terminalActiveRef.current,
isWorkspaceFocused: terminalPresentedRef.current,
isPaneFocused,
isAppActivelyVisible,
isClientReady: client !== null,
@@ -503,7 +503,7 @@ export function TerminalPane({
const handleStreamOutput = useStableEvent(
({ terminalId: outputTerminalId, data }: { terminalId: string; data: Uint8Array }) => {
if (!terminalActiveRef.current || terminalIdRef.current !== outputTerminalId) {
if (terminalIdRef.current !== outputTerminalId) {
return;
}
emulatorRef.current?.writeOutput(data);
@@ -513,7 +513,7 @@ export function TerminalPane({
const handleStreamRestore = useStableEvent(
({ terminalId: restoreTerminalId, data }: { terminalId: string; data: Uint8Array }) => {
workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId });
if (!terminalActiveRef.current || terminalIdRef.current !== restoreTerminalId) {
if (terminalIdRef.current !== restoreTerminalId) {
return;
}
emulatorRef.current?.restoreOutput(data);
@@ -523,7 +523,7 @@ export function TerminalPane({
const handleStreamSnapshot = useStableEvent(
({ terminalId: snapshotTerminalId, state }: { terminalId: string; state: TerminalState }) => {
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
if (!terminalActiveRef.current || terminalIdRef.current !== snapshotTerminalId) {
if (terminalIdRef.current !== snapshotTerminalId) {
return;
}
emulatorRef.current?.renderSnapshot(state);
@@ -534,7 +534,7 @@ export function TerminalPane({
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes,
canClaimSize: canRequestFocusClaim({
isWorkspaceFocused: terminalActiveRef.current,
isWorkspaceFocused: terminalPresentedRef.current,
isPaneFocused,
isAppActivelyVisible,
isClientReady: client !== null,
@@ -590,7 +590,7 @@ export function TerminalPane({
terminalId,
terminalStreamKey,
rendererReadyStreamKey,
isWorkspaceFocused: isTerminalActive,
isWorkspaceFocused,
});
streamControllerRef.current?.setTerminal({
terminalId: nextTerminalId,
@@ -598,7 +598,7 @@ export function TerminalPane({
}, [
client,
isConnected,
isTerminalActive,
isWorkspaceFocused,
rendererReadyStreamKey,
terminalId,
terminalStreamKey,
@@ -789,7 +789,7 @@ export function TerminalPane({
forceClaim: input.forceClaim ?? false,
supportsTerminalSizeOwnership,
readiness: {
isWorkspaceFocused: isTerminalActive,
isWorkspaceFocused: isTerminalPresented,
isPaneFocused,
isAppActivelyVisible,
isClientReady: client !== null,
@@ -1024,7 +1024,7 @@ export function TerminalPane({
}
};
const showLoadingOverlay = shouldShowTerminalLoadingOverlay({
isWorkspaceFocused: isTerminalActive,
isWorkspaceFocused: isTerminalPresented,
hasStreamError: Boolean(streamError),
isAttaching,
rendererReadyStreamKey,