allow users to delete threads from spoons details page
This commit is contained in:
@@ -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<PendingOverwrite>();
|
||||
const [pendingClosePath, setPendingClosePath] = useState<string>();
|
||||
|
||||
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
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => void deleteStaleWorkspace()}
|
||||
>
|
||||
Delete stale record
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button type='button' variant='outline'>
|
||||
Delete stale record
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<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 ? (
|
||||
<Button type='button' variant='outline' asChild>
|
||||
<a href={`/threads/${job.threadId}`}>Open thread</a>
|
||||
@@ -573,6 +611,69 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
/>
|
||||
</aside>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={removeThread}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
Delete thread
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button type='button' variant='destructive' size='sm'>
|
||||
<Trash2 className='size-4' />
|
||||
Delete thread
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<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}
|
||||
<Button type='button' variant='outline' size='sm' onClick={remove}>
|
||||
<Trash2 className='size-4' />
|
||||
Delete workspace
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button type='button' variant='outline' size='sm'>
|
||||
<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}
|
||||
</div>
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
disabled={deleting || deletableCount === 0}
|
||||
onClick={() => void deleteOld()}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
Delete old
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
disabled={deleting || deletableCount === 0}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
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 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user