feat(web): persistent workspace terminal across tab switches

This commit is contained in:
Gabriel Brown
2026-07-11 13:05:49 -04:00
parent 33e7732566
commit 148e060a9b
4 changed files with 127 additions and 13 deletions
@@ -26,6 +26,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
Button, Button,
cn,
Tabs, Tabs,
TabsContent, TabsContent,
TabsList, TabsList,
@@ -681,11 +682,19 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
</TabsContent> </TabsContent>
<TabsContent <TabsContent
value='terminal' value='terminal'
className='m-0 min-h-0 flex-1 overflow-hidden' forceMount
className={cn(
'm-0 min-h-0 flex-1 overflow-hidden',
activeWorkspaceTab !== 'terminal' && 'hidden',
)}
> >
<WorkspaceTerminal <WorkspaceTerminal
jobId={jobId} jobId={jobId}
active={activeWorkspaceTab === 'terminal' && workspaceReady} active={workspaceReady}
visible={activeWorkspaceTab === 'terminal'}
waitingLabel={
workspacePending ? 'Waiting for workspace…' : undefined
}
/> />
</TabsContent> </TabsContent>
<TabsContent value='diff' className='m-0 min-h-0 flex-1'> <TabsContent value='diff' className='m-0 min-h-0 flex-1'>
@@ -5,12 +5,18 @@ import { XtermSession } from './xterm-session';
export const WorkspaceTerminal = ({ export const WorkspaceTerminal = ({
jobId, jobId,
active, active,
visible = true,
waitingLabel,
}: { }: {
jobId: string; jobId: string;
active: boolean; active: boolean;
visible?: boolean;
waitingLabel?: string;
}) => ( }) => (
<XtermSession <XtermSession
active={active} active={active}
visible={visible}
waitingLabel={waitingLabel}
tokenUrl={`/api/agent-jobs/${jobId}/terminal-token`} tokenUrl={`/api/agent-jobs/${jobId}/terminal-token`}
sessionKey={jobId} sessionKey={jobId}
/> />
@@ -71,6 +71,7 @@ const lightTheme: ITheme = {
export const XtermSession = ({ export const XtermSession = ({
active, active,
visible = true,
tokenUrl, tokenUrl,
sessionKey, sessionKey,
waitingLabel, waitingLabel,
@@ -78,6 +79,7 @@ export const XtermSession = ({
label = 'workspace shell', label = 'workspace shell',
}: { }: {
active: boolean; active: boolean;
visible?: boolean;
tokenUrl: string; tokenUrl: string;
sessionKey: string; sessionKey: string;
waitingLabel?: string; waitingLabel?: string;
@@ -86,9 +88,11 @@ export const XtermSession = ({
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null); const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<{ fit: () => void } | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const themeIsLight = resolvedTheme === 'light'; const themeIsLight = resolvedTheme === 'light';
const [status, setStatus] = useState<XtermStatus>('connecting'); const [status, setStatus] = useState<XtermStatus>('idle');
const [errorText, setErrorText] = useState<string>(); const [errorText, setErrorText] = useState<string>();
const [reconnectKey, setReconnectKey] = useState(0); const [reconnectKey, setReconnectKey] = useState(0);
@@ -158,6 +162,7 @@ export const XtermSession = ({
term.loadAddon(new WebLinksAddon()); term.loadAddon(new WebLinksAddon());
term.open(container); term.open(container);
termRef.current = term; termRef.current = term;
fitRef.current = fit;
// Task 11 will wire clipboard copy-on-select behavior here. Accept the // 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. // prop now so callers (and the effect deps) are stable across that change.
@@ -193,6 +198,7 @@ export const XtermSession = ({
}); });
ws = new WebSocket(url); ws = new WebSocket(url);
wsRef.current = ws;
ws.binaryType = 'arraybuffer'; ws.binaryType = 'arraybuffer';
ws.onopen = () => { ws.onopen = () => {
if (isAborted()) return; if (isAborted()) return;
@@ -233,17 +239,44 @@ export const XtermSession = ({
ws?.close(); ws?.close();
termRef.current?.dispose(); termRef.current?.dispose();
termRef.current = null; termRef.current = null;
fitRef.current = null;
wsRef.current = null;
}; };
// resolvedTheme intentionally excluded: handled by the theme effect above. // resolvedTheme intentionally excluded: handled by the theme effect above.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, sessionKey, reconnectKey]); }, [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 ( return (
<div className='relative flex h-full min-h-0 flex-col'> <div className='relative flex h-full min-h-0 flex-col'>
<div className='border-border flex h-10 flex-none items-center justify-between gap-3 border-b px-3'> <div className='border-border flex h-10 flex-none items-center justify-between gap-3 border-b px-3'>
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
{status === 'connected' {status === 'connected'
? `Connected · ${label}` ? `Connected · ${label}`
: status === 'idle'
? (waitingLabel ? 'Waiting…' : 'Idle')
: status === 'connecting' : status === 'connecting'
? 'Connecting…' ? 'Connecting…'
: status === 'closed' : status === 'closed'
@@ -3,10 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { XtermSession } from '../../src/components/agent-workspace/xterm-session'; import { XtermSession } from '../../src/components/agent-workspace/xterm-session';
const { openSpy, wsInstances, fetchSpy } = vi.hoisted(() => ({ const { openSpy, wsInstances, fetchSpy, fitSpy } = vi.hoisted(() => ({
openSpy: vi.fn(), openSpy: vi.fn(),
wsInstances: [] as Array<{ url: string }>, wsInstances: [] as Array<{
url: string;
send: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
}>,
fetchSpy: vi.fn(), fetchSpy: vi.fn(),
fitSpy: vi.fn(),
})); }));
class FakeTerminal { class FakeTerminal {
@@ -30,7 +35,7 @@ class FakeTerminal {
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal })); vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }));
vi.mock('@xterm/addon-fit', () => ({ vi.mock('@xterm/addon-fit', () => ({
FitAddon: class { FitAddon: class {
fit = vi.fn(); fit = fitSpy;
}, },
})); }));
vi.mock('@xterm/addon-web-links', () => ({ vi.mock('@xterm/addon-web-links', () => ({
@@ -60,6 +65,7 @@ class FakeWebSocket {
beforeEach(() => { beforeEach(() => {
wsInstances.length = 0; wsInstances.length = 0;
openSpy.mockClear(); openSpy.mockClear();
fitSpy.mockClear();
fetchSpy.mockReset(); fetchSpy.mockReset();
fetchSpy.mockResolvedValue({ fetchSpy.mockResolvedValue({
ok: true, ok: true,
@@ -127,4 +133,64 @@ describe('XtermSession', () => {
expect(wsInstances[0]?.url).toBe('ws://x'); expect(wsInstances[0]?.url).toBe('ws://x');
expect(openSpy).toHaveBeenCalled(); expect(openSpy).toHaveBeenCalled();
}); });
it('refits and sends a resize frame when it becomes visible', async () => {
const { rerender } = render(
<XtermSession
active
visible={false}
tokenUrl='/api/box/terminal-token'
sessionKey='box-1'
/>,
);
await waitFor(() => expect(wsInstances).toHaveLength(1));
const ws = wsInstances[0]!;
// Ignore fits/sends from the initial connect; only the visible toggle matters.
fitSpy.mockClear();
ws.send.mockClear();
rerender(
<XtermSession
active
visible
tokenUrl='/api/box/terminal-token'
sessionKey='box-1'
/>,
);
await waitFor(() => expect(fitSpy).toHaveBeenCalled());
await waitFor(() =>
expect(ws.send).toHaveBeenCalledWith(
expect.stringContaining('"type":"resize"'),
),
);
});
it('keeps the WebSocket open when hidden while still active', async () => {
const { rerender } = render(
<XtermSession
active
visible
tokenUrl='/api/box/terminal-token'
sessionKey='box-1'
/>,
);
await waitFor(() => expect(wsInstances).toHaveLength(1));
const ws = wsInstances[0]!;
rerender(
<XtermSession
active
visible={false}
tokenUrl='/api/box/terminal-token'
sessionKey='box-1'
/>,
);
await Promise.resolve();
expect(ws.close).not.toHaveBeenCalled();
expect(wsInstances).toHaveLength(1);
});
}); });