allow users to delete threads from spoons details page
Build and Push Spoon Images / quality (push) Successful in 2m36s
Build and Push Spoon Images / build-images (push) Successful in 9m21s

This commit is contained in:
Gabriel Brown
2026-06-23 16:00:34 -04:00
parent a6f7ea7f78
commit 5567a4be95
8 changed files with 493 additions and 221 deletions
@@ -11,6 +11,7 @@ import { SpoonMetrics } from '@/components/spoons/spoon-metrics';
import { SpoonPrList } from '@/components/spoons/spoon-pr-list'; import { SpoonPrList } from '@/components/spoons/spoon-pr-list';
import { SpoonSecretsForm } from '@/components/spoons/spoon-secrets-form'; import { SpoonSecretsForm } from '@/components/spoons/spoon-secrets-form';
import { SpoonSettingsForm } from '@/components/spoons/spoon-settings-form'; import { SpoonSettingsForm } from '@/components/spoons/spoon-settings-form';
import { DeleteThreadButton } from '@/components/threads/delete-thread-button';
import { ThreadWorkspaceForm } from '@/components/threads/thread-workspace-form'; import { ThreadWorkspaceForm } from '@/components/threads/thread-workspace-form';
import { useQuery } from 'convex/react'; import { useQuery } from 'convex/react';
@@ -54,6 +55,17 @@ const SpoonDetailPage = () => {
}); });
const agentJobs = const agentJobs =
useQuery(api.agentJobs.listForSpoon, { spoonId, limit: 25 }) ?? []; useQuery(api.agentJobs.listForSpoon, { spoonId, limit: 25 }) ?? [];
const canDeleteThread = (thread: (typeof threads)[number]) => {
const latestJobStatus = thread.latestJobStatus;
const latestWorkspaceStatus = thread.latestJobWorkspaceStatus;
if (!latestJobStatus && !latestWorkspaceStatus) return true;
return (
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
latestJobStatus ?? '',
) ||
['stopped', 'expired', 'failed'].includes(latestWorkspaceStatus ?? '')
);
};
if (details === undefined) { if (details === undefined) {
return <main className='text-muted-foreground p-6'>Loading Spoon...</main>; return <main className='text-muted-foreground p-6'>Loading Spoon...</main>;
@@ -253,17 +265,29 @@ const SpoonDetailPage = () => {
<CardContent className='space-y-3'> <CardContent className='space-y-3'>
{threads.length ? ( {threads.length ? (
threads.map((thread) => ( threads.map((thread) => (
<Link <div
key={thread._id} key={thread._id}
href={`/threads/${thread._id}`} className='border-border hover:border-primary/50 grid gap-3 rounded-md border p-3 transition-colors md:grid-cols-[1fr_auto] md:items-center'
className='border-border hover:border-primary/50 block rounded-md border p-3 transition-colors'
> >
<p className='font-medium'>{thread.title}</p> <Link href={`/threads/${thread._id}`} className='min-w-0'>
<p className='text-muted-foreground mt-1 text-sm'> <p className='truncate font-medium'>{thread.title}</p>
{thread.status.replaceAll('_', ' ')} ·{' '} <p className='text-muted-foreground mt-1 text-sm'>
{thread.source.replaceAll('_', ' ')} {thread.status.replaceAll('_', ' ')} ·{' '}
</p> {thread.source.replaceAll('_', ' ')}
</Link> {thread.latestJobWorkspaceStatus
? ` · workspace ${thread.latestJobWorkspaceStatus.replaceAll('_', ' ')}`
: ''}
</p>
</Link>
<div className='flex justify-start md:justify-end'>
<DeleteThreadButton
threadId={thread._id}
disabled={!canDeleteThread(thread)}
label='Delete'
variant='outline'
/>
</div>
</div>
)) ))
) : ( ) : (
<p className='text-muted-foreground text-sm'> <p className='text-muted-foreground text-sm'>
@@ -4,19 +4,23 @@ import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell'; import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell';
import { DeleteThreadButton } from '@/components/threads/delete-thread-button';
import { useMutation, useQuery } from 'convex/react'; import { useMutation, useQuery } from 'convex/react';
import { import { ArrowUpRight, CheckCircle2, Play, XCircle } from 'lucide-react';
ArrowUpRight,
CheckCircle2,
Play,
Trash2,
XCircle,
} from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js'; import { api } from '@spoon/backend/convex/_generated/api.js';
import { import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Badge, Badge,
Button, Button,
Card, Card,
@@ -33,9 +37,7 @@ const ThreadDetailPage = () => {
const createJob = useMutation(api.agentJobs.createForThread); const createJob = useMutation(api.agentJobs.createForThread);
const markResolved = useMutation(api.threads.markResolved); const markResolved = useMutation(api.threads.markResolved);
const cancel = useMutation(api.threads.cancel); const cancel = useMutation(api.threads.cancel);
const deleteThread = useMutation(api.threads.deleteThread);
const [queueing, setQueueing] = useState(false); const [queueing, setQueueing] = useState(false);
const [deleting, setDeleting] = useState(false);
if (details === undefined) { if (details === undefined) {
return <main className='text-muted-foreground p-6'>Loading thread...</main>; return <main className='text-muted-foreground p-6'>Loading thread...</main>;
@@ -92,29 +94,6 @@ const ThreadDetailPage = () => {
} }
}; };
const removeThread = async () => {
if (
!window.confirm(
'Delete this thread and any terminal workspace records attached to it? This cannot be undone.',
)
) {
return;
}
setDeleting(true);
try {
await deleteThread({ threadId });
toast.success('Thread deleted.');
router.push('/threads');
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Could not delete thread.',
);
} finally {
setDeleting(false);
}
};
return ( return (
<main className='space-y-6'> <main className='space-y-6'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-start'> <div className='flex flex-col justify-between gap-4 md:flex-row md:items-start'>
@@ -170,40 +149,67 @@ const ThreadDetailPage = () => {
) : null} ) : null}
{!terminalThread ? ( {!terminalThread ? (
<> <>
<Button <AlertDialog>
variant='outline' <AlertDialogTrigger asChild>
onClick={() => { <Button variant='outline'>
if (!window.confirm('Mark this thread as resolved?')) return; <CheckCircle2 className='size-4' />
void markResolved({ threadId }).then(() => Resolve
toast.success('Thread resolved.'), </Button>
); </AlertDialogTrigger>
}} <AlertDialogContent>
> <AlertDialogHeader>
<CheckCircle2 className='size-4' /> <AlertDialogTitle>Mark thread resolved?</AlertDialogTitle>
Resolve <AlertDialogDescription>
</Button> This closes the thread without deleting its history.
<Button </AlertDialogDescription>
variant='outline' </AlertDialogHeader>
onClick={() => { <AlertDialogFooter>
if (!window.confirm('Cancel this thread?')) return; <AlertDialogCancel>Keep open</AlertDialogCancel>
void cancel({ threadId }).then(() => <AlertDialogAction
toast.success('Thread cancelled.'), onClick={() => {
); void markResolved({ threadId }).then(() =>
}} toast.success('Thread resolved.'),
> );
<XCircle className='size-4' /> }}
Cancel >
</Button> Resolve thread
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant='outline'>
<XCircle className='size-4' />
Cancel
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel this thread?</AlertDialogTitle>
<AlertDialogDescription>
This marks the thread as cancelled. It does not delete
existing workspace history.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep open</AlertDialogCancel>
<AlertDialogAction
variant='destructive'
onClick={() => {
void cancel({ threadId }).then(() =>
toast.success('Thread cancelled.'),
);
}}
>
Cancel thread
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</> </>
) : null} ) : null}
<Button <DeleteThreadButton threadId={threadId} redirectTo='/threads' />
variant='destructive'
disabled={deleting}
onClick={() => void removeThread()}
>
<Trash2 className='size-4' />
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</div> </div>
</div> </div>
+7 -49
View File
@@ -3,8 +3,9 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { DeleteThreadButton } from '@/components/threads/delete-thread-button';
import { useMutation, useQuery } from 'convex/react'; import { useMutation, useQuery } from 'convex/react';
import { MessageSquare, Plus, Trash2 } from 'lucide-react'; import { MessageSquare, Plus } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -43,9 +44,7 @@ const ThreadsPage = () => {
const [materializeEnvFile, setMaterializeEnvFile] = useState(false); const [materializeEnvFile, setMaterializeEnvFile] = useState(false);
const [envFilePath, setEnvFilePath] = useState('.env.local'); const [envFilePath, setEnvFilePath] = useState('.env.local');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [deletingThreadId, setDeletingThreadId] = useState<string>();
const createThread = useMutation(api.threads.createUserThread); const createThread = useMutation(api.threads.createUserThread);
const deleteThread = useMutation(api.threads.deleteThread);
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? []; const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? []; const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
const defaultProfile = profiles.find((profile) => profile.isDefault); const defaultProfile = profiles.find((profile) => profile.isDefault);
@@ -129,32 +128,6 @@ const ThreadsPage = () => {
} }
}; };
const removeThread = async (
event: React.MouseEvent<HTMLButtonElement>,
threadId: Id<'threads'>,
) => {
event.stopPropagation();
if (
!window.confirm(
'Delete this thread and any terminal workspace records attached to it? This cannot be undone.',
)
) {
return;
}
setDeletingThreadId(threadId);
try {
await deleteThread({ threadId });
toast.success('Thread deleted.');
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Could not delete thread.',
);
} finally {
setDeletingThreadId(undefined);
}
};
return ( return (
<main className='space-y-6'> <main className='space-y-6'>
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'> <div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
@@ -411,26 +384,11 @@ const ThreadsPage = () => {
</a> </a>
</Button> </Button>
) : null} ) : null}
<Button <DeleteThreadButton
type='button' threadId={thread._id as Id<'threads'>}
size='sm' disabled={!canDeleteThread(thread)}
variant='destructive' label='Delete'
disabled={ />
deletingThreadId === thread._id ||
!canDeleteThread(thread)
}
title={
canDeleteThread(thread)
? 'Delete thread'
: 'Stop or cancel the active workspace before deleting this thread.'
}
onClick={(event) =>
void removeThread(event, thread._id as Id<'threads'>)
}
>
<Trash2 className='size-3' />
Delete
</Button>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -8,7 +8,22 @@ import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js'; import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui'; import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Button,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@spoon/ui';
import type { DiffResponse, FileResponse, FileTreeNode } from './types'; import type { DiffResponse, FileResponse, FileTreeNode } from './types';
import { AgentThread } from './agent-thread'; import { AgentThread } from './agent-thread';
@@ -29,6 +44,11 @@ type OpenFileState = {
error?: string; error?: string;
}; };
type PendingOverwrite = {
path: string;
content: string;
};
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const job = useQuery(api.agentJobs.get, { jobId }); const job = useQuery(api.agentJobs.get, { jobId });
const messages = const messages =
@@ -64,6 +84,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const [activeWorkspaceTab, setActiveWorkspaceTab] = useState< const [activeWorkspaceTab, setActiveWorkspaceTab] = useState<
'editor' | 'diff' | 'thread' 'editor' | 'diff' | 'thread'
>('editor'); >('editor');
const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>();
const [pendingClosePath, setPendingClosePath] = useState<string>();
const workspaceDisabled = const workspaceDisabled =
!job || !job ||
@@ -250,7 +272,6 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}; };
const deleteStaleWorkspace = async () => { const deleteStaleWorkspace = async () => {
if (!window.confirm('Delete this stale workspace record?')) return;
await markWorkspaceLost({ jobId }); await markWorkspaceLost({ jobId });
await deleteWorkspace({ jobId }); await deleteWorkspace({ jobId });
window.location.href = job.threadId window.location.href = job.threadId
@@ -258,40 +279,12 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
: `/spoons/${job.spoonId}`; : `/spoons/${job.spoonId}`;
}; };
const saveFile = async (content: string) => { const writeFileContent = async (path: string, 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) => ({ setFiles((current) => ({
...current, ...current,
[activeFilePath]: { [path]: {
...(current[activeFilePath] ?? { ...(current[path] ?? {
path: activeFilePath, path,
savedContent: '', savedContent: '',
loading: false, loading: false,
}), }),
@@ -301,15 +294,15 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
})); }));
const response = await fetch(`/api/agent-jobs/${jobId}/file`, { const response = await fetch(`/api/agent-jobs/${jobId}/file`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ path: activeFilePath, content }), body: JSON.stringify({ path, content }),
}); });
if (!response.ok) { if (!response.ok) {
toast.error('Could not save file.'); toast.error('Could not save file.');
setFiles((current) => ({ setFiles((current) => ({
...current, ...current,
[activeFilePath]: { [path]: {
...(current[activeFilePath] ?? { ...(current[path] ?? {
path: activeFilePath, path,
content, content,
savedContent: '', savedContent: '',
loading: false, loading: false,
@@ -321,9 +314,9 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
} }
setFiles((current) => ({ setFiles((current) => ({
...current, ...current,
[activeFilePath]: { [path]: {
...(current[activeFilePath] ?? { ...(current[path] ?? {
path: activeFilePath, path,
loading: false, loading: false,
}), }),
content, content,
@@ -335,14 +328,29 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
toast.success('File saved.'); toast.success('File saved.');
}; };
const closeFile = (path: string) => { const saveFile = async (content: string) => {
const file = files[path]; if (!activeFilePath) return;
if (file && file.content !== file.savedContent) { const path = activeFilePath;
const confirmed = window.confirm( const activeFileBeforeSave = files[path];
`Close ${path} and discard unsaved changes?`, if (activeFileBeforeSave) {
const latestResponse = await fetch(
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
); );
if (!confirmed) return; if (latestResponse.ok) {
const latestData = (await latestResponse.json()) as FileResponse;
if (latestData.content !== activeFileBeforeSave.savedContent) {
setPendingOverwrite({
path,
content,
});
return;
}
}
} }
await writeFileContent(path, content);
};
const closeFileUnchecked = (path: string) => {
const index = openFilePaths.indexOf(path); const index = openFilePaths.indexOf(path);
const nextOpen = openFilePaths.filter((filePath) => filePath !== path); const nextOpen = openFilePaths.filter((filePath) => filePath !== path);
setOpenFilePaths(nextOpen); setOpenFilePaths(nextOpen);
@@ -356,6 +364,15 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
} }
}; };
const closeFile = (path: string) => {
const file = files[path];
if (file && file.content !== file.savedContent) {
setPendingClosePath(path);
return;
}
closeFileUnchecked(path);
};
const toggleDirectory = (path: string) => { const toggleDirectory = (path: string) => {
setExpandedDirectoryPaths((current) => setExpandedDirectoryPaths((current) =>
current.includes(path) current.includes(path)
@@ -414,13 +431,34 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
Start a fresh run Start a fresh run
</Button> </Button>
) : null} ) : null}
<Button <AlertDialog>
type='button' <AlertDialogTrigger asChild>
variant='outline' <Button type='button' variant='outline'>
onClick={() => void deleteStaleWorkspace()} Delete stale record
> </Button>
Delete stale record </AlertDialogTrigger>
</Button> <AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete this stale workspace record?
</AlertDialogTitle>
<AlertDialogDescription>
This marks the unreachable workspace as failed and removes
its stored messages, events, artifacts, diffs, and UI
state. The thread itself is kept.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep record</AlertDialogCancel>
<AlertDialogAction
variant='destructive'
onClick={() => void deleteStaleWorkspace()}
>
Delete stale record
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{job.threadId ? ( {job.threadId ? (
<Button type='button' variant='outline' asChild> <Button type='button' variant='outline' asChild>
<a href={`/threads/${job.threadId}`}>Open thread</a> <a href={`/threads/${job.threadId}`}>Open thread</a>
@@ -573,6 +611,69 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
/> />
</aside> </aside>
</div> </div>
<AlertDialog
open={Boolean(pendingOverwrite)}
onOpenChange={(open) => {
if (!open) setPendingOverwrite(undefined);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Overwrite newer workspace changes?
</AlertDialogTitle>
<AlertDialogDescription>
{pendingOverwrite?.path} changed after you opened it. Overwriting
will replace the newer workspace contents with your editor
contents.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep editing</AlertDialogCancel>
<AlertDialogAction
variant='destructive'
onClick={() => {
const pending = pendingOverwrite;
setPendingOverwrite(undefined);
if (pending) {
void writeFileContent(pending.path, pending.content);
}
}}
>
Overwrite file
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={Boolean(pendingClosePath)}
onOpenChange={(open) => {
if (!open) setPendingClosePath(undefined);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Discard unsaved changes?</AlertDialogTitle>
<AlertDialogDescription>
{pendingClosePath} has unsaved changes. Closing this tab will
discard the editor contents that have not been saved.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep tab open</AlertDialogCancel>
<AlertDialogAction
variant='destructive'
onClick={() => {
const path = pendingClosePath;
setPendingClosePath(undefined);
if (path) closeFileUnchecked(path);
}}
>
Discard and close
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</main> </main>
); );
}; };
@@ -12,7 +12,18 @@ import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js'; import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button } from '@spoon/ui'; import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Button,
} from '@spoon/ui';
export const WorkspaceActions = ({ export const WorkspaceActions = ({
job, job,
@@ -42,13 +53,6 @@ export const WorkspaceActions = ({
}; };
const remove = async () => { const remove = async () => {
if (
!window.confirm(
'Delete this workspace and its messages, events, artifacts, diffs, and UI state? This cannot be undone.',
)
) {
return;
}
try { try {
await deleteWorkspace({ jobId: job._id }); await deleteWorkspace({ jobId: job._id });
toast.success('Workspace deleted.'); toast.success('Workspace deleted.');
@@ -61,13 +65,6 @@ export const WorkspaceActions = ({
const removeThread = async () => { const removeThread = async () => {
if (!job.threadId) return; if (!job.threadId) return;
if (
!window.confirm(
'Delete this thread and any terminal workspace records attached to it? This cannot be undone.',
)
) {
return;
}
try { try {
await deleteThread({ threadId: job.threadId }); await deleteThread({ threadId: job.threadId });
toast.success('Thread deleted.'); toast.success('Thread deleted.');
@@ -120,20 +117,61 @@ export const WorkspaceActions = ({
{canDelete ? ( {canDelete ? (
<> <>
{job.threadId ? ( {job.threadId ? (
<Button <AlertDialog>
type='button' <AlertDialogTrigger asChild>
variant='destructive' <Button type='button' variant='destructive' size='sm'>
size='sm' <Trash2 className='size-4' />
onClick={removeThread} Delete thread
> </Button>
<Trash2 className='size-4' /> </AlertDialogTrigger>
Delete thread <AlertDialogContent>
</Button> <AlertDialogHeader>
<AlertDialogTitle>Delete this thread?</AlertDialogTitle>
<AlertDialogDescription>
This removes the thread and any terminal workspace records,
messages, events, artifacts, diffs, and UI state attached to
it. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep thread</AlertDialogCancel>
<AlertDialogAction
variant='destructive'
onClick={() => void removeThread()}
>
Delete thread
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null} ) : null}
<Button type='button' variant='outline' size='sm' onClick={remove}> <AlertDialog>
<Trash2 className='size-4' /> <AlertDialogTrigger asChild>
Delete workspace <Button type='button' variant='outline' size='sm'>
</Button> <Trash2 className='size-4' />
Delete workspace
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this workspace?</AlertDialogTitle>
<AlertDialogDescription>
This removes the workspace record, messages, events,
artifacts, diffs, and UI state. The thread is kept unless you
delete it separately.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep workspace</AlertDialogCancel>
<AlertDialogAction
variant='destructive'
onClick={() => void remove()}
>
Delete workspace
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</> </>
) : null} ) : null}
</div> </div>
@@ -7,6 +7,15 @@ import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js'; import { api } from '@spoon/backend/convex/_generated/api.js';
import { import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Badge, Badge,
Button, Button,
Card, Card,
@@ -107,13 +116,6 @@ export const WorkerHealthPanel = () => {
}; };
const deleteOld = async () => { const deleteOld = async () => {
if (
!window.confirm(
`Delete up to 100 stopped, cancelled, failed, or expired workspaces older than ${olderThanDays} days?`,
)
) {
return;
}
setDeleting(true); setDeleting(true);
try { try {
const result = await deleteOldWorkspaces({ const result = await deleteOldWorkspaces({
@@ -241,15 +243,40 @@ export const WorkerHealthPanel = () => {
{deletableCount} stopped, cancelled, failed, timed out, or expired {deletableCount} stopped, cancelled, failed, timed out, or expired
workspaces match this age filter. workspaces match this age filter.
</p> </p>
<Button <AlertDialog>
type='button' <AlertDialogTrigger asChild>
variant='destructive' <Button
disabled={deleting || deletableCount === 0} type='button'
onClick={() => void deleteOld()} variant='destructive'
> disabled={deleting || deletableCount === 0}
<Trash2 className='size-4' /> >
Delete old <Trash2 className='size-4' />
</Button> Delete old
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete old workspace records?
</AlertDialogTitle>
<AlertDialogDescription>
This deletes up to 100 stopped, cancelled, failed, timed
out, or expired workspaces older than {olderThanDays} days.
Active workspaces are not eligible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep records</AlertDialogCancel>
<AlertDialogAction
variant='destructive'
disabled={deleting}
onClick={() => void deleteOld()}
>
Delete old workspaces
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
<div className='border-border flex flex-col justify-between gap-3 rounded-md border p-3 md:flex-row md:items-center'> <div className='border-border flex flex-col justify-between gap-3 rounded-md border p-3 md:flex-row md:items-center'>
@@ -0,0 +1,98 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useMutation } from 'convex/react';
import { Trash2 } from 'lucide-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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Button,
} from '@spoon/ui';
export const DeleteThreadButton = ({
threadId,
disabled,
redirectTo,
onDeleted,
label = 'Delete',
size = 'sm',
variant = 'destructive',
}: {
threadId: Id<'threads'>;
disabled?: boolean;
redirectTo?: string;
onDeleted?: () => void;
label?: string;
size?: 'sm' | 'default';
variant?: 'destructive' | 'outline';
}) => {
const router = useRouter();
const deleteThread = useMutation(api.threads.deleteThread);
const [deleting, setDeleting] = useState(false);
const remove = async () => {
setDeleting(true);
try {
await deleteThread({ threadId });
toast.success('Thread deleted.');
onDeleted?.();
if (redirectTo) router.push(redirectTo);
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Could not delete thread.',
);
} finally {
setDeleting(false);
}
};
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type='button'
size={size}
variant={variant}
disabled={(disabled ?? false) || deleting}
onClick={(event) => event.stopPropagation()}
>
<Trash2 className='size-4' />
{deleting ? 'Deleting...' : label}
</Button>
</AlertDialogTrigger>
<AlertDialogContent onClick={(event) => event.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Delete this thread?</AlertDialogTitle>
<AlertDialogDescription>
This removes the thread and any terminal workspace records,
messages, events, artifacts, diffs, and UI state attached to it.
This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep thread</AlertDialogCancel>
<AlertDialogAction
variant='destructive'
disabled={deleting}
onClick={() => void remove()}
>
Delete thread
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
+21 -1
View File
@@ -178,11 +178,31 @@ export const listForSpoon = query({
handler: async (ctx, { spoonId, limit }) => { handler: async (ctx, { spoonId, limit }) => {
const ownerId = await getRequiredUserId(ctx); const ownerId = await getRequiredUserId(ctx);
await getOwnedSpoon(ctx, spoonId, ownerId); await getOwnedSpoon(ctx, spoonId, ownerId);
return await ctx.db const threads = await ctx.db
.query('threads') .query('threads')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId)) .withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
.order('desc') .order('desc')
.take(limit ?? 25); .take(limit ?? 25);
return await Promise.all(
threads.map(async (thread) => {
const latestJob = thread.latestAgentJobId
? await ctx.db.get(thread.latestAgentJobId)
: null;
return {
...publicThread(thread),
latestJobStatus:
latestJob?.ownerId === ownerId ? latestJob.status : undefined,
latestJobWorkspaceStatus:
latestJob?.ownerId === ownerId
? latestJob.workspaceStatus
: undefined,
latestJobPullRequestUrl:
latestJob?.ownerId === ownerId
? latestJob.pullRequestUrl
: undefined,
};
}),
);
}, },
}); });