Clean up old stuff & fix ui errors
Build and Push Spoon Images / quality (push) Successful in 2m22s
Build and Push Spoon Images / build-images (push) Successful in 23m10s

This commit is contained in:
Gabriel Brown
2026-06-23 14:57:05 -04:00
parent d207b8b0b8
commit a6f7ea7f78
34 changed files with 1565 additions and 551 deletions
@@ -1,30 +1,113 @@
'use client';
import { useState } from 'react';
import { Ban, Send } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Ban,
FilePenLine,
MessagesSquare,
Send,
Terminal,
TriangleAlert,
} from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Button, Textarea } from '@spoon/ui';
import { extractFileDiff } from './diff-utils';
type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors';
const filters: { value: ActivityFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'chat', label: 'Chat' },
{ value: 'activity', label: 'Activity' },
{ value: 'files', label: 'Files' },
{ value: 'errors', label: 'Errors' },
];
const formatEventTime = (value: number) =>
new Date(value).toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
});
const eventIcon = (event: Doc<'agentJobEvents'>) => {
if (event.level === 'error') return <TriangleAlert className='size-3' />;
if (event.phase === 'edit') return <FilePenLine className='size-3' />;
if (event.phase === 'check' || event.phase === 'test') {
return <Terminal className='size-3' />;
}
return <MessagesSquare className='size-3' />;
};
export const AgentThread = ({
jobId,
messages,
events,
interactions,
workspaceChanges,
disabled,
agentTurnActive,
onOpenFile,
onOpenDiff,
}: {
jobId: string;
messages: Doc<'agentJobMessages'>[];
events: Doc<'agentJobEvents'>[];
interactions: Doc<'agentInteractionRequests'>[];
workspaceChanges: Doc<'agentWorkspaceChanges'>[];
disabled: boolean;
agentTurnActive: boolean;
onOpenFile: (path: string) => void;
onOpenDiff: (path: string) => void;
}) => {
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
const [replying, setReplying] = useState<string>();
const [filter, setFilter] = useState<ActivityFilter>('all');
const scrollRef = useRef<HTMLDivElement>(null);
const failedMessages = useMemo(
() => messages.filter((message) => message.status === 'failed'),
[messages],
);
const visibleMessages =
filter === 'activity' || filter === 'files' || filter === 'errors'
? filter === 'errors'
? failedMessages
: []
: messages;
const visibleEvents =
filter === 'chat' || filter === 'files'
? []
: filter === 'errors'
? events.filter((event) => event.level === 'error')
: events;
const visibleChanges =
filter === 'chat' || filter === 'activity' || filter === 'errors'
? []
: workspaceChanges;
useEffect(() => {
const node = scrollRef.current;
if (!node) return;
const distanceFromBottom =
node.scrollHeight - node.scrollTop - node.clientHeight;
if (distanceFromBottom < 160 || agentTurnActive) {
if (typeof node.scrollTo === 'function') {
node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' });
} else {
node.scrollTop = node.scrollHeight;
}
}
}, [
agentTurnActive,
events.length,
interactions.length,
messages.length,
workspaceChanges.length,
]);
const send = async () => {
if (!content.trim()) return;
@@ -84,10 +167,15 @@ export const AgentThread = ({
};
return (
<div className='flex h-full min-h-[520px] flex-col'>
<div className='border-border flex items-start justify-between gap-3 border-b p-3'>
<div className='flex h-full min-h-0 flex-col overflow-hidden'>
<div className='border-border flex flex-none items-start justify-between gap-3 border-b p-3'>
<div>
<h2 className='text-sm font-semibold'>Agent thread</h2>
<div className='flex items-center gap-2'>
<h2 className='text-sm font-semibold'>Agent thread</h2>
{agentTurnActive ? (
<Badge variant='secondary'>Working</Badge>
) : null}
</div>
<p className='text-muted-foreground text-xs'>
Messages, tool activity, and requests persist with this workspace.
</p>
@@ -103,43 +191,64 @@ export const AgentThread = ({
Abort
</Button>
</div>
<div className='min-h-0 flex-1 space-y-3 overflow-auto p-3'>
{interactions.map((interaction) => (
<article
key={interaction._id}
className='border-primary/40 bg-primary/5 rounded-md border p-3 text-sm'
<div className='border-border flex flex-none gap-1 overflow-x-auto border-b px-3 py-2'>
{filters.map((item) => (
<Button
key={item.value}
type='button'
variant={filter === item.value ? 'secondary' : 'ghost'}
size='sm'
className='h-7 flex-none text-xs'
onClick={() => setFilter(item.value)}
>
<div className='mb-2 flex items-center justify-between gap-2'>
<span className='font-medium'>{interaction.title}</span>
<Badge variant='outline' className='capitalize'>
{interaction.status}
</Badge>
</div>
<p className='text-sm whitespace-pre-wrap'>{interaction.body}</p>
{interaction.status === 'pending' ? (
<div className='mt-3 flex gap-2'>
<Button
type='button'
size='sm'
disabled={replying === interaction._id}
onClick={() => void reply(interaction, 'once')}
>
Approve
</Button>
<Button
type='button'
size='sm'
variant='outline'
disabled={replying === interaction._id}
onClick={() => void reply(interaction, 'reject')}
>
Reject
</Button>
</div>
) : null}
</article>
{item.label}
</Button>
))}
{messages.map((message) => (
</div>
<div
ref={scrollRef}
className='min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain p-3'
>
{(filter === 'all' || filter === 'chat') && interactions.length > 0
? interactions.map((interaction) => (
<article
key={interaction._id}
className='border-primary/40 bg-primary/5 rounded-md border p-3 text-sm'
>
<div className='mb-2 flex items-center justify-between gap-2'>
<span className='font-medium'>{interaction.title}</span>
<Badge variant='outline' className='capitalize'>
{interaction.status}
</Badge>
</div>
<p className='text-sm whitespace-pre-wrap'>
{interaction.body}
</p>
{interaction.status === 'pending' ? (
<div className='mt-3 flex gap-2'>
<Button
type='button'
size='sm'
disabled={replying === interaction._id}
onClick={() => void reply(interaction, 'once')}
>
Approve
</Button>
<Button
type='button'
size='sm'
variant='outline'
disabled={replying === interaction._id}
onClick={() => void reply(interaction, 'reject')}
>
Reject
</Button>
</div>
) : null}
</article>
))
: null}
{visibleMessages.map((message) => (
<article
key={message._id}
className={
@@ -167,27 +276,107 @@ export const AgentThread = ({
</p>
</article>
))}
{events.slice(-20).map((event) => (
{visibleChanges.map((change) => (
<article
key={event._id}
className='border-border text-muted-foreground rounded-md border border-dashed p-2 text-xs'
key={change._id}
className='border-border bg-background rounded-md border p-3 text-sm'
>
<div className='flex items-center justify-between gap-2'>
<span className='font-medium capitalize'>
{event.phase} / {event.level}
</span>
<span>{new Date(event.createdAt).toLocaleTimeString()}</span>
<div className='flex items-center justify-between gap-3'>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<FilePenLine className='text-primary size-4 flex-none' />
<span className='truncate font-mono text-xs'>
{change.path}
</span>
</div>
<p className='text-muted-foreground mt-1 text-xs capitalize'>
{change.source} {change.changeType}
</p>
</div>
<div className='flex flex-none items-center gap-2'>
{extractFileDiff(change.diff, change.path) ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={() => onOpenDiff(change.path)}
>
View diff
</Button>
) : null}
{change.path !== '.' ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={() => onOpenFile(change.path)}
>
Open
</Button>
) : null}
</div>
</div>
<p className='mt-1 whitespace-pre-wrap'>{event.message}</p>
{extractFileDiff(change.diff, change.path) ? (
<details className='mt-3'>
<summary className='text-muted-foreground cursor-pointer text-xs'>
File diff
</summary>
<pre className='bg-muted mt-2 max-h-72 overflow-auto rounded p-2 text-xs whitespace-pre-wrap'>
{extractFileDiff(change.diff, change.path)}
</pre>
</details>
) : null}
</article>
))}
{visibleEvents.slice(-80).map((event) => (
<article
key={event._id}
className={
event.level === 'error'
? 'border-destructive/40 bg-destructive/5 rounded-md border p-2 text-xs'
: 'border-border text-muted-foreground rounded-md border border-dashed p-2 text-xs'
}
>
<div className='flex items-center justify-between gap-2'>
<span className='flex min-w-0 items-center gap-1 font-medium capitalize'>
{eventIcon(event)}
{event.phase} / {event.level}
</span>
<span>{formatEventTime(event.createdAt)}</span>
</div>
<p className='mt-1 whitespace-pre-wrap'>{event.message}</p>
{event.metadata ? (
<details className='mt-2'>
<summary className='cursor-pointer'>Details</summary>
<pre className='bg-muted mt-1 max-h-40 overflow-auto rounded p-2 whitespace-pre-wrap'>
{event.metadata}
</pre>
</details>
) : null}
</article>
))}
{visibleMessages.length === 0 &&
visibleEvents.length === 0 &&
visibleChanges.length === 0 &&
(filter !== 'chat' || interactions.length === 0) ? (
<p className='text-muted-foreground p-3 text-sm'>
No {filter === 'all' ? 'agent activity' : filter} has been recorded
yet.
</p>
) : null}
</div>
<div className='border-border space-y-2 border-t p-3'>
<div className='border-border flex-none 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)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void send();
}
}}
/>
<Button
type='button'
@@ -1,7 +1,9 @@
'use client';
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { FileCode, GitCompare, MessagesSquare } from 'lucide-react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -33,6 +35,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
const events =
useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
const workspaceChanges =
useQuery(api.agentJobs.listWorkspaceChanges, { jobId, limit: 200 }) ?? [];
const interactions =
useQuery(api.agentJobs.listInteractionRequests, {
jobId,
@@ -50,11 +54,16 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const [expandedDirectoryPaths, setExpandedDirectoryPaths] = useState<
string[]
>([]);
const [agentThreadWidth, setAgentThreadWidth] = useState(420);
const [vimEnabled, setVimEnabled] = useState(false);
const [hydratedUiState, setHydratedUiState] = useState(false);
const [diff, setDiff] = useState('');
const [focusedDiffPath, setFocusedDiffPath] = useState<string>();
const [workspaceError, setWorkspaceError] = useState<string>();
const [agentTurnActive, setAgentTurnActive] = useState(false);
const [activeWorkspaceTab, setActiveWorkspaceTab] = useState<
'editor' | 'diff' | 'thread'
>('editor');
const workspaceDisabled =
!job ||
@@ -177,6 +186,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
setOpenFilePaths(uiState.openFilePaths);
setActiveFilePath(uiState.activeFilePath);
setExpandedDirectoryPaths(uiState.expandedDirectoryPaths);
setAgentThreadWidth(uiState.agentThreadWidth ?? 420);
setVimEnabled(uiState.vimEnabled);
setHydratedUiState(true);
}, 0);
@@ -192,6 +202,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
activeFilePath,
vimEnabled,
expandedDirectoryPaths,
agentThreadWidth,
}).catch((error: unknown) => {
console.error(error);
});
@@ -200,6 +211,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}, [
activeFilePath,
expandedDirectoryPaths,
agentThreadWidth,
hydratedUiState,
jobId,
openFilePaths,
@@ -230,11 +242,11 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
const recoverWorkspace = async () => {
if (!job.threadId) return;
const newJobId = await createJobForThread({
await createJobForThread({
threadId: job.threadId,
jobType: job.jobType ?? 'user_change',
});
window.location.href = `/spoons/${job.spoonId}/agent/${newJobId}`;
window.location.href = `/threads/${job.threadId}`;
};
const deleteStaleWorkspace = async () => {
@@ -248,6 +260,33 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const saveFile = async (content: string) => {
if (!activeFilePath) return;
const activeFileBeforeSave = files[activeFilePath];
if (activeFileBeforeSave) {
const latestResponse = await fetch(
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(activeFilePath)}`,
);
if (latestResponse.ok) {
const latestData = (await latestResponse.json()) as FileResponse;
if (latestData.content !== activeFileBeforeSave.savedContent) {
const overwrite = window.confirm(
`${activeFilePath} changed in the workspace after you opened it. Overwrite those newer changes with your editor contents?`,
);
if (!overwrite) {
setFiles((current) => ({
...current,
[activeFilePath]: {
...activeFileBeforeSave,
content: latestData.content,
savedContent: latestData.content,
saving: false,
},
}));
toast.info('File reloaded with latest workspace contents.');
return;
}
}
}
}
setFiles((current) => ({
...current,
[activeFilePath]: {
@@ -325,20 +364,54 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
);
};
const openFileFromActivity = (path: string) => {
openFile(path);
setActiveWorkspaceTab('editor');
};
const openDiffFromActivity = (path: string) => {
setFocusedDiffPath(path);
setActiveWorkspaceTab('diff');
};
const resizeAgentThread = (event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = agentThreadWidth;
const move = (moveEvent: PointerEvent) => {
const nextWidth = Math.min(
Math.max(startWidth - (moveEvent.clientX - startX), 320),
720,
);
setAgentThreadWidth(Math.round(nextWidth));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
<JobStatusBar job={job} />
{workspaceError ? (
<div className='border-border bg-background border-b p-4'>
<div className='border-destructive/40 bg-destructive/5 rounded-md border p-4'>
<p className='font-medium'>Workspace not active on this worker</p>
<p className='font-medium'>Thread workspace needs recovery</p>
<p className='text-muted-foreground mt-1 text-sm'>
The saved workspace record exists, but this worker cannot reach
its active runtime. This usually happens after a worker restart or
local container cleanup.
</p>
<p className='text-muted-foreground mt-2 text-xs break-all'>
{workspaceError}
</p>
<div className='mt-3 flex flex-wrap gap-2'>
{job.threadId ? (
<Button type='button' onClick={() => void recoverWorkspace()}>
Recreate workspace run
Start a fresh run
</Button>
) : null}
<Button
@@ -346,7 +419,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
variant='outline'
onClick={() => void deleteStaleWorkspace()}
>
Delete stale workspace
Delete stale record
</Button>
{job.threadId ? (
<Button type='button' variant='outline' asChild>
@@ -360,7 +433,14 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
<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-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] 2xl:grid-cols-[300px_minmax(0,1fr)_420px]'>
<div
className='grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] 2xl:grid-cols-[300px_minmax(0,1fr)_6px_var(--agent-thread-width)]'
style={
{
'--agent-thread-width': `${agentThreadWidth}px`,
} as CSSProperties
}
>
<aside className='border-border bg-background min-h-0 border-r'>
<div className='border-border border-b p-3'>
<h2 className='text-sm font-semibold'>Files</h2>
@@ -374,15 +454,34 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
onToggleDirectory={toggleDirectory}
/>
</aside>
<section className='bg-background flex min-w-0 flex-col'>
<Tabs defaultValue='editor' className='flex min-h-0 flex-1 flex-col'>
<TabsList
variant='line'
className='border-border h-11 flex-none justify-start rounded-none border-b px-3'
>
<TabsTrigger value='editor'>Editor</TabsTrigger>
<TabsTrigger value='diff'>Diff</TabsTrigger>
<TabsTrigger value='thread' className='2xl:hidden'>
<section className='bg-background flex min-w-0 flex-col overflow-hidden'>
<Tabs
value={activeWorkspaceTab}
onValueChange={(value) =>
setActiveWorkspaceTab(value as 'editor' | 'diff' | 'thread')
}
className='flex min-h-0 flex-1 flex-col'
>
<TabsList className='border-border bg-muted/30 h-12 flex-none justify-start rounded-none border-b px-3'>
<TabsTrigger
value='editor'
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
>
<FileCode className='size-4' />
Editor
</TabsTrigger>
<TabsTrigger
value='diff'
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
>
<GitCompare className='size-4' />
Diff viewer
</TabsTrigger>
<TabsTrigger
value='thread'
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm 2xl:hidden'
>
<MessagesSquare className='size-4' />
Thread
</TabsTrigger>
</TabsList>
@@ -427,32 +526,50 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
/>
</TabsContent>
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
<DiffViewer diff={diff} onRefresh={loadDiff} />
<DiffViewer
diff={diff}
focusedPath={focusedDiffPath}
onRefresh={loadDiff}
onClearFocusedPath={() => setFocusedDiffPath(undefined)}
/>
</TabsContent>
<TabsContent
value='thread'
className='m-0 min-h-0 flex-1 2xl:hidden'
className='m-0 min-h-0 flex-1 overflow-hidden 2xl:hidden'
>
<AgentThread
jobId={jobId}
messages={messages}
events={events}
interactions={interactions}
workspaceChanges={workspaceChanges}
disabled={workspaceDisabled}
agentTurnActive={agentTurnActive}
onOpenFile={openFileFromActivity}
onOpenDiff={openDiffFromActivity}
/>
</TabsContent>
</Tabs>
<CommandPanel jobId={jobId} disabled={workspaceDisabled} />
</section>
<aside className='border-border bg-muted/20 hidden min-w-0 border-l 2xl:block'>
<div
role='separator'
aria-label='Resize agent thread'
aria-orientation='vertical'
className='bg-border hover:bg-primary/50 hidden cursor-col-resize transition-colors 2xl:block'
onPointerDown={resizeAgentThread}
/>
<aside className='border-border bg-muted/20 hidden min-h-0 min-w-0 overflow-hidden border-l 2xl:block'>
<AgentThread
jobId={jobId}
messages={messages}
events={events}
interactions={interactions}
workspaceChanges={workspaceChanges}
disabled={workspaceDisabled}
agentTurnActive={agentTurnActive}
onOpenFile={openFileFromActivity}
onOpenDiff={openDiffFromActivity}
/>
</aside>
</div>
@@ -83,8 +83,11 @@ export const CodeEditor = ({
return (
<div className='flex h-full min-h-0 flex-col'>
<div className='border-border flex h-11 items-center justify-between gap-3 border-b px-3'>
<div className='border-border flex h-14 items-center justify-between gap-3 border-b px-3'>
<div className='min-w-0'>
<p className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase'>
Editor
</p>
<p className='truncate font-mono text-xs'>{path}</p>
{dirty ? (
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
@@ -0,0 +1,26 @@
export const extractFileDiff = (diff: string | undefined, filePath: string) => {
if (!diff?.trim() || filePath === '.') return '';
const lines = diff.split('\n');
const sections: string[][] = [];
let current: string[] | null = null;
for (const line of lines) {
if (line.startsWith('diff --git ')) {
if (current) sections.push(current);
current = [line];
continue;
}
current?.push(line);
}
if (current) sections.push(current);
const normalizedPath = filePath.replace(/^\.\/+/, '');
const section = sections.find((item) => {
const header = item[0] ?? '';
return (
header.includes(` a/${normalizedPath} `) ||
header.endsWith(` a/${normalizedPath}`) ||
header.includes(` b/${normalizedPath}`) ||
header.endsWith(` b/${normalizedPath}`)
);
});
return section?.join('\n') ?? '';
};
@@ -4,6 +4,8 @@ import dynamic from 'next/dynamic';
import { Button } from '@spoon/ui';
import { extractFileDiff } from './diff-utils';
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false,
});
@@ -22,34 +24,56 @@ const diffStats = (diff: string) => {
export const DiffViewer = ({
diff,
focusedPath,
onRefresh,
onClearFocusedPath,
}: {
diff: string;
focusedPath?: string;
onRefresh: () => Promise<void>;
onClearFocusedPath?: () => void;
}) => {
const stats = diffStats(diff);
const focusedDiff = focusedPath ? extractFileDiff(diff, focusedPath) : '';
const visibleDiff = focusedPath ? focusedDiff : diff;
const stats = diffStats(visibleDiff);
return (
<div className='flex h-full min-h-0 flex-col'>
<div className='border-border flex h-12 items-center justify-between gap-3 border-b px-3'>
<div className='min-w-0'>
<p className='text-sm font-medium'>Workspace diff</p>
<p className='truncate text-sm font-medium'>
{focusedPath ? `Diff viewer: ${focusedPath}` : 'Diff viewer'}
</p>
<p className='text-muted-foreground truncate text-xs'>
{diff.trim()
{visibleDiff.trim()
? `${stats.files} files, +${stats.additions} -${stats.removals}`
: 'Current git diff'}
: focusedPath
? 'No diff for this file'
: 'Current git diff'}
</p>
</div>
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
Refresh
</Button>
<div className='flex flex-none items-center gap-2'>
{focusedPath ? (
<Button
type='button'
variant='ghost'
size='sm'
onClick={onClearFocusedPath}
>
Show all
</Button>
) : null}
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
Refresh
</Button>
</div>
</div>
{diff.trim() ? (
{visibleDiff.trim() ? (
<MonacoEditor
height='100%'
width='100%'
language='diff'
theme='vs-dark'
value={diff}
value={visibleDiff}
options={{
readOnly: true,
minimap: { enabled: false },
@@ -61,7 +85,9 @@ export const DiffViewer = ({
/>
) : (
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
No workspace diff yet.
{focusedPath
? 'No diff is recorded for this file yet.'
: 'No workspace diff yet.'}
</div>
)}
</div>
@@ -23,6 +23,7 @@ export const WorkspaceActions = ({
}) => {
const router = useRouter();
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
const deleteThread = useMutation(api.threads.deleteThread);
const canDelete =
['failed', 'cancelled', 'timed_out'].includes(job.status) ||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
@@ -58,6 +59,27 @@ export const WorkspaceActions = ({
}
};
const removeThread = async () => {
if (!job.threadId) return;
if (
!window.confirm(
'Delete this thread and any terminal workspace records attached to it? This cannot be undone.',
)
) {
return;
}
try {
await deleteThread({ threadId: job.threadId });
toast.success('Thread deleted.');
router.push('/threads');
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Could not delete thread.',
);
}
};
const stop = async () => {
try {
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
@@ -96,10 +118,23 @@ export const WorkspaceActions = ({
Stop
</Button>
{canDelete ? (
<Button type='button' variant='destructive' size='sm' onClick={remove}>
<Trash2 className='size-4' />
Delete workspace
</Button>
<>
{job.threadId ? (
<Button
type='button'
variant='destructive'
size='sm'
onClick={removeThread}
>
<Trash2 className='size-4' />
Delete thread
</Button>
) : null}
<Button type='button' variant='outline' size='sm' onClick={remove}>
<Trash2 className='size-4' />
Delete workspace
</Button>
</>
) : null}
</div>
);
@@ -1,51 +0,0 @@
'use client';
import { Copy } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button } from '@spoon/ui';
export const AgentArtifactViewer = ({
artifacts,
}: {
artifacts: Doc<'agentJobArtifacts'>[];
}) => {
if (!artifacts.length) {
return (
<p className='text-muted-foreground text-sm'>
No artifacts captured yet.
</p>
);
}
return (
<div className='space-y-3'>
{artifacts.map((artifact) => (
<section key={artifact._id} className='border-border rounded-md border'>
<div className='flex items-center justify-between gap-3 border-b p-3'>
<div>
<h3 className='text-sm font-semibold'>{artifact.title}</h3>
<p className='text-muted-foreground text-xs'>{artifact.kind}</p>
</div>
<Button
type='button'
variant='outline'
size='icon'
aria-label='Copy artifact'
onClick={async () => {
await navigator.clipboard.writeText(artifact.content);
toast.success('Artifact copied.');
}}
>
<Copy className='size-4' />
</Button>
</div>
<pre className='bg-muted/40 max-h-96 overflow-auto p-3 text-xs whitespace-pre-wrap'>
{artifact.content}
</pre>
</section>
))}
</div>
);
};
@@ -1,47 +0,0 @@
'use client';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
const formatTime = (value: number) =>
new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(value);
export const AgentEventLog = ({
events,
}: {
events: Doc<'agentJobEvents'>[];
}) => {
if (!events.length) {
return (
<p className='text-muted-foreground text-sm'>No worker events yet.</p>
);
}
return (
<div className='divide-border overflow-hidden rounded-md border'>
{events.map((event) => (
<div key={event._id} className='grid gap-1 border-b p-3 text-sm'>
<div className='flex flex-wrap items-center gap-2'>
<span className='font-mono text-xs uppercase'>{event.phase}</span>
<span className='text-muted-foreground text-xs'>
{formatTime(event.createdAt)}
</span>
<span className='text-muted-foreground text-xs capitalize'>
{event.level}
</span>
</div>
<p className='whitespace-pre-wrap'>{event.message}</p>
{event.metadata ? (
<pre className='bg-muted overflow-auto rounded p-2 text-xs'>
{event.metadata}
</pre>
) : null}
</div>
))}
</div>
);
};
@@ -1,66 +0,0 @@
'use client';
import { useQuery } from 'convex/react';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
import { AgentArtifactViewer } from './agent-artifact-viewer';
import { AgentEventLog } from './agent-event-log';
export const AgentJobDetail = ({ job }: { job: Doc<'agentJobs'> }) => {
const events =
useQuery(api.agentJobs.listEvents, { jobId: job._id, limit: 200 }) ?? [];
const artifacts =
useQuery(api.agentJobs.listArtifacts, { jobId: job._id }) ?? [];
return (
<Card className='shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Job details</CardTitle>
</CardHeader>
<CardContent className='space-y-5'>
<div className='grid gap-3 text-sm md:grid-cols-3'>
<div>
<p className='text-muted-foreground text-xs'>Status</p>
<p className='font-medium capitalize'>
{job.status.replaceAll('_', ' ')}
</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>Branch</p>
<p className='font-mono text-xs'>{job.workBranch}</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>Model</p>
<p className='font-medium'>{job.model}</p>
</div>
</div>
{job.pullRequestUrl ? (
<a
href={job.pullRequestUrl}
target='_blank'
rel='noreferrer'
className='text-primary text-sm font-medium underline-offset-4 hover:underline'
>
Open draft PR #{job.pullRequestNumber}
</a>
) : null}
{job.error ? (
<pre className='border-destructive bg-destructive/5 text-destructive overflow-auto rounded-md border p-3 text-xs whitespace-pre-wrap'>
{job.error}
</pre>
) : null}
<section className='space-y-2'>
<h3 className='text-sm font-semibold'>Events</h3>
<AgentEventLog events={events} />
</section>
<section className='space-y-2'>
<h3 className='text-sm font-semibold'>Artifacts</h3>
<AgentArtifactViewer artifacts={artifacts} />
</section>
</CardContent>
</Card>
);
};
@@ -1,151 +0,0 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useMutation } from 'convex/react';
import { ExternalLink, MonitorUp, Trash2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Badge, Button } from '@spoon/ui';
import { AgentJobDetail } from './agent-job-detail';
const formatTime = (value: number) =>
new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(value);
export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
const cancel = useMutation(api.agentJobs.cancel);
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
const [selectedJobId, setSelectedJobId] = useState<string | null>(
jobs[0]?._id ?? null,
);
const selectedJob = jobs.find((job) => job._id === selectedJobId) ?? jobs[0];
const selectedJobCanDelete = selectedJob
? ['failed', 'cancelled', 'timed_out'].includes(selectedJob.status) ||
['stopped', 'expired', 'failed'].includes(
selectedJob.workspaceStatus ?? '',
)
: false;
if (!jobs.length) {
return (
<div className='border-border rounded-md border p-5'>
<h3 className='text-sm font-semibold'>No agent jobs yet</h3>
<p className='text-muted-foreground mt-1 text-sm'>
Queue a job to have Spoon open a draft PR against this fork.
</p>
</div>
);
}
return (
<div className='grid gap-4 xl:grid-cols-[0.85fr_1.15fr]'>
<div className='divide-border overflow-hidden rounded-md border'>
{jobs.map((job) => (
<button
key={job._id}
type='button'
className='hover:bg-muted/40 data-[selected=true]:bg-muted/60 block w-full border-b p-3 text-left'
data-selected={job._id === selectedJob?._id}
onClick={() => setSelectedJobId(job._id)}
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<p className='truncate text-sm font-medium'>{job.prompt}</p>
<p className='text-muted-foreground mt-1 font-mono text-xs'>
{job.workBranch}
</p>
</div>
<Badge variant='outline' className='capitalize'>
{job.status.replaceAll('_', ' ')}
</Badge>
</div>
<div className='text-muted-foreground mt-2 flex flex-wrap gap-2 text-xs'>
<span>{formatTime(job.createdAt)}</span>
{job.pullRequestUrl ? (
<a
href={job.pullRequestUrl}
target='_blank'
rel='noreferrer'
className='text-primary inline-flex items-center gap-1'
>
PR <ExternalLink className='size-3' />
</a>
) : null}
</div>
</button>
))}
</div>
{selectedJob ? (
<div className='space-y-3'>
{[
'queued',
'claimed',
'preparing',
'running',
'checks_running',
].includes(selectedJob.status) ? (
<Button
type='button'
variant='outline'
onClick={async () => {
try {
await cancel({ jobId: selectedJob._id });
toast.success('Agent job cancelled.');
} catch (error) {
console.error(error);
toast.error('Could not cancel job.');
}
}}
>
<XCircle className='size-4' />
Cancel job
</Button>
) : null}
<Button asChild>
<Link
href={`/spoons/${selectedJob.spoonId}/agent/${selectedJob._id}`}
>
<MonitorUp className='size-4' />
Open workspace
</Link>
</Button>
{selectedJobCanDelete ? (
<Button
type='button'
variant='destructive'
onClick={async () => {
if (
!window.confirm(
'Delete this workspace and its messages, events, artifacts, diffs, and UI state? This cannot be undone.',
)
) {
return;
}
try {
await deleteWorkspace({ jobId: selectedJob._id });
toast.success('Workspace deleted.');
setSelectedJobId(null);
} catch (error) {
console.error(error);
toast.error('Could not delete workspace.');
}
}}
>
<Trash2 className='size-4' />
Delete workspace
</Button>
) : null}
<AgentJobDetail job={selectedJob} />
</div>
) : null}
</div>
);
};
@@ -5,7 +5,9 @@ import { usePathname } from 'next/navigation';
export const AppShell = ({ children }: { children: ReactNode }) => {
const pathname = usePathname();
const isWorkspace = /\/spoons\/[^/]+\/agent\/[^/]+/.test(pathname);
const isWorkspace =
/\/spoons\/[^/]+\/agent\/[^/]+/.test(pathname) ||
/^\/threads\/[^/]+/.test(pathname);
return (
<div className='bg-muted/20 flex-1 border-t'>
@@ -176,7 +176,7 @@ export const SpoonAgentSettingsForm = ({
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between gap-4'>
<Label htmlFor='agentEnabled'>Enable agent jobs</Label>
<Label htmlFor='agentEnabled'>Enable thread workspaces</Label>
<Switch
id='agentEnabled'
checked={enabled}
@@ -1,8 +1,9 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useMutation, useQuery } from 'convex/react';
import { Bot } from 'lucide-react';
import { MessageSquarePlus } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -35,13 +36,14 @@ type AgentSettings = {
aiProviderProfileId?: Id<'aiProviderProfiles'>;
};
export const AgentRequestForm = ({
export const ThreadWorkspaceForm = ({
spoon,
agentSettings,
}: {
spoon: Doc<'spoons'>;
agentSettings?: AgentSettings | null;
}) => {
const router = useRouter();
const secrets =
useQuery(api.spoonSecrets.listForSpoon, {
spoonId: spoon._id,
@@ -90,7 +92,7 @@ export const AgentRequestForm = ({
event.preventDefault();
setSubmitting(true);
try {
await createThread({
const threadId = await createThread({
spoonId: spoon._id,
prompt,
baseBranch,
@@ -105,9 +107,10 @@ export const AgentRequestForm = ({
setPrompt('');
setRequestedBranchName('');
toast.success('Thread created.');
router.push(`/threads/${threadId}`);
} catch (error) {
console.error(error);
toast.error('Could not queue agent job.');
toast.error('Could not create thread workspace.');
} finally {
setSubmitting(false);
}
@@ -117,16 +120,16 @@ export const AgentRequestForm = ({
<Card className='shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='flex items-center gap-2 text-base'>
<Bot className='size-4' />
Request agent work
<MessageSquarePlus className='size-4' />
Create thread workspace
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={submit} className='space-y-4'>
<div className='grid gap-2'>
<Label htmlFor='agentPrompt'>Prompt</Label>
<Label htmlFor='threadPrompt'>Prompt</Label>
<Textarea
id='agentPrompt'
id='threadPrompt'
required
minLength={12}
value={prompt}