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 ? (
- void deleteOld()}
- >
-
- Delete old
-
+
+
+
+
+ Delete old
+
+
+
+
+
+ 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()}
+ >
+
+ {deleting ? 'Deleting...' : label}
+
+
+ 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,
+ };
+ }),
+ );
},
});