Add features & update project

This commit is contained in:
Gabriel Brown
2026-06-23 01:46:08 -04:00
parent 930fbf5965
commit fe72fc2957
39 changed files with 3106 additions and 178 deletions
+2 -1
View File
@@ -3,7 +3,7 @@
import type { ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Brain, Github, Shield, User } from 'lucide-react';
import { Brain, Github, ServerCog, Shield, User } from 'lucide-react';
import { cn } from '@spoon/ui';
@@ -11,6 +11,7 @@ const settingsItems = [
{ href: '/settings/profile', label: 'Profile', icon: User },
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
{ href: '/settings/security', label: 'Security', icon: Shield },
];
@@ -0,0 +1,15 @@
import { WorkerHealthPanel } from '@/components/settings/worker-health-panel';
const WorkerSettingsPage = () => (
<section className='max-w-5xl space-y-4'>
<div>
<h2 className='text-xl font-semibold'>Worker</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Monitor the agent worker and clean up old workspace state.
</p>
</div>
<WorkerHealthPanel />
</section>
);
export default WorkerSettingsPage;
@@ -0,0 +1,11 @@
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
export const POST = async (
_request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(
context,
async (jobId) =>
await proxyWorker(jobId, 'agent/abort', { method: 'POST' }),
);
@@ -0,0 +1,11 @@
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
export const GET = async (
_request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(
context,
async (jobId) =>
await proxyWorker(jobId, 'agent/status', { method: 'GET' }),
);
@@ -0,0 +1,23 @@
import {
proxyWorker,
requireOwnedJob,
routeJobId,
} from '@/lib/agent-worker-proxy';
export const POST = async (
request: Request,
context: { params: Promise<{ jobId: string; interactionId: string }> },
) => {
const params = await context.params;
const jobId = await routeJobId({ params });
const owned = await requireOwnedJob(jobId);
if (!owned.ok) return owned.response;
return await proxyWorker(
jobId,
`interactions/${encodeURIComponent(params.interactionId)}/reply`,
{
method: 'POST',
body: await request.text(),
},
);
};
@@ -0,0 +1,10 @@
import {
proxyWorkerRoot,
requireAuthenticatedUser,
} from '@/lib/agent-worker-proxy';
export const POST = async () => {
const authenticated = await requireAuthenticatedUser();
if (!authenticated.ok) return authenticated.response;
return await proxyWorkerRoot('/cleanup', { method: 'POST' });
};
@@ -0,0 +1,10 @@
import {
proxyWorkerRoot,
requireAuthenticatedUser,
} from '@/lib/agent-worker-proxy';
export const GET = async () => {
const authenticated = await requireAuthenticatedUser();
if (!authenticated.ok) return authenticated.response;
return await proxyWorkerRoot('/health', { method: 'GET' });
};
@@ -1,23 +1,28 @@
'use client';
import { useState } from 'react';
import { Send } from 'lucide-react';
import { Ban, Send } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button, Textarea } from '@spoon/ui';
import { Badge, Button, Textarea } from '@spoon/ui';
export const AgentThread = ({
jobId,
messages,
events,
interactions,
disabled,
}: {
jobId: string;
messages: Doc<'agentJobMessages'>[];
events: Doc<'agentJobEvents'>[];
interactions: Doc<'agentInteractionRequests'>[];
disabled: boolean;
}) => {
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
const [replying, setReplying] = useState<string>();
const send = async () => {
if (!content.trim()) return;
@@ -37,27 +42,141 @@ export const AgentThread = ({
}
};
const abort = async () => {
try {
const response = await fetch(`/api/agent-jobs/${jobId}/agent/abort`, {
method: 'POST',
});
if (!response.ok) throw new Error(await response.text());
toast.success('Agent turn aborted.');
} catch (error) {
console.error(error);
toast.error('Could not abort agent.');
}
};
const reply = async (
interaction: Doc<'agentInteractionRequests'>,
responseValue: string,
) => {
setReplying(interaction._id);
try {
const response = await fetch(
`/api/agent-jobs/${jobId}/interactions/${interaction._id}/reply`,
{
method: 'POST',
body: JSON.stringify({
externalRequestId: interaction.externalRequestId,
response: responseValue,
}),
},
);
if (!response.ok) throw new Error(await response.text());
toast.success('Response sent.');
} catch (error) {
console.error(error);
toast.error('Could not answer interaction.');
} finally {
setReplying(undefined);
}
};
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 className='border-border flex items-start justify-between gap-3 border-b p-3'>
<div>
<h2 className='text-sm font-semibold'>Agent thread</h2>
<p className='text-muted-foreground text-xs'>
Messages, tool activity, and requests persist with this workspace.
</p>
</div>
<Button
type='button'
variant='outline'
size='sm'
disabled={disabled}
onClick={abort}
>
<Ban className='size-3' />
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='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>
))}
{messages.map((message) => (
<article
key={message._id}
className='border-border bg-background rounded-md border p-3 text-sm'
className={
message.role === 'user'
? 'border-border bg-muted ml-6 rounded-md border p-3 text-sm'
: message.status === 'failed'
? 'border-destructive/40 bg-destructive/5 rounded-md border p-3 text-sm'
: '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'>
<Badge
variant={
message.status === 'failed' ? 'destructive' : 'outline'
}
className='capitalize'
>
{message.status}
</span>
</Badge>
</div>
<p className='whitespace-pre-wrap'>{message.content}</p>
<p className='whitespace-pre-wrap'>
{message.content ||
(message.status === 'streaming' ? 'Working...' : '')}
</p>
</article>
))}
{events.slice(-20).map((event) => (
<article
key={event._id}
className='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='font-medium capitalize'>
{event.phase} / {event.level}
</span>
<span>{new Date(event.createdAt).toLocaleTimeString()}</span>
</div>
<p className='mt-1 whitespace-pre-wrap'>{event.message}</p>
</article>
))}
</div>
@@ -1,7 +1,7 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useQuery } from 'convex/react';
import { useMutation, useQuery } from 'convex/react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -13,17 +13,42 @@ import { AgentThread } from './agent-thread';
import { CodeEditor } from './code-editor';
import { CommandPanel } from './command-panel';
import { DiffViewer } from './diff-viewer';
import { FileTabs } from './file-tabs';
import { FileTree } from './file-tree';
import { JobStatusBar } from './job-status-bar';
import { WorkspaceActions } from './workspace-actions';
type OpenFileState = {
path: string;
content: string;
savedContent: string;
loading: boolean;
saving: boolean;
error?: string;
};
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const job = useQuery(api.agentJobs.get, { jobId });
const messages =
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
const events =
useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
const interactions =
useQuery(api.agentJobs.listInteractionRequests, {
jobId,
status: 'all',
}) ?? [];
const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId });
const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
const [tree, setTree] = useState<FileTreeNode | null>(null);
const [selectedPath, setSelectedPath] = useState<string>();
const [fileContent, setFileContent] = useState('');
const [files, setFiles] = useState<Record<string, OpenFileState>>({});
const [openFilePaths, setOpenFilePaths] = useState<string[]>([]);
const [activeFilePath, setActiveFilePath] = useState<string>();
const [expandedDirectoryPaths, setExpandedDirectoryPaths] = useState<
string[]
>([]);
const [vimEnabled, setVimEnabled] = useState(false);
const [hydratedUiState, setHydratedUiState] = useState(false);
const [diff, setDiff] = useState('');
const workspaceDisabled =
@@ -49,17 +74,59 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const loadFile = useCallback(
async (path: string) => {
setFiles((current) => ({
...current,
[path]: current[path] ?? {
path,
content: '',
savedContent: '',
loading: true,
saving: false,
},
}));
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);
setFiles((current) => ({
...current,
[data.path]: {
path: data.path,
content: data.content,
savedContent: data.content,
loading: false,
saving: false,
},
}));
},
[jobId],
);
const openFile = useCallback(
(path: string) => {
setOpenFilePaths((current) =>
current.includes(path) ? current : [...current, path],
);
setActiveFilePath(path);
if (!files[path]) {
void loadFile(path).catch((error) => {
console.error(error);
setFiles((current) => {
const next = { ...current };
delete next[path];
return next;
});
setOpenFilePaths((current) =>
current.filter((filePath) => filePath !== path),
);
toast.error('Could not load file.');
});
}
},
[files, loadFile],
);
useEffect(() => {
if (!job) return;
const timeout = window.setTimeout(() => {
@@ -73,27 +140,143 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
return () => window.clearTimeout(timeout);
}, [job, loadDiff, loadTree]);
useEffect(() => {
if (!uiState || hydratedUiState) return;
const timeout = window.setTimeout(() => {
setOpenFilePaths(uiState.openFilePaths);
setActiveFilePath(uiState.activeFilePath);
setExpandedDirectoryPaths(uiState.expandedDirectoryPaths);
setVimEnabled(uiState.vimEnabled);
setHydratedUiState(true);
}, 0);
return () => window.clearTimeout(timeout);
}, [hydratedUiState, uiState]);
useEffect(() => {
if (!hydratedUiState) return;
const timeout = window.setTimeout(() => {
void patchUiState({
jobId,
openFilePaths,
activeFilePath,
vimEnabled,
expandedDirectoryPaths,
}).catch((error: unknown) => {
console.error(error);
});
}, 400);
return () => window.clearTimeout(timeout);
}, [
activeFilePath,
expandedDirectoryPaths,
hydratedUiState,
jobId,
openFilePaths,
patchUiState,
vimEnabled,
]);
useEffect(() => {
if (!hydratedUiState) return;
const timeout = window.setTimeout(() => {
for (const path of openFilePaths) {
if (!files[path]) {
void loadFile(path).catch((error) => {
console.error(error);
});
}
}
}, 0);
return () => window.clearTimeout(timeout);
}, [files, hydratedUiState, loadFile, openFilePaths]);
if (job === undefined) {
return (
<main className='text-muted-foreground p-6'>Loading workspace...</main>
);
}
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
const saveFile = async (content: string) => {
if (!selectedPath) return;
if (!activeFilePath) return;
setFiles((current) => ({
...current,
[activeFilePath]: {
...(current[activeFilePath] ?? {
path: activeFilePath,
savedContent: '',
loading: false,
}),
content,
saving: true,
},
}));
const response = await fetch(`/api/agent-jobs/${jobId}/file`, {
method: 'PUT',
body: JSON.stringify({ path: selectedPath, content }),
body: JSON.stringify({ path: activeFilePath, content }),
});
if (!response.ok) {
toast.error('Could not save file.');
setFiles((current) => ({
...current,
[activeFilePath]: {
...(current[activeFilePath] ?? {
path: activeFilePath,
content,
savedContent: '',
loading: false,
}),
saving: false,
},
}));
throw new Error(await response.text());
}
setFileContent(content);
setFiles((current) => ({
...current,
[activeFilePath]: {
...(current[activeFilePath] ?? {
path: activeFilePath,
loading: false,
}),
content,
savedContent: content,
saving: false,
},
}));
await loadDiff();
toast.success('File saved.');
};
const closeFile = (path: string) => {
const file = files[path];
if (file && file.content !== file.savedContent) {
const confirmed = window.confirm(
`Close ${path} and discard unsaved changes?`,
);
if (!confirmed) return;
}
const index = openFilePaths.indexOf(path);
const nextOpen = openFilePaths.filter((filePath) => filePath !== path);
setOpenFilePaths(nextOpen);
setFiles((current) => {
const next = { ...current };
delete next[path];
return next;
});
if (activeFilePath === path) {
setActiveFilePath(nextOpen[index - 1] ?? nextOpen[index] ?? undefined);
}
};
const toggleDirectory = (path: string) => {
setExpandedDirectoryPaths((current) =>
current.includes(path)
? current.filter((directoryPath) => directoryPath !== path)
: [...current, path],
);
};
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} />
@@ -108,13 +291,10 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
</div>
<FileTree
tree={tree}
selectedPath={selectedPath}
onSelect={(path) => {
void loadFile(path).catch((error) => {
console.error(error);
toast.error('Could not load file.');
});
}}
selectedPath={activeFilePath}
expandedPaths={expandedDirectoryPaths}
onSelect={openFile}
onToggleDirectory={toggleDirectory}
/>
</aside>
<section className='bg-background flex min-w-0 flex-col'>
@@ -129,12 +309,44 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
Thread
</TabsTrigger>
</TabsList>
<TabsContent value='editor' className='m-0 min-h-0 flex-1'>
<TabsContent
value='editor'
className='m-0 flex min-h-0 flex-1 flex-col'
>
<FileTabs
tabs={openFilePaths.map((path) => ({
path,
dirty: files[path]
? files[path].content !== files[path].savedContent
: false,
}))}
activePath={activeFilePath}
onActivate={setActiveFilePath}
onClose={closeFile}
/>
<CodeEditor
path={selectedPath}
content={fileContent}
path={activeFilePath}
content={activeFile?.content ?? ''}
savedContent={activeFile?.savedContent ?? ''}
readOnly={workspaceDisabled}
vimEnabled={vimEnabled}
onSave={saveFile}
onVimEnabledChange={setVimEnabled}
onChange={(content) => {
if (!activeFilePath) return;
setFiles((current) => ({
...current,
[activeFilePath]: {
...(current[activeFilePath] ?? {
path: activeFilePath,
savedContent: '',
loading: false,
saving: false,
}),
content,
},
}));
}}
/>
</TabsContent>
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
@@ -147,6 +359,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
<AgentThread
jobId={jobId}
messages={messages}
events={events}
interactions={interactions}
disabled={workspaceDisabled}
/>
</TabsContent>
@@ -157,6 +371,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
<AgentThread
jobId={jobId}
messages={messages}
events={events}
interactions={interactions}
disabled={workspaceDisabled}
/>
</aside>
@@ -5,6 +5,8 @@ import dynamic from 'next/dynamic';
import { Button, Switch } from '@spoon/ui';
import { languageForPath } from './languages';
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false,
});
@@ -20,27 +22,27 @@ type VimMode = {
export const CodeEditor = ({
path,
content,
savedContent,
readOnly,
vimEnabled,
onSave,
onChange,
onVimEnabledChange,
}: {
path?: string;
content: string;
savedContent: string;
readOnly: boolean;
vimEnabled: boolean;
onSave: (content: string) => Promise<void>;
onChange: (content: string) => void;
onVimEnabledChange: (enabled: boolean) => 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;
@@ -71,13 +73,14 @@ export const CodeEditor = ({
const save = async () => {
setSaving(true);
try {
await onSave(value);
setDirty(false);
await onSave(content);
} finally {
setSaving(false);
}
};
const dirty = content !== savedContent;
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'>
@@ -90,7 +93,7 @@ export const CodeEditor = ({
<div className='flex items-center gap-3'>
<label className='flex items-center gap-2 text-xs'>
Vim
<Switch checked={vimEnabled} onCheckedChange={setVimEnabled} />
<Switch checked={vimEnabled} onCheckedChange={onVimEnabledChange} />
</label>
<Button
type='button'
@@ -107,7 +110,8 @@ export const CodeEditor = ({
height='100%'
width='100%'
path={path}
value={value}
language={languageForPath(path)}
value={content}
theme='vs-dark'
options={{
readOnly,
@@ -116,13 +120,20 @@ export const CodeEditor = ({
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
scrollbar: { alwaysConsumeMouseWheel: false },
quickSuggestions: true,
suggestOnTriggerCharacters: true,
tabCompletion: 'on',
wordBasedSuggestions: 'matchingDocuments',
bracketPairColorization: { enabled: true },
renderWhitespace: 'selection',
}}
onMount={(editor) => {
editorRef.current = editor as MonacoEditorInstance;
}}
onChange={(next) => {
setValue(next ?? '');
setDirty((next ?? '') !== content);
const nextValue = next ?? '';
onChange(nextValue);
}}
/>
</div>
@@ -8,42 +8,62 @@ const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false,
});
const diffStats = (diff: string) => {
const files = new Set<string>();
let additions = 0;
let removals = 0;
for (const line of diff.split('\n')) {
if (line.startsWith('diff --git ')) files.add(line);
if (line.startsWith('+') && !line.startsWith('+++')) additions += 1;
if (line.startsWith('-') && !line.startsWith('---')) removals += 1;
}
return { files: files.size, additions, removals };
};
export const DiffViewer = ({
diff,
onRefresh,
}: {
diff: string;
onRefresh: () => Promise<void>;
}) => (
<div className='flex h-full min-h-0 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>
}) => {
const stats = diffStats(diff);
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='text-muted-foreground truncate text-xs'>
{diff.trim()
? `${stats.files} files, +${stats.additions} -${stats.removals}`
: 'Current git diff'}
</p>
</div>
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
Refresh
</Button>
</div>
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
Refresh
</Button>
{diff.trim() ? (
<MonacoEditor
height='100%'
width='100%'
language='diff'
theme='vs-dark'
value={diff}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
automaticLayout: true,
scrollbar: { alwaysConsumeMouseWheel: false },
}}
/>
) : (
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
No workspace diff yet.
</div>
)}
</div>
{diff.trim() ? (
<MonacoEditor
height='100%'
width='100%'
language='diff'
theme='vs-dark'
value={diff}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
automaticLayout: true,
}}
/>
) : (
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
No workspace diff yet.
</div>
)}
</div>
);
);
};
@@ -0,0 +1,65 @@
'use client';
import { Circle, X } from 'lucide-react';
import { Button } from '@spoon/ui';
import { basename } from './languages';
export type OpenFileTab = {
path: string;
dirty: boolean;
};
export const FileTabs = ({
tabs,
activePath,
onActivate,
onClose,
}: {
tabs: OpenFileTab[];
activePath?: string;
onActivate: (path: string) => void;
onClose: (path: string) => void;
}) => {
if (tabs.length === 0) return null;
return (
<div className='border-border bg-muted/30 flex h-10 flex-none items-stretch overflow-x-auto border-b'>
{tabs.map((tab) => {
const active = tab.path === activePath;
return (
<div
key={tab.path}
className={
active
? 'border-primary bg-background flex max-w-56 min-w-0 items-center border-t-2 border-r'
: 'border-border flex max-w-56 min-w-0 items-center border-r'
}
title={tab.path}
>
<button
type='button'
className='flex h-full min-w-0 flex-1 items-center gap-2 px-3 text-left text-xs'
onClick={() => onActivate(tab.path)}
>
{tab.dirty ? (
<Circle className='fill-primary text-primary size-2 flex-none' />
) : null}
<span className='truncate font-mono'>{basename(tab.path)}</span>
</button>
<Button
type='button'
variant='ghost'
size='icon'
className='mr-1 size-6 flex-none'
aria-label={`Close ${tab.path}`}
onClick={() => onClose(tab.path)}
>
<X className='size-3' />
</Button>
</div>
);
})}
</div>
);
};
@@ -1,6 +1,12 @@
'use client';
import { ChevronRight, FileCode, Folder } from 'lucide-react';
import {
ChevronDown,
ChevronRight,
FileCode,
Folder,
FolderOpen,
} from 'lucide-react';
import { Button } from '@spoon/ui';
@@ -9,38 +15,59 @@ import type { FileTreeNode } from './types';
const TreeNode = ({
node,
selectedPath,
expandedPaths,
onSelect,
onToggle,
depth = 0,
}: {
node: FileTreeNode;
selectedPath?: string;
expandedPaths: Set<string>;
onSelect: (path: string) => void;
onToggle: (path: string) => void;
depth?: number;
}) => {
if (node.type === 'directory') {
const isRoot = !node.path;
const expanded = isRoot || expandedPaths.has(node.path);
return (
<div>
{node.path ? (
<div
className='text-muted-foreground flex h-7 items-center gap-1 px-2 text-xs font-medium'
{!isRoot ? (
<button
type='button'
aria-expanded={expanded}
className='text-muted-foreground hover:bg-muted flex h-7 w-full items-center gap-1 px-2 text-left text-xs font-medium'
style={{ paddingLeft: depth * 12 + 8 }}
onClick={() => onToggle(node.path)}
>
<ChevronRight className='size-3' />
<Folder className='size-3' />
{expanded ? (
<ChevronDown className='size-3 flex-none' />
) : (
<ChevronRight className='size-3 flex-none' />
)}
{expanded ? (
<FolderOpen className='size-3 flex-none' />
) : (
<Folder className='size-3 flex-none' />
)}
<span className='truncate'>{node.name}</span>
</button>
) : null}
{expanded ? (
<div>
{node.children?.map((child) => (
<TreeNode
key={`${child.type}:${child.path}`}
node={child}
selectedPath={selectedPath}
expandedPaths={expandedPaths}
onSelect={onSelect}
onToggle={onToggle}
depth={node.path ? depth + 1 : depth}
/>
))}
</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>
);
}
@@ -62,11 +89,15 @@ const TreeNode = ({
export const FileTree = ({
tree,
selectedPath,
expandedPaths,
onSelect,
onToggleDirectory,
}: {
tree: FileTreeNode | null;
selectedPath?: string;
expandedPaths: string[];
onSelect: (path: string) => void;
onToggleDirectory: (path: string) => void;
}) => {
if (!tree) {
return (
@@ -76,8 +107,14 @@ export const FileTree = ({
);
}
return (
<div className='overflow-auto py-2'>
<TreeNode node={tree} selectedPath={selectedPath} onSelect={onSelect} />
<div className='h-full overflow-auto py-2'>
<TreeNode
node={tree}
selectedPath={selectedPath}
expandedPaths={new Set(expandedPaths)}
onSelect={onSelect}
onToggle={onToggleDirectory}
/>
</div>
);
};
@@ -0,0 +1,27 @@
export const languageForPath = (path?: string) => {
if (!path) return undefined;
const name = path.toLowerCase().split('/').at(-1) ?? path.toLowerCase();
if (name === '.env' || name.startsWith('.env.')) return 'plaintext';
if (name.endsWith('.tsx') || name.endsWith('.ts')) return 'typescript';
if (
name.endsWith('.jsx') ||
name.endsWith('.js') ||
name.endsWith('.mjs') ||
name.endsWith('.cjs')
) {
return 'javascript';
}
if (name.endsWith('.json')) return 'json';
if (name.endsWith('.css')) return 'css';
if (name.endsWith('.scss')) return 'scss';
if (name.endsWith('.html')) return 'html';
if (name.endsWith('.md') || name.endsWith('.mdx')) return 'markdown';
if (name.endsWith('.yml') || name.endsWith('.yaml')) return 'yaml';
if (name.endsWith('.sh') || name.endsWith('.bash')) return 'shell';
if (name.endsWith('.py')) return 'python';
if (name.endsWith('.rs')) return 'rust';
if (name.endsWith('.go')) return 'go';
return undefined;
};
export const basename = (path: string) => path.split('/').at(-1) ?? path;
@@ -1,9 +1,17 @@
'use client';
import { ExternalLink, GitPullRequestDraft, Square } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useMutation } from 'convex/react';
import {
ExternalLink,
GitPullRequestDraft,
Square,
Trash2,
} 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 { Button } from '@spoon/ui';
export const WorkspaceActions = ({
@@ -13,6 +21,12 @@ export const WorkspaceActions = ({
job: Doc<'agentJobs'>;
disabled: boolean;
}) => {
const router = useRouter();
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
const canDelete =
['failed', 'cancelled', 'timed_out'].includes(job.status) ||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
const openPr = async () => {
try {
const response = await fetch(`/api/agent-jobs/${job._id}/open-pr`, {
@@ -26,6 +40,24 @@ export const WorkspaceActions = ({
}
};
const remove = 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: job._id });
toast.success('Workspace deleted.');
router.push(`/spoons/${job.spoonId}`);
} catch (error) {
console.error(error);
toast.error('Could not delete workspace.');
}
};
const stop = async () => {
try {
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
@@ -63,6 +95,12 @@ export const WorkspaceActions = ({
<Square className='size-4' />
Stop
</Button>
{canDelete ? (
<Button type='button' variant='destructive' size='sm' onClick={remove}>
<Trash2 className='size-4' />
Delete workspace
</Button>
) : null}
</div>
);
};
@@ -3,7 +3,7 @@
import { useState } from 'react';
import Link from 'next/link';
import { useMutation } from 'convex/react';
import { ExternalLink, MonitorUp, XCircle } from 'lucide-react';
import { ExternalLink, MonitorUp, Trash2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -22,10 +22,17 @@ const formatTime = (value: number) =>
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 (
@@ -110,6 +117,32 @@ export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
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}
@@ -0,0 +1,258 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { RefreshCw, Trash2, Wrench } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
} from '@spoon/ui';
type WorkerHealth = {
ok: boolean;
workerId: string;
convexUrl: string;
runtime: string;
containerRuntime: string;
containerAccess: string;
jobImage: string;
workdir: string;
network?: string;
httpPort: number;
activeWorkspaceCount: number;
workspaceContainers: string[];
};
type CleanupResult = {
removedContainers: string[];
removedWorkdirs: string[];
};
export const WorkerHealthPanel = () => {
const [health, setHealth] = useState<WorkerHealth | null>(null);
const [healthError, setHealthError] = useState<string>();
const [loadingHealth, setLoadingHealth] = useState(false);
const [cleaning, setCleaning] = useState(false);
const [deleting, setDeleting] = useState(false);
const [olderThanDays, setOlderThanDays] = useState(7);
const deletableCount =
useQuery(api.agentJobs.countOldWorkspaces, { olderThanDays }) ?? 0;
const deleteOldWorkspaces = useMutation(api.agentJobs.deleteOldWorkspaces);
const refreshHealth = async () => {
setLoadingHealth(true);
setHealthError(undefined);
try {
const response = await fetch('/api/agent-worker/health');
if (!response.ok) throw new Error(await response.text());
setHealth((await response.json()) as WorkerHealth);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setHealthError(message);
setHealth(null);
} finally {
setLoadingHealth(false);
}
};
useEffect(() => {
void refreshHealth();
}, []);
const cleanupOrphans = async () => {
setCleaning(true);
try {
const response = await fetch('/api/agent-worker/cleanup', {
method: 'POST',
});
if (!response.ok) throw new Error(await response.text());
const result = (await response.json()) as CleanupResult;
toast.success(
`Cleaned ${result.removedContainers.length} containers and ${result.removedWorkdirs.length} workdirs.`,
);
await refreshHealth();
} catch (error) {
console.error(error);
toast.error('Could not clean worker resources.');
} finally {
setCleaning(false);
}
};
const deleteOld = async () => {
if (
!window.confirm(
`Delete up to 100 stopped, cancelled, failed, or expired workspaces older than ${olderThanDays} days?`,
)
) {
return;
}
setDeleting(true);
try {
const result = await deleteOldWorkspaces({
olderThanDays,
limit: 100,
});
toast.success(`Deleted ${result.deleted} workspaces.`);
} catch (error) {
console.error(error);
toast.error('Could not delete old workspaces.');
} finally {
setDeleting(false);
}
};
return (
<div className='space-y-4'>
<Card className='shadow-none'>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle>Worker health</CardTitle>
<p className='text-muted-foreground mt-1 text-sm'>
Runtime status for the server-side agent worker.
</p>
</div>
<Button
type='button'
variant='outline'
size='sm'
disabled={loadingHealth}
onClick={() => void refreshHealth()}
>
<RefreshCw className='size-4' />
Refresh
</Button>
</CardHeader>
<CardContent className='space-y-4'>
{healthError ? (
<div className='border-destructive/40 bg-destructive/10 text-destructive rounded-md border p-3 text-sm'>
{healthError}
</div>
) : null}
{health ? (
<>
<div className='flex flex-wrap gap-2'>
<Badge variant={health.ok ? 'secondary' : 'destructive'}>
{health.ok ? 'healthy' : 'unhealthy'}
</Badge>
<Badge variant='outline'>{health.workerId}</Badge>
<Badge variant='outline'>
{health.containerRuntime} / {health.containerAccess}
</Badge>
</div>
<dl className='grid gap-3 text-sm md:grid-cols-2'>
<div>
<dt className='text-muted-foreground'>Convex</dt>
<dd className='font-mono break-all'>{health.convexUrl}</dd>
</div>
<div>
<dt className='text-muted-foreground'>Job image</dt>
<dd className='font-mono break-all'>{health.jobImage}</dd>
</div>
<div>
<dt className='text-muted-foreground'>Workdir</dt>
<dd className='font-mono break-all'>{health.workdir}</dd>
</div>
<div>
<dt className='text-muted-foreground'>Network</dt>
<dd className='font-mono break-all'>
{health.network ?? 'none'}
</dd>
</div>
<div>
<dt className='text-muted-foreground'>HTTP port</dt>
<dd>{health.httpPort}</dd>
</div>
<div>
<dt className='text-muted-foreground'>Active workspaces</dt>
<dd>{health.activeWorkspaceCount}</dd>
</div>
</dl>
<div>
<p className='text-muted-foreground text-sm'>
Workspace containers
</p>
<p className='mt-1 font-mono text-sm'>
{health.workspaceContainers.length
? health.workspaceContainers.join(', ')
: 'none'}
</p>
</div>
</>
) : !healthError ? (
<p className='text-muted-foreground text-sm'>
{loadingHealth ? 'Checking worker...' : 'No worker response yet.'}
</p>
) : null}
</CardContent>
</Card>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Cleanup</CardTitle>
<p className='text-muted-foreground mt-1 text-sm'>
Remove stopped workspace records and orphaned local worker
resources.
</p>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid gap-3 md:grid-cols-[12rem_1fr_auto] md:items-end'>
<label className='space-y-1'>
<span className='text-sm font-medium'>Older than days</span>
<Input
type='number'
min={0}
value={olderThanDays}
onChange={(event) =>
setOlderThanDays(
Math.max(Number.parseInt(event.target.value, 10) || 0, 0),
)
}
/>
</label>
<p className='text-muted-foreground text-sm'>
{deletableCount} stopped, cancelled, failed, timed out, or expired
workspaces match this age filter.
</p>
<Button
type='button'
variant='destructive'
disabled={deleting || deletableCount === 0}
onClick={() => void deleteOld()}
>
<Trash2 className='size-4' />
Delete old
</Button>
</div>
<div className='border-border flex flex-col justify-between gap-3 rounded-md border p-3 md:flex-row md:items-center'>
<div>
<p className='text-sm font-medium'>Orphaned worker resources</p>
<p className='text-muted-foreground text-sm'>
Remove inactive Spoon job containers and inactive directories
under the configured worker workdir.
</p>
</div>
<Button
type='button'
variant='outline'
disabled={cleaning}
onClick={() => void cleanupOrphans()}
>
<Wrench className='size-4' />
Clean orphans
</Button>
</div>
</CardContent>
</Card>
</div>
);
};
+39
View File
@@ -32,6 +32,45 @@ export const requireOwnedJob = async (jobId: Id<'agentJobs'>) => {
return { ok: true as const };
};
export const requireAuthenticatedUser = async () => {
const token = await convexAuthNextjsToken();
if (!token) {
return {
ok: false as const,
response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
};
}
await fetchQuery(api.auth.getUser, {}, { token });
return { ok: true as const };
};
export const proxyWorkerRoot = async (path: string, init?: RequestInit) => {
const token = workerToken();
if (!token) {
return NextResponse.json(
{ error: 'SPOON_AGENT_WORKER_INTERNAL_TOKEN is not configured.' },
{ status: 500 },
);
}
const url = new URL(path, env.SPOON_AGENT_WORKER_URL);
const response = await fetch(url, {
...init,
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
...init?.headers,
},
});
const text = await response.text();
return new NextResponse(text, {
status: response.status,
headers: {
'content-type':
response.headers.get('content-type') ?? 'application/json',
},
});
};
export const proxyWorker = async (
jobId: Id<'agentJobs'>,
action: string,
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import {
basename,
languageForPath,
} from '../../src/components/agent-workspace/languages';
describe('workspace language helpers', () => {
it('maps common code file extensions to Monaco languages', () => {
expect(languageForPath('src/app.ts')).toBe('typescript');
expect(languageForPath('src/app.tsx')).toBe('typescript');
expect(languageForPath('src/app.js')).toBe('javascript');
expect(languageForPath('package.json')).toBe('json');
expect(languageForPath('README.md')).toBe('markdown');
expect(languageForPath('.env.local')).toBe('plaintext');
});
it('lets Monaco fall back for unknown paths', () => {
expect(languageForPath('Gemfile')).toBeUndefined();
expect(languageForPath()).toBeUndefined();
});
it('returns a useful basename for file tabs', () => {
expect(basename('src/components/button.tsx')).toBe('button.tsx');
expect(basename('README.md')).toBe('README.md');
});
});