From 5567a4be95ea899a48a617bce22324b69255a3ba Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 23 Jun 2026 16:00:34 -0400 Subject: [PATCH] allow users to delete threads from spoons details page --- .../src/app/(app)/spoons/[spoonId]/page.tsx | 42 +++- .../src/app/(app)/threads/[threadId]/page.tsx | 134 +++++------ apps/next/src/app/(app)/threads/page.tsx | 56 +---- .../agent-workspace/agent-workspace-shell.tsx | 209 +++++++++++++----- .../agent-workspace/workspace-actions.tsx | 94 +++++--- .../settings/worker-health-panel.tsx | 59 +++-- .../threads/delete-thread-button.tsx | 98 ++++++++ packages/backend/convex/threads.ts | 22 +- 8 files changed, 493 insertions(+), 221 deletions(-) create mode 100644 apps/next/src/components/threads/delete-thread-button.tsx diff --git a/apps/next/src/app/(app)/spoons/[spoonId]/page.tsx b/apps/next/src/app/(app)/spoons/[spoonId]/page.tsx index 500099e..fbb4023 100644 --- a/apps/next/src/app/(app)/spoons/[spoonId]/page.tsx +++ b/apps/next/src/app/(app)/spoons/[spoonId]/page.tsx @@ -11,6 +11,7 @@ import { SpoonMetrics } from '@/components/spoons/spoon-metrics'; import { SpoonPrList } from '@/components/spoons/spoon-pr-list'; import { SpoonSecretsForm } from '@/components/spoons/spoon-secrets-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 { useQuery } from 'convex/react'; @@ -54,6 +55,17 @@ const SpoonDetailPage = () => { }); const agentJobs = 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) { return
Loading Spoon...
; @@ -253,17 +265,29 @@ const SpoonDetailPage = () => { {threads.length ? ( threads.map((thread) => ( - -

{thread.title}

-

- {thread.status.replaceAll('_', ' ')} ·{' '} - {thread.source.replaceAll('_', ' ')} -

- + +

{thread.title}

+

+ {thread.status.replaceAll('_', ' ')} ·{' '} + {thread.source.replaceAll('_', ' ')} + {thread.latestJobWorkspaceStatus + ? ` · workspace ${thread.latestJobWorkspaceStatus.replaceAll('_', ' ')}` + : ''} +

+ +
+ +
+ )) ) : (

diff --git a/apps/next/src/app/(app)/threads/[threadId]/page.tsx b/apps/next/src/app/(app)/threads/[threadId]/page.tsx index 0987303..f471c58 100644 --- a/apps/next/src/app/(app)/threads/[threadId]/page.tsx +++ b/apps/next/src/app/(app)/threads/[threadId]/page.tsx @@ -4,19 +4,23 @@ import { useState } from 'react'; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell'; +import { DeleteThreadButton } from '@/components/threads/delete-thread-button'; import { useMutation, useQuery } from 'convex/react'; -import { - ArrowUpRight, - CheckCircle2, - Play, - Trash2, - XCircle, -} from 'lucide-react'; +import { ArrowUpRight, CheckCircle2, Play, XCircle } 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, Badge, Button, Card, @@ -33,9 +37,7 @@ const ThreadDetailPage = () => { const createJob = useMutation(api.agentJobs.createForThread); const markResolved = useMutation(api.threads.markResolved); const cancel = useMutation(api.threads.cancel); - const deleteThread = useMutation(api.threads.deleteThread); const [queueing, setQueueing] = useState(false); - const [deleting, setDeleting] = useState(false); if (details === undefined) { return

Loading thread...
; @@ -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 (
@@ -170,40 +149,67 @@ const ThreadDetailPage = () => { ) : null} {!terminalThread ? ( <> - - + + + + + + + Mark thread resolved? + + This closes the thread without deleting its history. + + + + Keep open + { + void markResolved({ threadId }).then(() => + toast.success('Thread resolved.'), + ); + }} + > + Resolve thread + + + + + + + + + + + Cancel this thread? + + This marks the thread as cancelled. It does not delete + existing workspace history. + + + + Keep open + { + void cancel({ threadId }).then(() => + toast.success('Thread cancelled.'), + ); + }} + > + Cancel thread + + + + ) : null} - +
diff --git a/apps/next/src/app/(app)/threads/page.tsx b/apps/next/src/app/(app)/threads/page.tsx index 81ccf8b..a11127c 100644 --- a/apps/next/src/app/(app)/threads/page.tsx +++ b/apps/next/src/app/(app)/threads/page.tsx @@ -3,8 +3,9 @@ import { useState } from 'react'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; +import { DeleteThreadButton } from '@/components/threads/delete-thread-button'; import { useMutation, useQuery } from 'convex/react'; -import { MessageSquare, Plus, Trash2 } from 'lucide-react'; +import { MessageSquare, Plus } from 'lucide-react'; import { toast } from 'sonner'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; @@ -43,9 +44,7 @@ const ThreadsPage = () => { const [materializeEnvFile, setMaterializeEnvFile] = useState(false); const [envFilePath, setEnvFilePath] = useState('.env.local'); const [creating, setCreating] = useState(false); - const [deletingThreadId, setDeletingThreadId] = useState(); const createThread = useMutation(api.threads.createUserThread); - const deleteThread = useMutation(api.threads.deleteThread); const spoons = useQuery(api.spoons.listMineWithState, {}) ?? []; const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? []; const defaultProfile = profiles.find((profile) => profile.isDefault); @@ -129,32 +128,6 @@ const ThreadsPage = () => { } }; - const removeThread = async ( - event: React.MouseEvent, - 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 (
@@ -411,26 +384,11 @@ const ThreadsPage = () => { ) : null} - + } + disabled={!canDeleteThread(thread)} + label='Delete' + />
diff --git a/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx b/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx index 4b5d30e..6455dd6 100644 --- a/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx +++ b/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx @@ -8,7 +8,22 @@ import { toast } from 'sonner'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.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 { AgentThread } from './agent-thread'; @@ -29,6 +44,11 @@ type OpenFileState = { error?: string; }; +type PendingOverwrite = { + path: string; + content: string; +}; + export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { const job = useQuery(api.agentJobs.get, { jobId }); const messages = @@ -64,6 +84,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { const [activeWorkspaceTab, setActiveWorkspaceTab] = useState< 'editor' | 'diff' | 'thread' >('editor'); + const [pendingOverwrite, setPendingOverwrite] = useState(); + const [pendingClosePath, setPendingClosePath] = useState(); const workspaceDisabled = !job || @@ -250,7 +272,6 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { }; const deleteStaleWorkspace = async () => { - if (!window.confirm('Delete this stale workspace record?')) return; await markWorkspaceLost({ jobId }); await deleteWorkspace({ jobId }); window.location.href = job.threadId @@ -258,40 +279,12 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { : `/spoons/${job.spoonId}`; }; - 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; - } - } - } - } + const writeFileContent = async (path: string, content: string) => { setFiles((current) => ({ ...current, - [activeFilePath]: { - ...(current[activeFilePath] ?? { - path: activeFilePath, + [path]: { + ...(current[path] ?? { + path, savedContent: '', loading: false, }), @@ -301,15 +294,15 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { })); const response = await fetch(`/api/agent-jobs/${jobId}/file`, { method: 'PUT', - body: JSON.stringify({ path: activeFilePath, content }), + body: JSON.stringify({ path, content }), }); if (!response.ok) { toast.error('Could not save file.'); setFiles((current) => ({ ...current, - [activeFilePath]: { - ...(current[activeFilePath] ?? { - path: activeFilePath, + [path]: { + ...(current[path] ?? { + path, content, savedContent: '', loading: false, @@ -321,9 +314,9 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { } setFiles((current) => ({ ...current, - [activeFilePath]: { - ...(current[activeFilePath] ?? { - path: activeFilePath, + [path]: { + ...(current[path] ?? { + path, loading: false, }), content, @@ -335,14 +328,29 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { 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?`, + const saveFile = async (content: string) => { + if (!activeFilePath) return; + const path = activeFilePath; + const activeFileBeforeSave = files[path]; + 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 nextOpen = openFilePaths.filter((filePath) => filePath !== path); 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) => { setExpandedDirectoryPaths((current) => current.includes(path) @@ -414,13 +431,34 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { Start a fresh run ) : null} - + + + + + + + + Delete this stale workspace record? + + + This marks the unreachable workspace as failed and removes + its stored messages, events, artifacts, diffs, and UI + state. The thread itself is kept. + + + + Keep record + void deleteStaleWorkspace()} + > + Delete stale record + + + + {job.threadId ? (
); }; diff --git a/apps/next/src/components/agent-workspace/workspace-actions.tsx b/apps/next/src/components/agent-workspace/workspace-actions.tsx index 7c83dee..0f76e94 100644 --- a/apps/next/src/components/agent-workspace/workspace-actions.tsx +++ b/apps/next/src/components/agent-workspace/workspace-actions.tsx @@ -12,7 +12,18 @@ 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'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, + Button, +} from '@spoon/ui'; export const WorkspaceActions = ({ job, @@ -42,13 +53,6 @@ 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.'); @@ -61,13 +65,6 @@ 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.'); @@ -120,20 +117,61 @@ export const WorkspaceActions = ({ {canDelete ? ( <> {job.threadId ? ( - + + + + + + + Delete this thread? + + This removes the thread and any terminal workspace records, + messages, events, artifacts, diffs, and UI state attached to + it. This cannot be undone. + + + + Keep thread + void removeThread()} + > + Delete thread + + + + ) : null} - + + + + + + + Delete this workspace? + + This removes the workspace record, messages, events, + artifacts, diffs, and UI state. The thread is kept unless you + delete it separately. + + + + Keep workspace + void remove()} + > + Delete workspace + + + + ) : null} diff --git a/apps/next/src/components/settings/worker-health-panel.tsx b/apps/next/src/components/settings/worker-health-panel.tsx index a866aa5..9bda7fa 100644 --- a/apps/next/src/components/settings/worker-health-panel.tsx +++ b/apps/next/src/components/settings/worker-health-panel.tsx @@ -7,6 +7,15 @@ import { toast } from 'sonner'; import { api } from '@spoon/backend/convex/_generated/api.js'; import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, Badge, Button, Card, @@ -107,13 +116,6 @@ export const WorkerHealthPanel = () => { }; 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({ @@ -241,15 +243,40 @@ export const WorkerHealthPanel = () => { {deletableCount} stopped, cancelled, failed, timed out, or expired workspaces match this age filter.

- + + + + + + + + Delete old workspace records? + + + This deletes up to 100 stopped, cancelled, failed, timed + out, or expired workspaces older than {olderThanDays} days. + Active workspaces are not eligible. + + + + Keep records + void deleteOld()} + > + Delete old workspaces + + + +
diff --git a/apps/next/src/components/threads/delete-thread-button.tsx b/apps/next/src/components/threads/delete-thread-button.tsx new file mode 100644 index 0000000..87a3392 --- /dev/null +++ b/apps/next/src/components/threads/delete-thread-button.tsx @@ -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 ( + + + + + event.stopPropagation()}> + + Delete this thread? + + This removes the thread and any terminal workspace records, + messages, events, artifacts, diffs, and UI state attached to it. + This cannot be undone. + + + + Keep thread + void remove()} + > + Delete thread + + + + + ); +}; diff --git a/packages/backend/convex/threads.ts b/packages/backend/convex/threads.ts index 845eb5e..cd3fc29 100644 --- a/packages/backend/convex/threads.ts +++ b/packages/backend/convex/threads.ts @@ -178,11 +178,31 @@ export const listForSpoon = query({ handler: async (ctx, { spoonId, limit }) => { const ownerId = await getRequiredUserId(ctx); await getOwnedSpoon(ctx, spoonId, ownerId); - return await ctx.db + const threads = await ctx.db .query('threads') .withIndex('by_spoon', (q) => q.eq('spoonId', spoonId)) .order('desc') .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, + }; + }), + ); }, });