From 148e060a9bbed3f4dd0260a1716e8f05d196195b Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sat, 11 Jul 2026 13:02:45 -0400 Subject: [PATCH] feat(web): persistent workspace terminal across tab switches --- .../agent-workspace/agent-workspace-shell.tsx | 13 +++- .../agent-workspace/workspace-terminal.tsx | 6 ++ .../agent-workspace/xterm-session.tsx | 49 ++++++++++--- .../tests/component/xterm-session.test.tsx | 72 ++++++++++++++++++- 4 files changed, 127 insertions(+), 13 deletions(-) diff --git a/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx b/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx index d93cf7a..91e3302 100644 --- a/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx +++ b/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx @@ -26,6 +26,7 @@ import { AlertDialogTitle, AlertDialogTrigger, Button, + cn, Tabs, TabsContent, TabsList, @@ -681,11 +682,19 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { diff --git a/apps/next/src/components/agent-workspace/workspace-terminal.tsx b/apps/next/src/components/agent-workspace/workspace-terminal.tsx index d744e54..f0c199a 100644 --- a/apps/next/src/components/agent-workspace/workspace-terminal.tsx +++ b/apps/next/src/components/agent-workspace/workspace-terminal.tsx @@ -5,12 +5,18 @@ import { XtermSession } from './xterm-session'; export const WorkspaceTerminal = ({ jobId, active, + visible = true, + waitingLabel, }: { jobId: string; active: boolean; + visible?: boolean; + waitingLabel?: string; }) => ( diff --git a/apps/next/src/components/agent-workspace/xterm-session.tsx b/apps/next/src/components/agent-workspace/xterm-session.tsx index 0a45ba6..2730587 100644 --- a/apps/next/src/components/agent-workspace/xterm-session.tsx +++ b/apps/next/src/components/agent-workspace/xterm-session.tsx @@ -71,6 +71,7 @@ const lightTheme: ITheme = { export const XtermSession = ({ active, + visible = true, tokenUrl, sessionKey, waitingLabel, @@ -78,6 +79,7 @@ export const XtermSession = ({ label = 'workspace shell', }: { active: boolean; + visible?: boolean; tokenUrl: string; sessionKey: string; waitingLabel?: string; @@ -86,9 +88,11 @@ export const XtermSession = ({ }) => { const containerRef = useRef(null); const termRef = useRef(null); + const fitRef = useRef<{ fit: () => void } | null>(null); + const wsRef = useRef(null); const { resolvedTheme } = useTheme(); const themeIsLight = resolvedTheme === 'light'; - const [status, setStatus] = useState('connecting'); + const [status, setStatus] = useState('idle'); const [errorText, setErrorText] = useState(); const [reconnectKey, setReconnectKey] = useState(0); @@ -158,6 +162,7 @@ export const XtermSession = ({ term.loadAddon(new WebLinksAddon()); term.open(container); termRef.current = term; + fitRef.current = fit; // Task 11 will wire clipboard copy-on-select behavior here. Accept the // prop now so callers (and the effect deps) are stable across that change. @@ -193,6 +198,7 @@ export const XtermSession = ({ }); ws = new WebSocket(url); + wsRef.current = ws; ws.binaryType = 'arraybuffer'; ws.onopen = () => { if (isAborted()) return; @@ -233,24 +239,51 @@ export const XtermSession = ({ ws?.close(); termRef.current?.dispose(); termRef.current = null; + fitRef.current = null; + wsRef.current = null; }; // resolvedTheme intentionally excluded: handled by the theme effect above. // eslint-disable-next-line react-hooks/exhaustive-deps }, [active, sessionKey, reconnectKey]); + // A hidden (display:none) container measures 0×0, so xterm mis-sizes while the + // tab is inactive. When the terminal becomes visible again, refit inside a + // frame (so layout has settled) and push the new dimensions to the PTY. The + // WebSocket stays open across visibility changes — this never reconnects. + useEffect(() => { + if (!visible || !active) return; + const frame = requestAnimationFrame(() => { + try { + fitRef.current?.fit(); + } catch { + // ignore transient layout errors + } + const ws = wsRef.current; + const term = termRef.current; + if (ws?.readyState === WebSocket.OPEN && term) { + ws.send( + JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }), + ); + } + }); + return () => cancelAnimationFrame(frame); + }, [visible, active]); + return (

{status === 'connected' ? `Connected · ${label}` - : status === 'connecting' - ? 'Connecting…' - : status === 'closed' - ? 'Session ended' - : status === 'unconfigured' - ? 'Terminal not configured' - : 'Connection error'} + : status === 'idle' + ? (waitingLabel ? 'Waiting…' : 'Idle') + : status === 'connecting' + ? 'Connecting…' + : status === 'closed' + ? 'Session ended' + : status === 'unconfigured' + ? 'Terminal not configured' + : 'Connection error'}

{status === 'closed' || status === 'error' ? (