diff --git a/apps/next/src/components/agent-workspace/workspace-terminal.tsx b/apps/next/src/components/agent-workspace/workspace-terminal.tsx index 59d9923..d744e54 100644 --- a/apps/next/src/components/agent-workspace/workspace-terminal.tsx +++ b/apps/next/src/components/agent-workspace/workspace-terminal.tsx @@ -1,67 +1,6 @@ '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"; - -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', -}; +import { XtermSession } from './xterm-session'; export const WorkspaceTerminal = ({ jobId, @@ -69,198 +8,10 @@ export const WorkspaceTerminal = ({ }: { jobId: string; active: boolean; -}) => { - const containerRef = useRef(null); - const termRef = useRef(null); - const { resolvedTheme } = useTheme(); - const themeIsLight = resolvedTheme === 'light'; - const [status, setStatus] = useState('connecting'); - const [errorText, setErrorText] = useState(); - 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) => { - 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 ( -
-
-

- {status === 'connected' - ? 'Connected · workspace shell' - : status === 'connecting' - ? 'Connecting…' - : status === 'closed' - ? 'Session ended' - : status === 'unconfigured' - ? 'Terminal not configured' - : 'Connection error'} -

- {status === 'closed' || status === 'error' ? ( - - ) : null} -
- {status === 'unconfigured' ? ( -
- The terminal is not enabled on this deployment. -
- Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it. -
- ) : ( -
-
- {errorText ? ( -

- {errorText} -

- ) : null} -
- )} -
- ); -}; +}) => ( + +); diff --git a/apps/next/src/components/agent-workspace/xterm-session.tsx b/apps/next/src/components/agent-workspace/xterm-session.tsx new file mode 100644 index 0000000..4d575e0 --- /dev/null +++ b/apps/next/src/components/agent-workspace/xterm-session.tsx @@ -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(null); + const termRef = useRef(null); + const { resolvedTheme } = useTheme(); + const themeIsLight = resolvedTheme === 'light'; + const [status, setStatus] = useState('connecting'); + const [errorText, setErrorText] = useState(); + 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) => { + 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 ( +
+
+

+ {status === 'connected' + ? 'Connected · workspace shell' + : status === 'connecting' + ? 'Connecting…' + : status === 'closed' + ? 'Session ended' + : status === 'unconfigured' + ? 'Terminal not configured' + : 'Connection error'} +

+ {status === 'closed' || status === 'error' ? ( + + ) : null} +
+ {status === 'unconfigured' ? ( +
+ The terminal is not enabled on this deployment. +
+ Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it. +
+ ) : !active && waitingLabel ? ( +
+ {waitingLabel} +
+ ) : ( +
+
+ {errorText ? ( +

+ {errorText} +

+ ) : null} +
+ )} +
+ ); +}; diff --git a/apps/next/tests/component/xterm-session.test.tsx b/apps/next/tests/component/xterm-session.test.tsx new file mode 100644 index 0000000..71fab02 --- /dev/null +++ b/apps/next/tests/component/xterm-session.test.tsx @@ -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 = {}; + 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) { + 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( + , + ); + + 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( + , + ); + + 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(); + }); +});