allow users to delete threads from spoons details page
This commit is contained in:
@@ -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 <main className='text-muted-foreground p-6'>Loading Spoon...</main>;
|
||||
@@ -253,17 +265,29 @@ const SpoonDetailPage = () => {
|
||||
<CardContent className='space-y-3'>
|
||||
{threads.length ? (
|
||||
threads.map((thread) => (
|
||||
<Link
|
||||
<div
|
||||
key={thread._id}
|
||||
href={`/threads/${thread._id}`}
|
||||
className='border-border hover:border-primary/50 block rounded-md border p-3 transition-colors'
|
||||
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'
|
||||
>
|
||||
<p className='font-medium'>{thread.title}</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{thread.status.replaceAll('_', ' ')} ·{' '}
|
||||
{thread.source.replaceAll('_', ' ')}
|
||||
</p>
|
||||
</Link>
|
||||
<Link href={`/threads/${thread._id}`} className='min-w-0'>
|
||||
<p className='truncate font-medium'>{thread.title}</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{thread.status.replaceAll('_', ' ')} ·{' '}
|
||||
{thread.source.replaceAll('_', ' ')}
|
||||
{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'>
|
||||
|
||||
@@ -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 <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 (
|
||||
<main className='space-y-6'>
|
||||
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-start'>
|
||||
@@ -170,40 +149,67 @@ const ThreadDetailPage = () => {
|
||||
) : null}
|
||||
{!terminalThread ? (
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
if (!window.confirm('Mark this thread as resolved?')) return;
|
||||
void markResolved({ threadId }).then(() =>
|
||||
toast.success('Thread resolved.'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 className='size-4' />
|
||||
Resolve
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
if (!window.confirm('Cancel this thread?')) return;
|
||||
void cancel({ threadId }).then(() =>
|
||||
toast.success('Thread cancelled.'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<XCircle className='size-4' />
|
||||
Cancel
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant='outline'>
|
||||
<CheckCircle2 className='size-4' />
|
||||
Resolve
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Mark thread resolved?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This closes the thread without deleting its history.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep open</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
void markResolved({ threadId }).then(() =>
|
||||
toast.success('Thread resolved.'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
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}
|
||||
<Button
|
||||
variant='destructive'
|
||||
disabled={deleting}
|
||||
onClick={() => void removeThread()}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
<DeleteThreadButton threadId={threadId} redirectTo='/threads' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
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<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 (
|
||||
<main className='space-y-6'>
|
||||
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
|
||||
@@ -411,26 +384,11 @@ const ThreadsPage = () => {
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
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>
|
||||
<DeleteThreadButton
|
||||
threadId={thread._id as Id<'threads'>}
|
||||
disabled={!canDeleteThread(thread)}
|
||||
label='Delete'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user