refactor(web): extract reusable XtermSession from WorkspaceTerminal

This commit is contained in:
Gabriel Brown
2026-07-11 12:43:08 -04:00
parent 27019dc1d3
commit ea56b29453
3 changed files with 426 additions and 257 deletions
@@ -1,67 +1,6 @@
'use client'; 'use client';
import type { ITheme, Terminal } from '@xterm/xterm'; import { XtermSession } from './xterm-session';
import { useEffect, useRef, useState } from 'react';
import { useTheme } from 'next-themes';
import { Button } from '@spoon/ui';
import { scheduleTerminalFits } from './terminal-fit';
import '@xterm/xterm/css/xterm.css';
const TERMINAL_FONT =
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, monospace";
type Status = 'connecting' | 'connected' | 'closed' | 'error' | 'unconfigured';
const darkTheme: ITheme = {
background: '#080e14',
foreground: '#eef3f5',
cursor: '#1fb895',
cursorAccent: '#080e14',
selectionBackground: '#1fb89544',
black: '#10171e',
red: '#f3625d',
green: '#8fd6b4',
yellow: '#e3b341',
blue: '#6aa6ff',
magenta: '#b692e8',
cyan: '#5fd0e0',
white: '#cdd6dc',
brightBlack: '#93a1a9',
brightRed: '#f3625d',
brightGreen: '#8fd6b4',
brightYellow: '#e3b341',
brightBlue: '#6aa6ff',
brightMagenta: '#b692e8',
brightCyan: '#5fd0e0',
brightWhite: '#eef3f5',
};
const lightTheme: ITheme = {
background: '#f7fbfa',
foreground: '#0d1218',
cursor: '#007560',
cursorAccent: '#f7fbfa',
selectionBackground: '#00756033',
black: '#0d1218',
red: '#d73337',
green: '#2f8f6e',
yellow: '#9a6b00',
blue: '#2f6bd8',
magenta: '#7c4dd1',
cyan: '#0f7d92',
white: '#26323c',
brightBlack: '#555f68',
brightRed: '#d73337',
brightGreen: '#2f8f6e',
brightYellow: '#9a6b00',
brightBlue: '#2f6bd8',
brightMagenta: '#7c4dd1',
brightCyan: '#0f7d92',
brightWhite: '#0d1218',
};
export const WorkspaceTerminal = ({ export const WorkspaceTerminal = ({
jobId, jobId,
@@ -69,198 +8,10 @@ export const WorkspaceTerminal = ({
}: { }: {
jobId: string; jobId: string;
active: boolean; active: boolean;
}) => { }) => (
const containerRef = useRef<HTMLDivElement>(null); <XtermSession
const termRef = useRef<Terminal | null>(null); active={active}
const { resolvedTheme } = useTheme(); tokenUrl={`/api/agent-jobs/${jobId}/terminal-token`}
const themeIsLight = resolvedTheme === 'light'; sessionKey={jobId}
const [status, setStatus] = useState<Status>('connecting'); />
const [errorText, setErrorText] = useState<string>(); );
const [reconnectKey, setReconnectKey] = useState(0);
// Update the live terminal's theme without tearing down the session.
useEffect(() => {
if (termRef.current) {
termRef.current.options.theme = themeIsLight ? lightTheme : darkTheme;
}
}, [themeIsLight]);
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
const abortController = new AbortController();
const signal = abortController.signal;
// Read through a function so TS doesn't narrow `aborted` to a constant after
// the first guard (it changes asynchronously, on cleanup).
const isAborted = () => signal.aborted;
let ws: WebSocket | undefined;
let resizeObserver: ResizeObserver | undefined;
const encoder = new TextEncoder();
const start = async () => {
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all(
[
import('@xterm/xterm'),
import('@xterm/addon-fit'),
import('@xterm/addon-web-links'),
],
);
if (isAborted()) return;
setStatus('connecting');
setErrorText(undefined);
let response: Response;
try {
response = await fetch(`/api/agent-jobs/${jobId}/terminal-token`, {
signal,
});
} catch {
if (!isAborted()) setStatus('error');
return;
}
if (isAborted()) return;
if (response.status === 503) {
setStatus('unconfigured');
return;
}
if (!response.ok) {
setStatus('error');
setErrorText(await response.text().catch(() => undefined));
return;
}
const { url } = (await response.json()) as { url: string };
const term = new Terminal({
fontFamily: TERMINAL_FONT,
fontSize: 13,
lineHeight: 1.2,
cursorBlink: true,
theme: themeIsLight ? lightTheme : darkTheme,
allowProposedApi: true,
scrollback: 5000,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.open(container);
termRef.current = term;
const sendResize = () => {
if (ws?.readyState !== WebSocket.OPEN) return;
ws.send(
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
);
};
scheduleTerminalFits({
fit: () => {
try {
fit.fit();
} catch {
// ignore transient layout errors
}
},
sendResize,
fontsReady: document.fonts.ready,
loadNerdFont: () =>
document.fonts
.load("16px 'Symbols Nerd Font Mono'", '\ue0b0')
.then(() => {
if (!isAborted()) term.refresh(0, term.rows - 1);
}),
raf: (cb) => requestAnimationFrame(cb),
isAborted,
});
ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
if (isAborted()) return;
setStatus('connected');
sendResize();
term.focus();
};
ws.onmessage = (event: MessageEvent<ArrayBuffer | string>) => {
if (typeof event.data === 'string') term.write(event.data);
else term.write(new Uint8Array(event.data));
};
ws.onclose = () => {
if (!isAborted()) setStatus('closed');
};
ws.onerror = () => {
if (!isAborted()) setStatus('error');
};
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
});
term.onResize(() => sendResize());
resizeObserver = new ResizeObserver(() => {
try {
fit.fit();
} catch {
// ignore transient layout errors
}
});
resizeObserver.observe(container);
};
void start();
return () => {
abortController.abort();
resizeObserver?.disconnect();
ws?.close();
termRef.current?.dispose();
termRef.current = null;
};
// resolvedTheme intentionally excluded: handled by the theme effect above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, jobId, reconnectKey]);
return (
<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'>
<p className='text-muted-foreground text-xs'>
{status === 'connected'
? 'Connected · workspace shell'
: status === 'connecting'
? 'Connecting…'
: status === 'closed'
? 'Session ended'
: status === 'unconfigured'
? 'Terminal not configured'
: 'Connection error'}
</p>
{status === 'closed' || status === 'error' ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setReconnectKey((key) => key + 1)}
>
Reconnect
</Button>
) : null}
</div>
{status === 'unconfigured' ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
The terminal is not enabled on this deployment.
<br />
Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it.
</div>
) : (
<div className='min-h-0 flex-1 overflow-hidden bg-[#080e14] p-2'>
<div ref={containerRef} className='h-full w-full' />
{errorText ? (
<p className='text-destructive mt-2 px-1 text-xs break-all'>
{errorText}
</p>
) : null}
</div>
)}
</div>
);
};
@@ -0,0 +1,288 @@
'use client';
import type { ITheme, Terminal } from '@xterm/xterm';
import { useEffect, useRef, useState } from 'react';
import { useTheme } from 'next-themes';
import { Button } from '@spoon/ui';
import { scheduleTerminalFits } from './terminal-fit';
import '@xterm/xterm/css/xterm.css';
const TERMINAL_FONT =
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, monospace";
export type XtermStatus =
| 'idle'
| 'connecting'
| 'connected'
| 'closed'
| 'error'
| 'unconfigured';
const darkTheme: ITheme = {
background: '#080e14',
foreground: '#eef3f5',
cursor: '#1fb895',
cursorAccent: '#080e14',
selectionBackground: '#1fb89544',
black: '#10171e',
red: '#f3625d',
green: '#8fd6b4',
yellow: '#e3b341',
blue: '#6aa6ff',
magenta: '#b692e8',
cyan: '#5fd0e0',
white: '#cdd6dc',
brightBlack: '#93a1a9',
brightRed: '#f3625d',
brightGreen: '#8fd6b4',
brightYellow: '#e3b341',
brightBlue: '#6aa6ff',
brightMagenta: '#b692e8',
brightCyan: '#5fd0e0',
brightWhite: '#eef3f5',
};
const lightTheme: ITheme = {
background: '#f7fbfa',
foreground: '#0d1218',
cursor: '#007560',
cursorAccent: '#f7fbfa',
selectionBackground: '#00756033',
black: '#0d1218',
red: '#d73337',
green: '#2f8f6e',
yellow: '#9a6b00',
blue: '#2f6bd8',
magenta: '#7c4dd1',
cyan: '#0f7d92',
white: '#26323c',
brightBlack: '#555f68',
brightRed: '#d73337',
brightGreen: '#2f8f6e',
brightYellow: '#9a6b00',
brightBlue: '#2f6bd8',
brightMagenta: '#7c4dd1',
brightCyan: '#0f7d92',
brightWhite: '#0d1218',
};
export const XtermSession = ({
active,
tokenUrl,
sessionKey,
waitingLabel,
copyOnSelect,
}: {
active: boolean;
tokenUrl: string;
sessionKey: string;
waitingLabel?: string;
copyOnSelect?: boolean;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const { resolvedTheme } = useTheme();
const themeIsLight = resolvedTheme === 'light';
const [status, setStatus] = useState<XtermStatus>('connecting');
const [errorText, setErrorText] = useState<string>();
const [reconnectKey, setReconnectKey] = useState(0);
// Update the live terminal's theme without tearing down the session.
useEffect(() => {
if (termRef.current) {
termRef.current.options.theme = themeIsLight ? lightTheme : darkTheme;
}
}, [themeIsLight]);
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
const abortController = new AbortController();
const signal = abortController.signal;
// Read through a function so TS doesn't narrow `aborted` to a constant after
// the first guard (it changes asynchronously, on cleanup).
const isAborted = () => signal.aborted;
let ws: WebSocket | undefined;
let resizeObserver: ResizeObserver | undefined;
const encoder = new TextEncoder();
const start = async () => {
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all(
[
import('@xterm/xterm'),
import('@xterm/addon-fit'),
import('@xterm/addon-web-links'),
],
);
if (isAborted()) return;
setStatus('connecting');
setErrorText(undefined);
let response: Response;
try {
response = await fetch(tokenUrl, { signal });
} catch {
if (!isAborted()) setStatus('error');
return;
}
if (isAborted()) return;
if (response.status === 503) {
setStatus('unconfigured');
return;
}
if (!response.ok) {
setStatus('error');
setErrorText(await response.text().catch(() => undefined));
return;
}
const { url } = (await response.json()) as { url: string };
const term = new Terminal({
fontFamily: TERMINAL_FONT,
fontSize: 13,
lineHeight: 1.2,
cursorBlink: true,
theme: themeIsLight ? lightTheme : darkTheme,
allowProposedApi: true,
scrollback: 5000,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.open(container);
termRef.current = term;
// 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.
if (copyOnSelect) {
term.onSelectionChange(() => {
// no-op placeholder until Task 11
});
}
const sendResize = () => {
if (ws?.readyState !== WebSocket.OPEN) return;
ws.send(
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
);
};
scheduleTerminalFits({
fit: () => {
try {
fit.fit();
} catch {
// ignore transient layout errors
}
},
sendResize,
fontsReady: document.fonts.ready,
loadNerdFont: () =>
document.fonts
.load("16px 'Symbols Nerd Font Mono'", '')
.then(() => {
if (!isAborted()) term.refresh(0, term.rows - 1);
}),
raf: (cb) => requestAnimationFrame(cb),
isAborted,
});
ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
if (isAborted()) return;
setStatus('connected');
sendResize();
term.focus();
};
ws.onmessage = (event: MessageEvent<ArrayBuffer | string>) => {
if (typeof event.data === 'string') term.write(event.data);
else term.write(new Uint8Array(event.data));
};
ws.onclose = () => {
if (!isAborted()) setStatus('closed');
};
ws.onerror = () => {
if (!isAborted()) setStatus('error');
};
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
});
term.onResize(() => sendResize());
resizeObserver = new ResizeObserver(() => {
try {
fit.fit();
} catch {
// ignore transient layout errors
}
});
resizeObserver.observe(container);
};
void start();
return () => {
abortController.abort();
resizeObserver?.disconnect();
ws?.close();
termRef.current?.dispose();
termRef.current = null;
};
// resolvedTheme intentionally excluded: handled by the theme effect above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, sessionKey, reconnectKey]);
return (
<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'>
<p className='text-muted-foreground text-xs'>
{status === 'connected'
? 'Connected · workspace shell'
: status === 'connecting'
? 'Connecting…'
: status === 'closed'
? 'Session ended'
: status === 'unconfigured'
? 'Terminal not configured'
: 'Connection error'}
</p>
{status === 'closed' || status === 'error' ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setReconnectKey((key) => key + 1)}
>
Reconnect
</Button>
) : null}
</div>
{status === 'unconfigured' ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
The terminal is not enabled on this deployment.
<br />
Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it.
</div>
) : !active && waitingLabel ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
{waitingLabel}
</div>
) : (
<div className='min-h-0 flex-1 overflow-hidden bg-[#080e14] p-2'>
<div ref={containerRef} className='h-full w-full' />
{errorText ? (
<p className='text-destructive mt-2 px-1 text-xs break-all'>
{errorText}
</p>
) : null}
</div>
)}
</div>
);
};
@@ -0,0 +1,130 @@
import { render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { XtermSession } from '../../src/components/agent-workspace/xterm-session';
const { openSpy, wsInstances, fetchSpy } = vi.hoisted(() => ({
openSpy: vi.fn(),
wsInstances: [] as Array<{ url: string }>,
fetchSpy: vi.fn(),
}));
class FakeTerminal {
cols = 80;
rows = 24;
options: Record<string, unknown> = {};
open = openSpy;
loadAddon = vi.fn();
onData = vi.fn();
onResize = vi.fn();
onSelectionChange = vi.fn();
dispose = vi.fn();
refresh = vi.fn();
write = vi.fn();
focus = vi.fn();
constructor(opts: Record<string, unknown>) {
this.options = opts ?? {};
}
}
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }));
vi.mock('@xterm/addon-fit', () => ({
FitAddon: class {
fit = vi.fn();
},
}));
vi.mock('@xterm/addon-web-links', () => ({
WebLinksAddon: class {},
}));
vi.mock('next-themes', () => ({
useTheme: () => ({ resolvedTheme: 'dark' }),
}));
class FakeWebSocket {
static OPEN = 1;
url: string;
readyState = 1;
binaryType = '';
onopen: (() => void) | null = null;
onmessage: ((event: unknown) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
send = vi.fn();
close = vi.fn();
constructor(url: string) {
this.url = url;
wsInstances.push(this);
}
}
beforeEach(() => {
wsInstances.length = 0;
openSpy.mockClear();
fetchSpy.mockReset();
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ url: 'ws://x' }),
text: async () => '',
});
vi.stubGlobal('WebSocket', FakeWebSocket);
vi.stubGlobal('fetch', fetchSpy);
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
disconnect() {}
unobserve() {}
},
);
Object.defineProperty(document, 'fonts', {
configurable: true,
value: {
ready: Promise.resolve(),
load: () => Promise.resolve([]),
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('XtermSession', () => {
it('shows the waiting label and does not open a WebSocket while inactive', async () => {
render(
<XtermSession
active={false}
tokenUrl='/api/box/terminal-token'
sessionKey='box-1'
waitingLabel='Workspace is asleep.'
/>,
);
expect(screen.getByText('Workspace is asleep.')).toBeInTheDocument();
// Give any (unexpected) async connect a chance to run.
await Promise.resolve();
expect(fetchSpy).not.toHaveBeenCalled();
expect(wsInstances).toHaveLength(0);
});
it('fetches the token URL and opens a WebSocket to the returned url when active', async () => {
render(
<XtermSession
active
tokenUrl='/api/box/terminal-token'
sessionKey='box-1'
/>,
);
await waitFor(() =>
expect(fetchSpy).toHaveBeenCalledWith(
'/api/box/terminal-token',
expect.objectContaining({ signal: expect.anything() }),
),
);
await waitFor(() => expect(wsInstances).toHaveLength(1));
expect(wsInstances[0]?.url).toBe('ws://x');
expect(openSpy).toHaveBeenCalled();
});
});