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 { SpoonPrList } from '@/components/spoons/spoon-pr-list';
|
||||||
import { SpoonSecretsForm } from '@/components/spoons/spoon-secrets-form';
|
import { SpoonSecretsForm } from '@/components/spoons/spoon-secrets-form';
|
||||||
import { SpoonSettingsForm } from '@/components/spoons/spoon-settings-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 { ThreadWorkspaceForm } from '@/components/threads/thread-workspace-form';
|
||||||
import { useQuery } from 'convex/react';
|
import { useQuery } from 'convex/react';
|
||||||
|
|
||||||
@@ -54,6 +55,17 @@ const SpoonDetailPage = () => {
|
|||||||
});
|
});
|
||||||
const agentJobs =
|
const agentJobs =
|
||||||
useQuery(api.agentJobs.listForSpoon, { spoonId, limit: 25 }) ?? [];
|
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) {
|
if (details === undefined) {
|
||||||
return <main className='text-muted-foreground p-6'>Loading Spoon...</main>;
|
return <main className='text-muted-foreground p-6'>Loading Spoon...</main>;
|
||||||
@@ -253,17 +265,29 @@ const SpoonDetailPage = () => {
|
|||||||
<CardContent className='space-y-3'>
|
<CardContent className='space-y-3'>
|
||||||
{threads.length ? (
|
{threads.length ? (
|
||||||
threads.map((thread) => (
|
threads.map((thread) => (
|
||||||
<Link
|
<div
|
||||||
key={thread._id}
|
key={thread._id}
|
||||||
href={`/threads/${thread._id}`}
|
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'
|
||||||
className='border-border hover:border-primary/50 block rounded-md border p-3 transition-colors'
|
|
||||||
>
|
>
|
||||||
<p className='font-medium'>{thread.title}</p>
|
<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'>
|
<p className='text-muted-foreground mt-1 text-sm'>
|
||||||
{thread.status.replaceAll('_', ' ')} ·{' '}
|
{thread.status.replaceAll('_', ' ')} ·{' '}
|
||||||
{thread.source.replaceAll('_', ' ')}
|
{thread.source.replaceAll('_', ' ')}
|
||||||
|
{thread.latestJobWorkspaceStatus
|
||||||
|
? ` · workspace ${thread.latestJobWorkspaceStatus.replaceAll('_', ' ')}`
|
||||||
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</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'>
|
<p className='text-muted-foreground text-sm'>
|
||||||
|
|||||||
@@ -4,19 +4,23 @@ import { useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell';
|
import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell';
|
||||||
|
import { DeleteThreadButton } from '@/components/threads/delete-thread-button';
|
||||||
import { useMutation, useQuery } from 'convex/react';
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
import {
|
import { ArrowUpRight, CheckCircle2, Play, XCircle } from 'lucide-react';
|
||||||
ArrowUpRight,
|
|
||||||
CheckCircle2,
|
|
||||||
Play,
|
|
||||||
Trash2,
|
|
||||||
XCircle,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
import {
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -33,9 +37,7 @@ const ThreadDetailPage = () => {
|
|||||||
const createJob = useMutation(api.agentJobs.createForThread);
|
const createJob = useMutation(api.agentJobs.createForThread);
|
||||||
const markResolved = useMutation(api.threads.markResolved);
|
const markResolved = useMutation(api.threads.markResolved);
|
||||||
const cancel = useMutation(api.threads.cancel);
|
const cancel = useMutation(api.threads.cancel);
|
||||||
const deleteThread = useMutation(api.threads.deleteThread);
|
|
||||||
const [queueing, setQueueing] = useState(false);
|
const [queueing, setQueueing] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
|
|
||||||
if (details === undefined) {
|
if (details === undefined) {
|
||||||
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
|
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 (
|
return (
|
||||||
<main className='space-y-6'>
|
<main className='space-y-6'>
|
||||||
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-start'>
|
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-start'>
|
||||||
@@ -170,40 +149,67 @@ const ThreadDetailPage = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
{!terminalThread ? (
|
{!terminalThread ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<AlertDialog>
|
||||||
variant='outline'
|
<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={() => {
|
onClick={() => {
|
||||||
if (!window.confirm('Mark this thread as resolved?')) return;
|
|
||||||
void markResolved({ threadId }).then(() =>
|
void markResolved({ threadId }).then(() =>
|
||||||
toast.success('Thread resolved.'),
|
toast.success('Thread resolved.'),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CheckCircle2 className='size-4' />
|
Resolve thread
|
||||||
Resolve
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant='outline'>
|
||||||
|
<XCircle className='size-4' />
|
||||||
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
</AlertDialogTrigger>
|
||||||
variant='outline'
|
<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={() => {
|
onClick={() => {
|
||||||
if (!window.confirm('Cancel this thread?')) return;
|
|
||||||
void cancel({ threadId }).then(() =>
|
void cancel({ threadId }).then(() =>
|
||||||
toast.success('Thread cancelled.'),
|
toast.success('Thread cancelled.'),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<XCircle className='size-4' />
|
Cancel thread
|
||||||
Cancel
|
</AlertDialogAction>
|
||||||
</Button>
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<DeleteThreadButton threadId={threadId} redirectTo='/threads' />
|
||||||
variant='destructive'
|
|
||||||
disabled={deleting}
|
|
||||||
onClick={() => void removeThread()}
|
|
||||||
>
|
|
||||||
<Trash2 className='size-4' />
|
|
||||||
{deleting ? 'Deleting...' : 'Delete'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { DeleteThreadButton } from '@/components/threads/delete-thread-button';
|
||||||
import { useMutation, useQuery } from 'convex/react';
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
import { MessageSquare, Plus, Trash2 } from 'lucide-react';
|
import { MessageSquare, Plus } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
@@ -43,9 +44,7 @@ const ThreadsPage = () => {
|
|||||||
const [materializeEnvFile, setMaterializeEnvFile] = useState(false);
|
const [materializeEnvFile, setMaterializeEnvFile] = useState(false);
|
||||||
const [envFilePath, setEnvFilePath] = useState('.env.local');
|
const [envFilePath, setEnvFilePath] = useState('.env.local');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [deletingThreadId, setDeletingThreadId] = useState<string>();
|
|
||||||
const createThread = useMutation(api.threads.createUserThread);
|
const createThread = useMutation(api.threads.createUserThread);
|
||||||
const deleteThread = useMutation(api.threads.deleteThread);
|
|
||||||
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||||
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||||
const defaultProfile = profiles.find((profile) => profile.isDefault);
|
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 (
|
return (
|
||||||
<main className='space-y-6'>
|
<main className='space-y-6'>
|
||||||
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
|
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
|
||||||
@@ -411,26 +384,11 @@ const ThreadsPage = () => {
|
|||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<DeleteThreadButton
|
||||||
type='button'
|
threadId={thread._id as Id<'threads'>}
|
||||||
size='sm'
|
disabled={!canDeleteThread(thread)}
|
||||||
variant='destructive'
|
label='Delete'
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -8,7 +8,22 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.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 type { DiffResponse, FileResponse, FileTreeNode } from './types';
|
||||||
import { AgentThread } from './agent-thread';
|
import { AgentThread } from './agent-thread';
|
||||||
@@ -29,6 +44,11 @@ type OpenFileState = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PendingOverwrite = {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||||
const job = useQuery(api.agentJobs.get, { jobId });
|
const job = useQuery(api.agentJobs.get, { jobId });
|
||||||
const messages =
|
const messages =
|
||||||
@@ -64,6 +84,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
const [activeWorkspaceTab, setActiveWorkspaceTab] = useState<
|
const [activeWorkspaceTab, setActiveWorkspaceTab] = useState<
|
||||||
'editor' | 'diff' | 'thread'
|
'editor' | 'diff' | 'thread'
|
||||||
>('editor');
|
>('editor');
|
||||||
|
const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>();
|
||||||
|
const [pendingClosePath, setPendingClosePath] = useState<string>();
|
||||||
|
|
||||||
const workspaceDisabled =
|
const workspaceDisabled =
|
||||||
!job ||
|
!job ||
|
||||||
@@ -250,7 +272,6 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteStaleWorkspace = async () => {
|
const deleteStaleWorkspace = async () => {
|
||||||
if (!window.confirm('Delete this stale workspace record?')) return;
|
|
||||||
await markWorkspaceLost({ jobId });
|
await markWorkspaceLost({ jobId });
|
||||||
await deleteWorkspace({ jobId });
|
await deleteWorkspace({ jobId });
|
||||||
window.location.href = job.threadId
|
window.location.href = job.threadId
|
||||||
@@ -258,40 +279,12 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
: `/spoons/${job.spoonId}`;
|
: `/spoons/${job.spoonId}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveFile = async (content: string) => {
|
const writeFileContent = async (path: string, 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) => ({
|
setFiles((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[activeFilePath]: {
|
[path]: {
|
||||||
...activeFileBeforeSave,
|
...(current[path] ?? {
|
||||||
content: latestData.content,
|
path,
|
||||||
savedContent: latestData.content,
|
|
||||||
saving: false,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
toast.info('File reloaded with latest workspace contents.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setFiles((current) => ({
|
|
||||||
...current,
|
|
||||||
[activeFilePath]: {
|
|
||||||
...(current[activeFilePath] ?? {
|
|
||||||
path: activeFilePath,
|
|
||||||
savedContent: '',
|
savedContent: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
}),
|
}),
|
||||||
@@ -301,15 +294,15 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
}));
|
}));
|
||||||
const response = await fetch(`/api/agent-jobs/${jobId}/file`, {
|
const response = await fetch(`/api/agent-jobs/${jobId}/file`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ path: activeFilePath, content }),
|
body: JSON.stringify({ path, content }),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error('Could not save file.');
|
toast.error('Could not save file.');
|
||||||
setFiles((current) => ({
|
setFiles((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[activeFilePath]: {
|
[path]: {
|
||||||
...(current[activeFilePath] ?? {
|
...(current[path] ?? {
|
||||||
path: activeFilePath,
|
path,
|
||||||
content,
|
content,
|
||||||
savedContent: '',
|
savedContent: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -321,9 +314,9 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
}
|
}
|
||||||
setFiles((current) => ({
|
setFiles((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[activeFilePath]: {
|
[path]: {
|
||||||
...(current[activeFilePath] ?? {
|
...(current[path] ?? {
|
||||||
path: activeFilePath,
|
path,
|
||||||
loading: false,
|
loading: false,
|
||||||
}),
|
}),
|
||||||
content,
|
content,
|
||||||
@@ -335,14 +328,29 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
toast.success('File saved.');
|
toast.success('File saved.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeFile = (path: string) => {
|
const saveFile = async (content: string) => {
|
||||||
const file = files[path];
|
if (!activeFilePath) return;
|
||||||
if (file && file.content !== file.savedContent) {
|
const path = activeFilePath;
|
||||||
const confirmed = window.confirm(
|
const activeFileBeforeSave = files[path];
|
||||||
`Close ${path} and discard unsaved changes?`,
|
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 index = openFilePaths.indexOf(path);
|
||||||
const nextOpen = openFilePaths.filter((filePath) => filePath !== path);
|
const nextOpen = openFilePaths.filter((filePath) => filePath !== path);
|
||||||
setOpenFilePaths(nextOpen);
|
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) => {
|
const toggleDirectory = (path: string) => {
|
||||||
setExpandedDirectoryPaths((current) =>
|
setExpandedDirectoryPaths((current) =>
|
||||||
current.includes(path)
|
current.includes(path)
|
||||||
@@ -414,13 +431,34 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
Start a fresh run
|
Start a fresh run
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<AlertDialog>
|
||||||
type='button'
|
<AlertDialogTrigger asChild>
|
||||||
variant='outline'
|
<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()}
|
onClick={() => void deleteStaleWorkspace()}
|
||||||
>
|
>
|
||||||
Delete stale record
|
Delete stale record
|
||||||
</Button>
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
{job.threadId ? (
|
{job.threadId ? (
|
||||||
<Button type='button' variant='outline' asChild>
|
<Button type='button' variant='outline' asChild>
|
||||||
<a href={`/threads/${job.threadId}`}>Open thread</a>
|
<a href={`/threads/${job.threadId}`}>Open thread</a>
|
||||||
@@ -573,6 +611,69 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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>
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,18 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.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 = ({
|
export const WorkspaceActions = ({
|
||||||
job,
|
job,
|
||||||
@@ -42,13 +53,6 @@ export const WorkspaceActions = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
if (
|
|
||||||
!window.confirm(
|
|
||||||
'Delete this workspace and its messages, events, artifacts, diffs, and UI state? This cannot be undone.',
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await deleteWorkspace({ jobId: job._id });
|
await deleteWorkspace({ jobId: job._id });
|
||||||
toast.success('Workspace deleted.');
|
toast.success('Workspace deleted.');
|
||||||
@@ -61,13 +65,6 @@ export const WorkspaceActions = ({
|
|||||||
|
|
||||||
const removeThread = async () => {
|
const removeThread = async () => {
|
||||||
if (!job.threadId) return;
|
if (!job.threadId) return;
|
||||||
if (
|
|
||||||
!window.confirm(
|
|
||||||
'Delete this thread and any terminal workspace records attached to it? This cannot be undone.',
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await deleteThread({ threadId: job.threadId });
|
await deleteThread({ threadId: job.threadId });
|
||||||
toast.success('Thread deleted.');
|
toast.success('Thread deleted.');
|
||||||
@@ -120,20 +117,61 @@ export const WorkspaceActions = ({
|
|||||||
{canDelete ? (
|
{canDelete ? (
|
||||||
<>
|
<>
|
||||||
{job.threadId ? (
|
{job.threadId ? (
|
||||||
<Button
|
<AlertDialog>
|
||||||
type='button'
|
<AlertDialogTrigger asChild>
|
||||||
variant='destructive'
|
<Button type='button' variant='destructive' size='sm'>
|
||||||
size='sm'
|
|
||||||
onClick={removeThread}
|
|
||||||
>
|
|
||||||
<Trash2 className='size-4' />
|
<Trash2 className='size-4' />
|
||||||
Delete thread
|
Delete thread
|
||||||
</Button>
|
</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}
|
) : null}
|
||||||
<Button type='button' variant='outline' size='sm' onClick={remove}>
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button type='button' variant='outline' size='sm'>
|
||||||
<Trash2 className='size-4' />
|
<Trash2 className='size-4' />
|
||||||
Delete workspace
|
Delete workspace
|
||||||
</Button>
|
</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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
import {
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -107,13 +116,6 @@ export const WorkerHealthPanel = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteOld = async () => {
|
const deleteOld = async () => {
|
||||||
if (
|
|
||||||
!window.confirm(
|
|
||||||
`Delete up to 100 stopped, cancelled, failed, or expired workspaces older than ${olderThanDays} days?`,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
const result = await deleteOldWorkspaces({
|
const result = await deleteOldWorkspaces({
|
||||||
@@ -241,15 +243,40 @@ export const WorkerHealthPanel = () => {
|
|||||||
{deletableCount} stopped, cancelled, failed, timed out, or expired
|
{deletableCount} stopped, cancelled, failed, timed out, or expired
|
||||||
workspaces match this age filter.
|
workspaces match this age filter.
|
||||||
</p>
|
</p>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type='button'
|
type='button'
|
||||||
variant='destructive'
|
variant='destructive'
|
||||||
disabled={deleting || deletableCount === 0}
|
disabled={deleting || deletableCount === 0}
|
||||||
onClick={() => void deleteOld()}
|
|
||||||
>
|
>
|
||||||
<Trash2 className='size-4' />
|
<Trash2 className='size-4' />
|
||||||
Delete old
|
Delete old
|
||||||
</Button>
|
</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>
|
||||||
|
|
||||||
<div className='border-border flex flex-col justify-between gap-3 rounded-md border p-3 md:flex-row md:items-center'>
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -178,11 +178,31 @@ export const listForSpoon = query({
|
|||||||
handler: async (ctx, { spoonId, limit }) => {
|
handler: async (ctx, { spoonId, limit }) => {
|
||||||
const ownerId = await getRequiredUserId(ctx);
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
await getOwnedSpoon(ctx, spoonId, ownerId);
|
await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||||
return await ctx.db
|
const threads = await ctx.db
|
||||||
.query('threads')
|
.query('threads')
|
||||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||||
.order('desc')
|
.order('desc')
|
||||||
.take(limit ?? 25);
|
.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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user