refactor(web): extract reusable XtermSession from WorkspaceTerminal
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user