Clean up old stuff & fix ui errors
Build and Push Spoon Images / quality (push) Successful in 2m22s
Build and Push Spoon Images / build-images (push) Successful in 23m10s

This commit is contained in:
Gabriel Brown
2026-06-23 14:57:05 -04:00
parent d207b8b0b8
commit a6f7ea7f78
34 changed files with 1565 additions and 551 deletions
@@ -1,15 +1,33 @@
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useParams, useRouter } from 'next/navigation';
import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell';
import { useQuery } from 'convex/react';
import { ArrowLeft } from 'lucide-react';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button } from '@spoon/ui';
const AgentWorkspacePage = () => {
const router = useRouter();
const params = useParams<{ spoonId: string; jobId: string }>();
const jobId = params.jobId as Id<'agentJobs'>;
const job = useQuery(api.agentJobs.get, { jobId });
useEffect(() => {
if (job?.threadId) router.replace(`/threads/${job.threadId}`);
}, [job?.threadId, router]);
if (job?.threadId) {
return (
<main className='text-muted-foreground p-6'>
Opening thread workspace...
</main>
);
}
return (
<main className='space-y-4'>
@@ -19,7 +37,7 @@ const AgentWorkspacePage = () => {
Back to Spoon
</Link>
</Button>
<AgentWorkspaceShell jobId={params.jobId as Id<'agentJobs'>} />
<AgentWorkspaceShell jobId={jobId} />
</main>
);
};
@@ -2,8 +2,6 @@
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { AgentJobList } from '@/components/agents/agent-job-list';
import { AgentRequestForm } from '@/components/agents/agent-request-form';
import { SpoonActivityTimeline } from '@/components/spoons/spoon-activity-timeline';
import { SpoonAgentSettingsForm } from '@/components/spoons/spoon-agent-settings-form';
import { SpoonClonePanel } from '@/components/spoons/spoon-clone-panel';
@@ -13,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 { ThreadWorkspaceForm } from '@/components/threads/thread-workspace-form';
import { useQuery } from 'convex/react';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -243,7 +242,7 @@ const SpoonDetailPage = () => {
</TabsContent>
<TabsContent value='threads' className='space-y-4'>
<AgentRequestForm
<ThreadWorkspaceForm
spoon={details.spoon}
agentSettings={agentSettings}
/>
@@ -273,7 +272,6 @@ const SpoonDetailPage = () => {
)}
</CardContent>
</Card>
<AgentJobList jobs={agentJobs} />
</TabsContent>
<TabsContent value='activity'>
@@ -2,9 +2,16 @@
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useParams, useRouter } from 'next/navigation';
import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell';
import { useMutation, useQuery } from 'convex/react';
import { ArrowUpRight, CheckCircle2, Play, XCircle } from 'lucide-react';
import {
ArrowUpRight,
CheckCircle2,
Play,
Trash2,
XCircle,
} from 'lucide-react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -16,41 +23,45 @@ import {
CardContent,
CardHeader,
CardTitle,
Textarea,
} from '@spoon/ui';
const ThreadDetailPage = () => {
const router = useRouter();
const params = useParams<{ threadId: string }>();
const threadId = params.threadId as Id<'threads'>;
const details = useQuery(api.threads.get, { threadId });
const messages = useQuery(api.threads.listMessages, { threadId }) ?? [];
const createJob = useMutation(api.agentJobs.createForThread);
const markResolved = useMutation(api.threads.markResolved);
const cancel = useMutation(api.threads.cancel);
const [sending, setSending] = useState(false);
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>;
}
const { thread, spoon, latestJob } = details;
if (latestJob && spoon) {
return (
<main className='space-y-4'>
<Button asChild variant='ghost' size='sm'>
<Link href={`/spoons/${spoon._id}`}>
<ArrowUpRight className='size-4 rotate-180' />
Back to Spoon
</Link>
</Button>
<AgentWorkspaceShell jobId={latestJob._id} />
</main>
);
}
const terminalThread = [
'resolved',
'ignored',
'failed',
'cancelled',
].includes(thread.status);
const activeJob =
latestJob &&
[
'claimed',
'preparing',
'running',
'checks_running',
'changes_ready',
].includes(latestJob.status) &&
['active', 'idle'].includes(latestJob.workspaceStatus ?? '');
const canQueueRun =
spoon &&
(!latestJob ||
@@ -67,40 +78,12 @@ const ThreadDetailPage = () => {
? ('maintenance_review' as const)
: ('user_change' as const);
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const value = form.get('message');
const content = typeof value === 'string' ? value : '';
setSending(true);
try {
const response = await fetch(`/api/threads/${threadId}/message`, {
method: 'POST',
body: JSON.stringify({ content }),
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as {
error?: string;
recoverable?: boolean;
} | null;
throw new Error(payload?.error ?? (await response.text()));
}
event.currentTarget.reset();
toast.success(activeJob ? 'Message sent to agent.' : 'Message added.');
} catch (error) {
console.error(error);
toast.error('Could not send message.');
} finally {
setSending(false);
}
};
const startRun = async () => {
setQueueing(true);
try {
const jobId = await createJob({ threadId, jobType });
await createJob({ threadId, jobType });
toast.success('Workspace run queued.');
window.location.href = `/spoons/${spoon?._id}/agent/${jobId}`;
router.replace(`/threads/${threadId}`);
} catch (error) {
console.error(error);
toast.error('Could not queue workspace run.');
@@ -109,6 +92,29 @@ 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'>
@@ -142,11 +148,7 @@ const ThreadDetailPage = () => {
<div className='flex flex-wrap gap-2'>
{latestJob ? (
<Button variant='outline' asChild>
<Link
href={`/spoons/${latestJob.spoonId}/agent/${latestJob._id}`}
>
Open workspace
</Link>
<Link href={`/threads/${threadId}`}>Open workspace</Link>
</Button>
) : null}
{latestJob?.pullRequestUrl ? (
@@ -194,57 +196,38 @@ const ThreadDetailPage = () => {
</Button>
</>
) : null}
<Button
variant='destructive'
disabled={deleting}
onClick={() => void removeThread()}
>
<Trash2 className='size-4' />
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
<div className='grid gap-6 xl:grid-cols-[1fr_320px]'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle>Conversation</CardTitle>
<CardTitle>Workspace</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
{messages.map((message) => (
<div
key={message._id}
className='border-border rounded-md border p-3'
>
<div className='mb-2 flex items-center justify-between gap-2'>
<Badge variant='outline'>{message.role}</Badge>
<span className='text-muted-foreground text-xs'>
{new Date(message.createdAt).toLocaleString()}
</span>
</div>
<p className='text-sm whitespace-pre-wrap'>{message.content}</p>
</div>
))}
<form onSubmit={submit} className='space-y-3'>
<Textarea
name='message'
required
minLength={2}
placeholder={
activeJob
? 'Send instructions to the active agent workspace.'
: 'Add context or instructions for the next run.'
}
disabled={sending || terminalThread}
/>
<div className='flex flex-wrap items-center gap-2'>
<Button type='submit' disabled={sending || terminalThread}>
{sending
? 'Sending...'
: activeJob
? 'Send to agent'
: 'Add note'}
</Button>
{!activeJob ? (
<p className='text-muted-foreground text-xs'>
No active workspace is attached, so messages are saved as
thread notes until a run is started.
</p>
) : null}
</div>
</form>
<CardContent className='space-y-4 text-sm'>
<p className='text-muted-foreground'>
Threads open into a full workspace where you can review agent
activity, edit files, inspect diffs, and reply to the agent.
</p>
{canQueueRun ? (
<Button disabled={queueing} onClick={() => void startRun()}>
<Play className='size-4' />
{latestJob ? 'Create new workspace run' : 'Start workspace run'}
</Button>
) : (
<p className='text-muted-foreground'>
This thread does not currently have a workspace that can be
opened.
</p>
)}
</CardContent>
</Card>
+73 -5
View File
@@ -4,7 +4,7 @@ import { useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useMutation, useQuery } from 'convex/react';
import { MessageSquare, Plus } from 'lucide-react';
import { MessageSquare, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -43,7 +43,9 @@ 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);
@@ -91,6 +93,20 @@ const ThreadsPage = () => {
router.push(next.size ? `/threads?${next.toString()}` : '/threads');
};
const threadTarget = (thread: (typeof visibleThreads)[number]) =>
`/threads/${thread._id}`;
const canDeleteThread = (thread: (typeof visibleThreads)[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 ?? '')
);
};
const submitThread = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!spoonId || !prompt.trim()) return;
@@ -113,6 +129,32 @@ 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'>
@@ -304,11 +346,11 @@ const ThreadsPage = () => {
role='link'
tabIndex={0}
className='hover:border-primary/50 cursor-pointer shadow-none transition-colors'
onClick={() => router.push(`/threads/${thread._id}`)}
onClick={() => router.push(threadTarget(thread))}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
router.push(`/threads/${thread._id}`);
router.push(threadTarget(thread));
}
}}
>
@@ -340,14 +382,20 @@ const ThreadsPage = () => {
{thread.latestJobStatus ? (
<p>{thread.latestJobStatus.replaceAll('_', ' ')}</p>
) : null}
{thread.latestJobWorkspaceStatus ? (
<p>
Workspace:{' '}
{thread.latestJobWorkspaceStatus.replaceAll('_', ' ')}
</p>
) : null}
<div className='mt-2 flex justify-start gap-2 md:justify-end'>
{thread.latestAgentJobId ? (
<Button size='sm' variant='outline' asChild>
<Link
href={`/spoons/${thread.spoonId}/agent/${thread.latestAgentJobId}`}
href={threadTarget(thread)}
onClick={(event) => event.stopPropagation()}
>
Workspace
Open workspace
</Link>
</Button>
) : null}
@@ -363,6 +411,26 @@ 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>
</div>
</div>
</CardContent>