Workspace Terminal tab: xterm front end + short-lived token route

- New Terminal tab in the workspace shell, backed by xterm.js, that connects
  to the worker's PTY WebSocket using a short-lived token minted by
  /api/agent-jobs/:id/terminal-token (owner-auth'd, never exposes the worker
  secret to the browser)
- Site-matched xterm theme (light/dark, live theme switching), Victor Mono,
  binary stdin + JSON resize protocol, reconnect, graceful 'not configured'
  state when NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL is unset
- env: NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL (client), SPOON_AGENT_TERMINAL_SECRET
This commit is contained in:
Gabriel Brown
2026-06-24 08:23:58 -04:00
parent c1263b2e69
commit 15407e7e9c
7 changed files with 335 additions and 5 deletions
@@ -3,7 +3,13 @@
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { FileCode, GitCompare, Loader2, MessagesSquare } from 'lucide-react';
import {
FileCode,
GitCompare,
Loader2,
MessagesSquare,
SquareTerminal,
} from 'lucide-react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -34,6 +40,9 @@ import { FileTabs } from './file-tabs';
import { FileTree } from './file-tree';
import { JobStatusBar } from './job-status-bar';
import { WorkspaceActions } from './workspace-actions';
import { WorkspaceTerminal } from './workspace-terminal';
type WorkspaceTab = 'editor' | 'diff' | 'thread' | 'terminal';
type OpenFileState = {
path: string;
@@ -81,9 +90,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const [focusedDiffPath, setFocusedDiffPath] = useState<string>();
const [workspaceError, setWorkspaceError] = useState<string>();
const [agentTurnActive, setAgentTurnActive] = useState(false);
const [activeWorkspaceTab, setActiveWorkspaceTab] = useState<
'editor' | 'diff' | 'thread'
>('editor');
const [activeWorkspaceTab, setActiveWorkspaceTab] =
useState<WorkspaceTab>('editor');
const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>();
const [pendingClosePath, setPendingClosePath] = useState<string>();
@@ -575,7 +583,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
<Tabs
value={activeWorkspaceTab}
onValueChange={(value) =>
setActiveWorkspaceTab(value as 'editor' | 'diff' | 'thread')
setActiveWorkspaceTab(value as WorkspaceTab)
}
className='flex min-h-0 flex-1 flex-col'
>
@@ -594,6 +602,13 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
<GitCompare className='size-4' />
Diff viewer
</TabsTrigger>
<TabsTrigger
value='terminal'
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
>
<SquareTerminal className='size-4' />
Terminal
</TabsTrigger>
<TabsTrigger
value='thread'
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm 2xl:hidden'
@@ -642,6 +657,15 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}}
/>
</TabsContent>
<TabsContent
value='terminal'
className='m-0 min-h-0 flex-1 overflow-hidden'
>
<WorkspaceTerminal
jobId={jobId}
active={activeWorkspaceTab === 'terminal' && workspaceReady}
/>
</TabsContent>
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
<DiffViewer
diff={diff}