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:
@@ -26,6 +26,7 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
|
||||
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
|
||||
- **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic.
|
||||
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
|
||||
- **Retained terminal tabs in the focused workspace keep their streams.** Hidden mounted terminals continue applying output, so switching tabs does not resubscribe or request a fresh snapshot. The retained-panel LRU bounds the number of live streams; terminals in an unfocused workspace detach.
|
||||
- **Terminal size has one daemon-owned claimant.** Focus and direct interaction send a `claim`; later geometry changes from that connection send `update`. A claim transfers ownership even when the dimensions are unchanged, while an update from any other connection is ignored. This lets an owning pane follow splits and keyboard insets without allowing an idle phone or browser to steal the PTY size.
|
||||
|
||||
## Measuring
|
||||
|
||||
@@ -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