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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user