From 669253bdf6a973adc0cbe6cea6d86c3ab6121c06 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 5 Aug 2026 15:19:45 +0200 Subject: [PATCH] fix(app): keep terminal output subscribed after focus changes (#2896) The mobile terminal stream readiness work made live focus and renderer state recreate the stream controller. A replacement controller could remain untargeted, so input reached the PTY while output stopped reaching the terminal. Keep the controller scoped to the client connection and expose current render state through stable callbacks. --- .../e2e/browser/terminal-stuck-size.spec.ts | 30 ++++- packages/app/src/components/terminal-pane.tsx | 126 ++++++++++-------- 2 files changed, 100 insertions(+), 56 deletions(-) diff --git a/packages/app/e2e/browser/terminal-stuck-size.spec.ts b/packages/app/e2e/browser/terminal-stuck-size.spec.ts index bf1a5541f..45c70838d 100644 --- a/packages/app/e2e/browser/terminal-stuck-size.spec.ts +++ b/packages/app/e2e/browser/terminal-stuck-size.spec.ts @@ -1,5 +1,6 @@ import { test, expect, type Page } from "../support/fixtures"; -import { TerminalE2EHarness } from "../support/helpers/terminal-dsl"; +import { TerminalE2EHarness, withTerminalInApp } from "../support/helpers/terminal-dsl"; +import { getTerminalBufferText } from "../support/helpers/terminal-perf"; import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; import { getServerId } from "../support/helpers/server-id"; @@ -97,6 +98,15 @@ async function captureTerminalText( return capture.lines.join("\n"); } +async function waitForTerminalMarker(page: Page, marker: string): Promise { + await expect + .poll(() => getTerminalBufferText(page), { + message: "terminal stream should deliver PTY output after focus returns", + timeout: 10_000, + }) + .toContain(marker); +} + test.describe("terminal PTY size claim under lost window focus", () => { let harness: TerminalE2EHarness; @@ -174,4 +184,22 @@ test.describe("terminal PTY size claim under lost window focus", () => { ) .toEqual({ rows: rendered.rows, cols: rendered.cols }); }); + + test("terminal output remains subscribed after window focus returns", async ({ page }) => { + await withTerminalInApp(page, harness, { name: "focus-return-output" }, async (terminal) => { + const marker = `OUTPUT_AFTER_FOCUS_${Date.now()}`; + + await setWindowFocused(page, false); + await page.waitForTimeout(50); + await setWindowFocused(page, true); + await page.waitForTimeout(50); + + harness.client.sendTerminalInput(terminal.id, { + type: "input", + data: `printf "\\n${marker}\\n"\n`, + }); + + await waitForTerminalMarker(page, marker); + }); + }); }); diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 428f92ba0..2fd44d664 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -7,6 +7,7 @@ import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated" import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native"; import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input"; +import type { TerminalState } from "@getpaseo/protocol/messages"; import { DEFAULT_TERMINAL_INPUT_MODE_STATE, type TerminalInputModeState, @@ -484,6 +485,66 @@ export function TerminalPane({ setStreamError(status.error); }, []); + const getPreferredStreamSize = useStableEvent(() => { + if ( + !canRequestFocusClaim({ + isWorkspaceFocused: terminalActiveRef.current, + isPaneFocused, + isAppActivelyVisible, + isClientReady: client !== null, + isConnected, + isRendererReady: rendererReadyStreamKey === terminalStreamKey, + }) + ) { + return null; + } + return measuredTerminalSizeRef.current; + }); + + const handleStreamOutput = useStableEvent( + ({ terminalId: outputTerminalId, data }: { terminalId: string; data: Uint8Array }) => { + if (!terminalActiveRef.current || terminalIdRef.current !== outputTerminalId) { + return; + } + emulatorRef.current?.writeOutput(data); + }, + ); + + const handleStreamRestore = useStableEvent( + ({ terminalId: restoreTerminalId, data }: { terminalId: string; data: Uint8Array }) => { + workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId }); + if (!terminalActiveRef.current || terminalIdRef.current !== restoreTerminalId) { + return; + } + emulatorRef.current?.restoreOutput(data); + }, + ); + + const handleStreamSnapshot = useStableEvent( + ({ terminalId: snapshotTerminalId, state }: { terminalId: string; state: TerminalState }) => { + workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state }); + if (!terminalActiveRef.current || terminalIdRef.current !== snapshotTerminalId) { + return; + } + emulatorRef.current?.renderSnapshot(state); + }, + ); + + const getStreamRestoreOptions = useStableEvent(() => + resolveTerminalRestoreOptions({ + supportsTerminalRestoreModes, + canClaimSize: canRequestFocusClaim({ + isWorkspaceFocused: terminalActiveRef.current, + isPaneFocused, + isAppActivelyVisible, + isClientReady: client !== null, + isConnected, + isRendererReady: rendererReadyStreamKey === terminalStreamKey, + }), + size: measuredTerminalSizeRef.current, + }), + ); + useEffect(() => { streamControllerRef.current?.dispose(); streamControllerRef.current = null; @@ -496,55 +557,11 @@ export function TerminalPane({ const controller = new TerminalStreamController({ client, - getPreferredSize: () => { - if ( - !canRequestFocusClaim({ - isWorkspaceFocused: terminalActiveRef.current, - isPaneFocused, - isAppActivelyVisible, - isClientReady: client !== null, - isConnected, - isRendererReady: rendererReadyStreamKey === terminalStreamKey, - }) - ) { - return null; - } - return measuredTerminalSizeRef.current; - }, - onOutput: ({ terminalId: outputTerminalId, data }) => { - if (!terminalActiveRef.current || terminalIdRef.current !== outputTerminalId) { - return; - } - emulatorRef.current?.writeOutput(data); - }, - onRestore: ({ terminalId: restoreTerminalId, data }) => { - workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId }); - if (!terminalActiveRef.current || terminalIdRef.current !== restoreTerminalId) { - return; - } - emulatorRef.current?.restoreOutput(data); - }, - onSnapshot: ({ terminalId: snapshotTerminalId, state }) => { - workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state }); - if (!terminalActiveRef.current || terminalIdRef.current !== snapshotTerminalId) { - return; - } - emulatorRef.current?.renderSnapshot(state); - }, - getRestoreOptions: () => { - return resolveTerminalRestoreOptions({ - supportsTerminalRestoreModes, - canClaimSize: canRequestFocusClaim({ - isWorkspaceFocused: terminalActiveRef.current, - isPaneFocused, - isAppActivelyVisible, - isClientReady: client !== null, - isConnected, - isRendererReady: rendererReadyStreamKey === terminalStreamKey, - }), - size: measuredTerminalSizeRef.current, - }); - }, + getPreferredSize: getPreferredStreamSize, + onOutput: handleStreamOutput, + onRestore: handleStreamRestore, + onSnapshot: handleStreamSnapshot, + getRestoreOptions: getStreamRestoreOptions, onStatusChange: handleStreamControllerStatus, }); @@ -558,14 +575,13 @@ export function TerminalPane({ }; }, [ client, + getPreferredStreamSize, + getStreamRestoreOptions, handleStreamControllerStatus, + handleStreamOutput, + handleStreamRestore, + handleStreamSnapshot, isConnected, - isAppActivelyVisible, - isPaneFocused, - rendererReadyStreamKey, - supportsTerminalRestoreModes, - terminalStreamKey, - workspaceTerminalSession.snapshots, ]); useEffect(() => {