Move to threads based system.

This commit is contained in:
Gabriel Brown
2026-06-22 10:37:26 -04:00
parent 8ae6c4b533
commit 206b64176b
82 changed files with 6169 additions and 1930 deletions
@@ -0,0 +1,83 @@
'use client';
import { useState } from 'react';
import { Send } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button, Textarea } from '@spoon/ui';
export const AgentThread = ({
jobId,
messages,
disabled,
}: {
jobId: string;
messages: Doc<'agentJobMessages'>[];
disabled: boolean;
}) => {
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
const send = async () => {
if (!content.trim()) return;
setSending(true);
try {
const response = await fetch(`/api/agent-jobs/${jobId}/message`, {
method: 'POST',
body: JSON.stringify({ content }),
});
if (!response.ok) throw new Error(await response.text());
setContent('');
} catch (error) {
console.error(error);
toast.error('Could not send message.');
} finally {
setSending(false);
}
};
return (
<div className='flex h-full min-h-[520px] flex-col'>
<div className='border-border border-b p-3'>
<h2 className='text-sm font-semibold'>Agent thread</h2>
<p className='text-muted-foreground text-xs'>
Messages persist with this workspace.
</p>
</div>
<div className='min-h-0 flex-1 space-y-3 overflow-auto p-3'>
{messages.map((message) => (
<article
key={message._id}
className='border-border bg-background rounded-md border p-3 text-sm'
>
<div className='mb-2 flex items-center justify-between gap-2'>
<span className='font-medium capitalize'>{message.role}</span>
<span className='text-muted-foreground text-xs capitalize'>
{message.status}
</span>
</div>
<p className='whitespace-pre-wrap'>{message.content}</p>
</article>
))}
</div>
<div className='border-border space-y-2 border-t p-3'>
<Textarea
value={content}
placeholder='Ask the agent to inspect, explain, or change this fork.'
disabled={disabled || sending}
onChange={(event) => setContent(event.target.value)}
/>
<Button
type='button'
className='w-full'
disabled={disabled || sending || !content.trim()}
onClick={send}
>
<Send className='size-4' />
{sending ? 'Sending...' : 'Send'}
</Button>
</div>
</div>
);
};
@@ -0,0 +1,153 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useQuery } from 'convex/react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
import type { DiffResponse, FileResponse, FileTreeNode } from './types';
import { AgentThread } from './agent-thread';
import { CodeEditor } from './code-editor';
import { CommandPanel } from './command-panel';
import { DiffViewer } from './diff-viewer';
import { FileTree } from './file-tree';
import { JobStatusBar } from './job-status-bar';
import { WorkspaceActions } from './workspace-actions';
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const job = useQuery(api.agentJobs.get, { jobId });
const messages =
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
const [tree, setTree] = useState<FileTreeNode | null>(null);
const [selectedPath, setSelectedPath] = useState<string>();
const [fileContent, setFileContent] = useState('');
const [diff, setDiff] = useState('');
const workspaceDisabled =
!job ||
['draft_pr_opened', 'failed', 'cancelled', 'timed_out'].includes(
job.status,
) ||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
const loadTree = useCallback(async () => {
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as { tree: FileTreeNode | null };
setTree(data.tree);
}, [jobId]);
const loadDiff = useCallback(async () => {
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as DiffResponse;
setDiff(data.diff);
}, [jobId]);
const loadFile = useCallback(
async (path: string) => {
const response = await fetch(
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as FileResponse;
setSelectedPath(data.path);
setFileContent(data.content);
},
[jobId],
);
useEffect(() => {
if (!job) return;
const timeout = window.setTimeout(() => {
void loadTree().catch((error: unknown) => {
console.error(error);
});
void loadDiff().catch((error: unknown) => {
console.error(error);
});
}, 0);
return () => window.clearTimeout(timeout);
}, [job, loadDiff, loadTree]);
if (job === undefined) {
return (
<main className='text-muted-foreground p-6'>Loading workspace...</main>
);
}
const saveFile = async (content: string) => {
if (!selectedPath) return;
const response = await fetch(`/api/agent-jobs/${jobId}/file`, {
method: 'PUT',
body: JSON.stringify({ path: selectedPath, content }),
});
if (!response.ok) {
toast.error('Could not save file.');
throw new Error(await response.text());
}
setFileContent(content);
await loadDiff();
toast.success('File saved.');
};
return (
<main className='border-border bg-muted/20 min-h-[calc(100vh-5rem)] overflow-hidden rounded-md border'>
<JobStatusBar job={job} />
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
<WorkspaceActions job={job} disabled={workspaceDisabled} />
</div>
<div className='grid min-h-[680px] grid-cols-1 xl:grid-cols-[260px_minmax(0,1fr)_360px]'>
<aside className='border-border bg-background min-h-[260px] border-r'>
<div className='border-border border-b p-3'>
<h2 className='text-sm font-semibold'>Files</h2>
<p className='text-muted-foreground text-xs'>Current workspace</p>
</div>
<FileTree
tree={tree}
selectedPath={selectedPath}
onSelect={(path) => {
void loadFile(path).catch((error) => {
console.error(error);
toast.error('Could not load file.');
});
}}
/>
</aside>
<section className='bg-background min-w-0'>
<Tabs defaultValue='editor' className='h-full'>
<TabsList
variant='line'
className='border-border h-11 w-full justify-start rounded-none border-b px-3'
>
<TabsTrigger value='editor'>Editor</TabsTrigger>
<TabsTrigger value='diff'>Diff</TabsTrigger>
</TabsList>
<TabsContent value='editor' className='m-0'>
<CodeEditor
path={selectedPath}
content={fileContent}
readOnly={workspaceDisabled}
onSave={saveFile}
/>
</TabsContent>
<TabsContent value='diff' className='m-0'>
<DiffViewer diff={diff} onRefresh={loadDiff} />
</TabsContent>
</Tabs>
<CommandPanel jobId={jobId} disabled={workspaceDisabled} />
</section>
<aside className='border-border bg-muted/20 min-w-0 border-l'>
<AgentThread
jobId={jobId}
messages={messages}
disabled={workspaceDisabled}
/>
</aside>
</div>
</main>
);
};
@@ -0,0 +1,133 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import { Button, Switch } from '@spoon/ui';
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false,
});
type MonacoEditorInstance = {
getModel?: () => unknown;
};
type VimMode = {
dispose: () => void;
};
export const CodeEditor = ({
path,
content,
readOnly,
onSave,
}: {
path?: string;
content: string;
readOnly: boolean;
onSave: (content: string) => Promise<void>;
}) => {
const [value, setValue] = useState(content);
const [saving, setSaving] = useState(false);
const [vimEnabled, setVimEnabled] = useState(false);
const [dirty, setDirty] = useState(false);
const editorRef = useRef<MonacoEditorInstance | null>(null);
const vimRef = useRef<VimMode | null>(null);
const statusRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setValue(content);
setDirty(false);
}, [content, path]);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
vimRef.current?.dispose();
vimRef.current = null;
if (!vimEnabled) return;
void import('monaco-vim').then((module) => {
const initVimMode = module.initVimMode as unknown as (
editor: MonacoEditorInstance,
statusNode?: HTMLElement | null,
) => VimMode;
vimRef.current = initVimMode(editor, statusRef.current);
});
return () => {
vimRef.current?.dispose();
vimRef.current = null;
};
}, [vimEnabled, path]);
if (!path) {
return (
<div className='text-muted-foreground flex h-full items-center justify-center text-sm'>
Select a file to inspect or edit.
</div>
);
}
const save = async () => {
setSaving(true);
try {
await onSave(value);
setDirty(false);
} finally {
setSaving(false);
}
};
return (
<div className='flex h-full min-h-[520px] flex-col'>
<div className='border-border flex h-11 items-center justify-between gap-3 border-b px-3'>
<div className='min-w-0'>
<p className='truncate font-mono text-xs'>{path}</p>
{dirty ? (
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
) : null}
</div>
<div className='flex items-center gap-3'>
<label className='flex items-center gap-2 text-xs'>
Vim
<Switch checked={vimEnabled} onCheckedChange={setVimEnabled} />
</label>
<Button
type='button'
size='sm'
disabled={readOnly || saving || !dirty}
onClick={save}
>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
<div className='min-h-0 flex-1'>
<MonacoEditor
height='520px'
path={path}
value={value}
theme='vs-dark'
options={{
readOnly,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
wordWrap: 'on',
}}
onMount={(editor) => {
editorRef.current = editor as MonacoEditorInstance;
}}
onChange={(next) => {
setValue(next ?? '');
setDirty((next ?? '') !== content);
}}
/>
</div>
<div
ref={statusRef}
className='border-border text-muted-foreground h-6 border-t px-3 py-1 font-mono text-xs'
/>
</div>
);
};
@@ -0,0 +1,56 @@
'use client';
import { useState } from 'react';
import { Terminal } from 'lucide-react';
import { toast } from 'sonner';
import { Button, Input } from '@spoon/ui';
export const CommandPanel = ({
jobId,
disabled,
}: {
jobId: string;
disabled: boolean;
}) => {
const [command, setCommand] = useState('');
const [running, setRunning] = useState(false);
const run = async () => {
setRunning(true);
try {
const response = await fetch(`/api/agent-jobs/${jobId}/command`, {
method: 'POST',
body: JSON.stringify({ command }),
});
if (!response.ok) throw new Error(await response.text());
toast.success('Command completed.');
setCommand('');
} catch (error) {
console.error(error);
toast.error('Command failed.');
} finally {
setRunning(false);
}
};
return (
<div className='border-border flex items-center gap-2 border-t p-3'>
<Terminal className='text-muted-foreground size-4' />
<Input
value={command}
placeholder='bun test, pnpm lint, npm run typecheck...'
disabled={disabled || running}
onChange={(event) => setCommand(event.target.value)}
/>
<Button
type='button'
variant='outline'
disabled={disabled || running || !command.trim()}
onClick={run}
>
{running ? 'Running...' : 'Run'}
</Button>
</div>
);
};
@@ -0,0 +1,47 @@
'use client';
import dynamic from 'next/dynamic';
import { Button } from '@spoon/ui';
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false,
});
export const DiffViewer = ({
diff,
onRefresh,
}: {
diff: string;
onRefresh: () => Promise<void>;
}) => (
<div className='flex h-full min-h-[520px] flex-col'>
<div className='border-border flex h-11 items-center justify-between border-b px-3'>
<div>
<p className='text-sm font-medium'>Workspace diff</p>
<p className='text-muted-foreground text-xs'>Current git diff</p>
</div>
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
Refresh
</Button>
</div>
{diff.trim() ? (
<MonacoEditor
height='520px'
language='diff'
theme='vs-dark'
value={diff}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
}}
/>
) : (
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
No workspace diff yet.
</div>
)}
</div>
);
@@ -0,0 +1,83 @@
'use client';
import { ChevronRight, FileCode, Folder } from 'lucide-react';
import { Button } from '@spoon/ui';
import type { FileTreeNode } from './types';
const TreeNode = ({
node,
selectedPath,
onSelect,
depth = 0,
}: {
node: FileTreeNode;
selectedPath?: string;
onSelect: (path: string) => void;
depth?: number;
}) => {
if (node.type === 'directory') {
return (
<div>
{node.path ? (
<div
className='text-muted-foreground flex h-7 items-center gap-1 px-2 text-xs font-medium'
style={{ paddingLeft: depth * 12 + 8 }}
>
<ChevronRight className='size-3' />
<Folder className='size-3' />
<span className='truncate'>{node.name}</span>
</div>
) : null}
<div>
{node.children?.map((child) => (
<TreeNode
key={`${child.type}:${child.path}`}
node={child}
selectedPath={selectedPath}
onSelect={onSelect}
depth={node.path ? depth + 1 : depth}
/>
))}
</div>
</div>
);
}
return (
<Button
type='button'
variant={selectedPath === node.path ? 'secondary' : 'ghost'}
className='h-7 w-full justify-start gap-2 rounded-none px-2 text-left text-xs font-normal'
style={{ paddingLeft: depth * 12 + 8 }}
onClick={() => onSelect(node.path)}
>
<FileCode className='size-3 flex-none' />
<span className='truncate'>{node.name}</span>
</Button>
);
};
export const FileTree = ({
tree,
selectedPath,
onSelect,
}: {
tree: FileTreeNode | null;
selectedPath?: string;
onSelect: (path: string) => void;
}) => {
if (!tree) {
return (
<p className='text-muted-foreground p-3 text-sm'>
Workspace files are not available yet.
</p>
);
}
return (
<div className='overflow-auto py-2'>
<TreeNode node={tree} selectedPath={selectedPath} onSelect={onSelect} />
</div>
);
};
@@ -0,0 +1,24 @@
'use client';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge } from '@spoon/ui';
export const JobStatusBar = ({ job }: { job: Doc<'agentJobs'> }) => (
<div className='border-border bg-background flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3'>
<div className='min-w-0'>
<h1 className='truncate text-base font-semibold'>{job.forkRepo}</h1>
<p className='text-muted-foreground truncate font-mono text-xs'>
{job.baseBranch} {'->'} {job.workBranch}
</p>
</div>
<div className='flex items-center gap-2'>
<Badge variant='outline' className='capitalize'>
{job.status.replaceAll('_', ' ')}
</Badge>
<Badge variant='secondary' className='capitalize'>
{(job.workspaceStatus ?? 'not_started').replaceAll('_', ' ')}
</Badge>
<Badge variant='outline'>{job.runtime ?? 'opencode'}</Badge>
</div>
</div>
);
@@ -0,0 +1,15 @@
export type FileTreeNode = {
name: string;
path: string;
type: 'file' | 'directory';
children?: FileTreeNode[];
};
export type FileResponse = {
path: string;
content: string;
};
export type DiffResponse = {
diff: string;
};
@@ -0,0 +1,68 @@
'use client';
import { ExternalLink, GitPullRequestDraft, Square } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button } from '@spoon/ui';
export const WorkspaceActions = ({
job,
disabled,
}: {
job: Doc<'agentJobs'>;
disabled: boolean;
}) => {
const openPr = async () => {
try {
const response = await fetch(`/api/agent-jobs/${job._id}/open-pr`, {
method: 'POST',
});
if (!response.ok) throw new Error(await response.text());
toast.success('Draft PR opened.');
} catch (error) {
console.error(error);
toast.error('Could not open draft PR.');
}
};
const stop = async () => {
try {
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
method: 'POST',
});
if (!response.ok) throw new Error(await response.text());
toast.success('Workspace stopped.');
} catch (error) {
console.error(error);
toast.error('Could not stop workspace.');
}
};
return (
<div className='flex flex-wrap items-center gap-2'>
{job.pullRequestUrl ? (
<Button asChild variant='outline' size='sm'>
<a href={job.pullRequestUrl} target='_blank' rel='noreferrer'>
<ExternalLink className='size-4' />
Open PR
</a>
</Button>
) : null}
<Button type='button' size='sm' disabled={disabled} onClick={openPr}>
<GitPullRequestDraft className='size-4' />
Open draft PR
</Button>
<Button
type='button'
variant='outline'
size='sm'
disabled={disabled}
onClick={stop}
>
<Square className='size-4' />
Stop
</Button>
</div>
);
};