From c0420ac1f96a7e4831d3950bc7ac50f5b82da00e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 25 Feb 2026 20:05:23 +0700 Subject: [PATCH] Fix mobile terminal tab routing and remount restoration --- packages/app/e2e/terminal-pane.spec.ts | 138 ++++++++++++++++++ .../app/src/components/terminal-emulator.tsx | 20 +++ packages/app/src/components/terminal-pane.tsx | 36 +++-- 3 files changed, 185 insertions(+), 9 deletions(-) diff --git a/packages/app/e2e/terminal-pane.spec.ts b/packages/app/e2e/terminal-pane.spec.ts index f6d0c9f5f..34597e6dc 100644 --- a/packages/app/e2e/terminal-pane.spec.ts +++ b/packages/app/e2e/terminal-pane.spec.ts @@ -190,6 +190,80 @@ async function runTerminalCommandWithPreEnterEcho( }); } +async function readCurrentTerminalBuffer(page: Page): Promise { + const bufferText = await page.evaluate(() => { + try { + const terminal = (window as { + __paseoTerminal?: { + buffer?: { + active?: { + length?: number; + getLine?: ( + line: number + ) => + | { + translateToString: (trimRight?: boolean) => string; + } + | null; + }; + }; + }; + }).__paseoTerminal; + + const lineCount = terminal?.buffer?.active?.length ?? 0; + const getLine = terminal?.buffer?.active?.getLine; + if (!getLine || lineCount <= 0) { + return ""; + } + + const lines: string[] = []; + for (let index = 0; index < lineCount; index += 1) { + let line: { translateToString: (trimRight?: boolean) => string } | null = null; + try { + line = getLine(index); + } catch { + return ""; + } + if (!line) { + continue; + } + lines.push(line.translateToString(true)); + } + return lines.join("\n"); + } catch { + return ""; + } + }); + + if (bufferText.length > 0) { + return bufferText; + } + + try { + return await visibleTestId(page, "terminal-surface").innerText(); + } catch { + return ""; + } +} + +async function expectCurrentTerminalBufferToContain(page: Page, marker: string): Promise { + await expect + .poll(async () => await readCurrentTerminalBuffer(page), { timeout: 30000 }) + .toContain(marker); +} + +async function expectCurrentTerminalBufferNotToContain(page: Page, marker: string): Promise { + await expect + .poll(async () => await readCurrentTerminalBuffer(page), { timeout: 5000 }) + .not.toContain(marker); +} + +async function waitForTerminalAttachToSettle(page: Page): Promise { + await expect(page.locator('[data-testid="terminal-attach-loading"]:visible')).toHaveCount(0, { + timeout: 30000, + }); +} + async function expectAnsiColorApplied(page: Page, marker: string): Promise { await expect .poll( @@ -303,6 +377,70 @@ test("terminal reattaches cleanly after heavy output and tab switches", async ({ } }); +test("mobile terminal tab switch keeps command input routed to the selected tab", async ({ page }) => { + const repo = await createTempGitRepo("paseo-e2e-terminal-mobile-routing-"); + + try { + await openNewAgentDraft(page); + await setWorkingDirectory(page, repo.path); + await ensureHostSelected(page); + await createAgent(page, "Reply with exactly: terminal routing"); + const createdAgentUrl = page.url(); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(createdAgentUrl); + + await ensureExplorerTabsVisible(page); + const terminalsTab = visibleTestId(page, "explorer-tab-terminals"); + await expect(terminalsTab).toBeVisible({ timeout: 30000 }); + await terminalsTab.click({ force: true }); + await expect(visibleTestId(page, "terminals-header")).toBeVisible({ + timeout: 30000, + }); + await expect(visibleTestId(page, "terminal-surface")).toBeVisible({ + timeout: 30000, + }); + await waitForTerminalAttachToSettle(page); + + await visibleTestId(page, "terminals-create-button").click(); + const tabs = visibleTestIdPrefix(page, "terminal-tab-"); + await expect + .poll(async () => await tabs.count(), { timeout: 30000 }) + .toBeGreaterThanOrEqual(2); + + const firstTab = tabs.first(); + const secondTab = tabs.nth(1); + const firstTabId = await firstTab.getAttribute("data-testid"); + const secondTabId = await secondTab.getAttribute("data-testid"); + if (!firstTabId || !secondTabId) { + throw new Error("Expected terminal tab IDs"); + } + + const firstMarker = `mobile-route-one-${Date.now()}`; + await firstTab.click(); + await visibleTestId(page, "terminal-surface").click({ force: true }); + await page.keyboard.type(`echo ${firstMarker}`, { delay: 1 }); + await page.keyboard.press("Enter"); + + const secondMarker = `mobile-route-two-${Date.now()}`; + await secondTab.click(); + await visibleTestId(page, "terminal-surface").click({ force: true }); + await page.keyboard.type(`echo ${secondMarker}`, { delay: 1 }); + await page.keyboard.press("Enter"); + + await page.locator(`[data-testid="${firstTabId}"]:visible`).first().click(); + await waitForTerminalAttachToSettle(page); + await expectCurrentTerminalBufferToContain(page, firstMarker); + await expectCurrentTerminalBufferNotToContain(page, secondMarker); + + await page.locator(`[data-testid="${secondTabId}"]:visible`).first().click(); + await waitForTerminalAttachToSettle(page); + await expectCurrentTerminalBufferToContain(page, secondMarker); + await expectCurrentTerminalBufferNotToContain(page, firstMarker); + } finally { + await repo.cleanup(); + } +}); + test("terminal keeps prompt echo visible after enter and backspace churn", async ({ page }) => { const repo = await createTempGitRepo("paseo-e2e-terminal-echo-churn-"); diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 70624986d..a15e67f2d 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -57,6 +57,7 @@ export default function TerminalEmulator({ const rootRef = useRef(null); const hostRef = useRef(null); const runtimeRef = useRef(null); + const appliedInitialOutputRef = useRef(null); useEffect(() => { const host = hostRef.current; @@ -86,15 +87,34 @@ export default function TerminalEmulator({ cursorColor, }, }); + appliedInitialOutputRef.current = initialOutputText; return () => { runtime.unmount(); if (runtimeRef.current === runtime) { runtimeRef.current = null; } + appliedInitialOutputRef.current = null; }; }, [backgroundColor, cursorColor, foregroundColor, streamKey]); + useEffect(() => { + const runtime = runtimeRef.current; + if (!runtime) { + return; + } + + if (appliedInitialOutputRef.current === initialOutputText) { + return; + } + + appliedInitialOutputRef.current = initialOutputText; + runtime.clear(); + if (initialOutputText.length > 0) { + runtime.write({ text: initialOutputText }); + } + }, [initialOutputText]); + useEffect(() => { runtimeRef.current?.setCallbacks({ callbacks: { diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index d0c3f7fd6..6483ca470 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -169,6 +169,24 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { const selectedTerminalIdRef = useRef(selectedTerminalId); const pendingTerminalInputRef = useRef([]); + const updateSelectedTerminalId = useCallback( + ( + next: + | string + | null + | ((current: string | null) => string | null) + ) => { + const current = selectedTerminalIdRef.current; + const resolved = + typeof next === "function" + ? (next as (value: string | null) => string | null)(current) + : next; + selectedTerminalIdRef.current = resolved; + setSelectedTerminalId(resolved); + }, + [] + ); + useEffect(() => { selectedTerminalIdRef.current = selectedTerminalId; }, [selectedTerminalId]); @@ -377,7 +395,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { }; }); selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id); - setSelectedTerminalId(createdTerminal.id); + updateSelectedTerminalId(createdTerminal.id); requestTerminalFocus(); } void queryClient.invalidateQueries({ @@ -401,7 +419,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { setHoveredTerminalId((current) => (current === terminalId ? null : current)); outputPumpRef.current?.clearTerminal({ terminalId }); if (selectedTerminalIdRef.current === terminalId) { - setSelectedTerminalId((current) => + updateSelectedTerminalId((current) => current === terminalId ? null : current ); setModifiers({ ...EMPTY_MODIFIERS }); @@ -417,9 +435,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { }); useEffect(() => { - setSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null); + updateSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null); lastReportedSizeRef.current = null; - }, [scopeKey]); + }, [scopeKey, updateSelectedTerminalId]); useEffect(() => { if (selectedTerminalId) { @@ -429,7 +447,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { useEffect(() => { if (terminals.length === 0) { - setSelectedTerminalId(null); + updateSelectedTerminalId(null); return; } @@ -442,16 +460,16 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { const stored = selectedTerminalByScopeRef.current.get(scopeKey); if (has(stored)) { - setSelectedTerminalId(stored!); + updateSelectedTerminalId(stored!); return; } const fallback = terminals[0]?.id ?? null; if (fallback) { selectedTerminalByScopeRef.current.set(scopeKey, fallback); - setSelectedTerminalId(fallback); + updateSelectedTerminalId(fallback); } - }, [scopeKey, terminals, selectedTerminalId]); + }, [scopeKey, terminals, selectedTerminalId, updateSelectedTerminalId]); useEffect(() => { const terminalIds = terminals.map((terminal) => terminal.id); @@ -917,7 +935,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { setSelectedTerminalId(terminal.id)} + onPress={() => updateSelectedTerminalId(terminal.id)} onHoverIn={() => handleTerminalTabHoverIn(terminal.id)} onHoverOut={() => handleTerminalTabHoverOut(terminal.id)} style={({ pressed, hovered }) => [