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
@@ -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>
);
};