Add features & update project
Build and Push Spoon Images / quality (push) Successful in 1m41s
Build and Push Spoon Images / build-images (push) Successful in 7m4s

This commit is contained in:
Gabriel Brown
2026-06-23 02:06:58 -04:00
parent fe72fc2957
commit d207b8b0b8
26 changed files with 1257 additions and 231 deletions
+7 -5
View File
@@ -11,18 +11,20 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
const DashboardPage = () => {
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
const activeSpoons = spoons.filter(
(spoon) => spoon.status === 'active',
).length;
const behind = spoons.filter((spoon) => spoon.syncStatus === 'behind').length;
const behind = spoons.filter(
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy === 0,
).length;
const diverged = spoons.filter(
(spoon) => spoon.syncStatus === 'diverged',
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0,
).length;
const openPullRequests = spoons.reduce(
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
0,
);
@@ -70,7 +72,7 @@ const DashboardPage = () => {
<MetricCard
label='Upstream commits'
value={openPullRequests}
note='Waiting across Spoons'
note='Actionable after ignores'
icon={ShieldCheck}
/>
</div>
+12 -5
View File
@@ -32,7 +32,7 @@ const formatDate = (value?: number) =>
const SpoonsPage = () => {
const router = useRouter();
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
const active = spoons.filter((spoon) => spoon.status === 'active').length;
const needsReview = threads.filter(
@@ -41,7 +41,7 @@ const SpoonsPage = () => {
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
).length;
const upstreamWaiting = spoons.reduce(
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
0,
);
@@ -152,10 +152,16 @@ const SpoonsPage = () => {
</TableCell>
<TableCell>
<div className='text-sm'>
<p>{spoon.upstreamAheadBy ?? 0} upstream</p>
<p>{spoon.effectiveUpstreamAheadBy} actionable</p>
<p className='text-muted-foreground'>
{spoon.forkAheadBy ?? 0} fork-only
{spoon.rawUpstreamAheadBy} raw upstream ·{' '}
{spoon.forkAheadBy} fork-only
</p>
{spoon.ignoredUpstreamCount ? (
<p className='text-muted-foreground'>
{spoon.ignoredUpstreamCount} ignored
</p>
) : null}
</div>
</TableCell>
<TableCell className='capitalize'>
@@ -197,7 +203,8 @@ const SpoonsPage = () => {
{spoons.length ? (
<p className='text-muted-foreground text-sm'>
Raw upstream commits waiting across all Spoons: {upstreamWaiting}
Actionable upstream commits waiting across all Spoons:{' '}
{upstreamWaiting}
</p>
) : null}
</main>
@@ -1,9 +1,10 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useMutation, useQuery } from 'convex/react';
import { ArrowUpRight, CheckCircle2, 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';
@@ -23,28 +24,88 @@ const ThreadDetailPage = () => {
const threadId = params.threadId as Id<'threads'>;
const details = useQuery(api.threads.get, { threadId });
const messages = useQuery(api.threads.listMessages, { threadId }) ?? [];
const appendMessage = useMutation(api.threads.appendUserMessage);
const createJob = useMutation(api.agentJobs.createForThread);
const markResolved = useMutation(api.threads.markResolved);
const cancel = useMutation(api.threads.cancel);
const [sending, setSending] = useState(false);
const [queueing, setQueueing] = useState(false);
if (details === undefined) {
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
}
const { thread, spoon, latestJob } = details;
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 ||
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
latestJob.status,
) ||
['stopped', 'expired', 'failed'].includes(
latestJob.workspaceStatus ?? '',
));
const jobType =
thread.source === 'merge_conflict'
? ('conflict_resolution' as const)
: thread.source === 'upstream_update'
? ('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 {
await appendMessage({ threadId, content });
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('Message added.');
toast.success(activeJob ? 'Message sent to agent.' : 'Message added.');
} catch (error) {
console.error(error);
toast.error('Could not add message.');
toast.error('Could not send message.');
} finally {
setSending(false);
}
};
const startRun = async () => {
setQueueing(true);
try {
const jobId = await createJob({ threadId, jobType });
toast.success('Workspace run queued.');
window.location.href = `/spoons/${spoon?._id}/agent/${jobId}`;
} catch (error) {
console.error(error);
toast.error('Could not queue workspace run.');
} finally {
setQueueing(false);
}
};
@@ -99,28 +160,40 @@ const ThreadDetailPage = () => {
</a>
</Button>
) : null}
<Button
variant='outline'
onClick={() =>
markResolved({ threadId }).then(() =>
toast.success('Thread resolved.'),
)
}
>
<CheckCircle2 className='size-4' />
Resolve
</Button>
<Button
variant='outline'
onClick={() =>
cancel({ threadId }).then(() =>
toast.success('Thread cancelled.'),
)
}
>
<XCircle className='size-4' />
Cancel
</Button>
{canQueueRun ? (
<Button disabled={queueing} onClick={() => void startRun()}>
<Play className='size-4' />
{latestJob ? 'Rerun' : 'Start workspace run'}
</Button>
) : 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>
</>
) : null}
</div>
</div>
@@ -149,9 +222,28 @@ const ThreadDetailPage = () => {
name='message'
required
minLength={2}
placeholder='Add context or instructions for this thread.'
placeholder={
activeJob
? 'Send instructions to the active agent workspace.'
: 'Add context or instructions for the next run.'
}
disabled={sending || terminalThread}
/>
<Button type='submit'>Add message</Button>
<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>
</Card>
+296 -39
View File
@@ -1,28 +1,52 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useQuery } from 'convex/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useMutation, useQuery } from 'convex/react';
import { MessageSquare, Plus } 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 {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
Textarea,
} from '@spoon/ui';
const formatTime = (value: number) => new Date(value).toLocaleString();
const ThreadsPage = () => {
const router = useRouter();
const params = useSearchParams();
const source = params.get('source') ?? 'all';
const status = params.get('status') ?? 'all';
const [spoonFilter, setSpoonFilter] = useState('all');
const [priorityFilter, setPriorityFilter] = useState('all');
const [outcomeFilter, setOutcomeFilter] = useState('all');
const [spoonId, setSpoonId] = useState('');
const [title, setTitle] = useState('');
const [prompt, setPrompt] = useState('');
const [materializeEnvFile, setMaterializeEnvFile] = useState(false);
const [envFilePath, setEnvFilePath] = useState('.env.local');
const [creating, setCreating] = useState(false);
const createThread = useMutation(api.threads.createUserThread);
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
const defaultProfile = profiles.find((profile) => profile.isDefault);
const threads =
useQuery(api.threads.listMine, {
source: source as
@@ -32,8 +56,62 @@ const ThreadsPage = () => {
| 'merge_conflict'
| 'manual_review'
| 'system',
status: status as
| 'all'
| 'open'
| 'queued'
| 'running'
| 'waiting_for_user'
| 'changes_ready'
| 'draft_pr_opened'
| 'resolved'
| 'ignored'
| 'failed'
| 'cancelled',
limit: 100,
}) ?? [];
const visibleThreads = threads.filter((thread) => {
if (spoonFilter !== 'all' && thread.spoonId !== spoonFilter) return false;
if (priorityFilter !== 'all' && thread.priority !== priorityFilter) {
return false;
}
if (
outcomeFilter !== 'all' &&
(thread.maintenanceOutcome ?? 'none') !== outcomeFilter
) {
return false;
}
return true;
});
const updateFilter = (key: string, value: string) => {
const next = new URLSearchParams(params.toString());
if (value === 'all') next.delete(key);
else next.set(key, value);
router.push(next.size ? `/threads?${next.toString()}` : '/threads');
};
const submitThread = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!spoonId || !prompt.trim()) return;
setCreating(true);
try {
const threadId = await createThread({
spoonId: spoonId as Id<'spoons'>,
title: title.trim() || undefined,
prompt,
materializeEnvFile,
envFilePath,
});
toast.success('Thread created.');
router.push(`/threads/${threadId}`);
} catch (error) {
console.error(error);
toast.error('Could not create thread.');
} finally {
setCreating(false);
}
};
return (
<main className='space-y-6'>
@@ -46,20 +124,97 @@ const ThreadsPage = () => {
</p>
</div>
<Button asChild>
<Link href='/spoons'>
<a href='#new-thread'>
<Plus className='size-4' />
New thread from Spoon
</Link>
New thread
</a>
</Button>
</div>
<div className='flex flex-col gap-3 md:flex-row'>
<Card id='new-thread' className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>New thread</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={submitThread} className='grid gap-4 lg:grid-cols-2'>
<div className='grid gap-2'>
<Label>Spoon</Label>
<Select value={spoonId} onValueChange={setSpoonId}>
<SelectTrigger>
<SelectValue placeholder='Choose a Spoon' />
</SelectTrigger>
<SelectContent>
{spoons.map((spoon) => (
<SelectItem key={spoon._id} value={spoon._id}>
{spoon.name} · {spoon.upstreamOwner}/{spoon.upstreamRepo}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label>Title</Label>
<Input
value={title}
placeholder='Optional'
onChange={(event) => setTitle(event.target.value)}
/>
</div>
<div className='grid gap-2 lg:col-span-2'>
<Label>Prompt</Label>
<Textarea
value={prompt}
placeholder='Describe the change, review, or maintenance task.'
required
minLength={4}
onChange={(event) => setPrompt(event.target.value)}
/>
</div>
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
<div>
<Label>Write Spoon secrets to env file</Label>
<p className='text-muted-foreground text-xs'>
All Spoon secrets are always available as process env.
</p>
</div>
<Switch
checked={materializeEnvFile}
onCheckedChange={setMaterializeEnvFile}
/>
</div>
<div className='grid gap-2'>
<Label>Env file path</Label>
<Input
value={envFilePath}
onChange={(event) => setEnvFilePath(event.target.value)}
/>
</div>
<div className='text-muted-foreground text-sm lg:col-span-2'>
Provider:{' '}
<span className='text-foreground font-medium'>
{defaultProfile
? `${defaultProfile.name} · ${defaultProfile.defaultModel}`
: 'Configure an AI provider in Settings'}
</span>
</div>
<div className='lg:col-span-2'>
<Button
type='submit'
disabled={
creating || !spoonId || !prompt.trim() || !defaultProfile
}
>
{creating ? 'Creating...' : 'Create thread'}
</Button>
</div>
</form>
</CardContent>
</Card>
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-5'>
<Select
value={source}
onValueChange={(value) => {
window.location.href =
value === 'all' ? '/threads' : `/threads?source=${value}`;
}}
onValueChange={(value) => updateFilter('source', value)}
>
<SelectTrigger className='w-full md:w-56'>
<SelectValue />
@@ -73,43 +228,145 @@ const ThreadsPage = () => {
<SelectItem value='system'>System</SelectItem>
</SelectContent>
</Select>
<Select
value={status}
onValueChange={(value) => updateFilter('status', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All statuses</SelectItem>
<SelectItem value='open'>Open</SelectItem>
<SelectItem value='queued'>Queued</SelectItem>
<SelectItem value='running'>Running</SelectItem>
<SelectItem value='waiting_for_user'>Waiting</SelectItem>
<SelectItem value='changes_ready'>Changes ready</SelectItem>
<SelectItem value='draft_pr_opened'>Draft PR opened</SelectItem>
<SelectItem value='resolved'>Resolved</SelectItem>
<SelectItem value='ignored'>Ignored</SelectItem>
<SelectItem value='failed'>Failed</SelectItem>
<SelectItem value='cancelled'>Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={spoonFilter} onValueChange={setSpoonFilter}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Spoons</SelectItem>
{spoons.map((spoon) => (
<SelectItem key={spoon._id} value={spoon._id}>
{spoon.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All priorities</SelectItem>
<SelectItem value='low'>Low</SelectItem>
<SelectItem value='normal'>Normal</SelectItem>
<SelectItem value='high'>High</SelectItem>
</SelectContent>
</Select>
<Select value={outcomeFilter} onValueChange={setOutcomeFilter}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All outcomes</SelectItem>
<SelectItem value='none'>No outcome</SelectItem>
<SelectItem value='auto_synced'>Auto synced</SelectItem>
<SelectItem value='sync_recommended'>Sync recommended</SelectItem>
<SelectItem value='ignored'>Ignored</SelectItem>
<SelectItem value='review_pr_recommended'>Review PR</SelectItem>
<SelectItem value='manual_review_required'>
Manual review
</SelectItem>
<SelectItem value='conflict_resolution_required'>
Conflict
</SelectItem>
<SelectItem value='failed'>Failed</SelectItem>
<SelectItem value='unknown'>Unknown</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-3'>
{threads.length ? (
threads.map((thread) => (
<Link
{visibleThreads.length ? (
visibleThreads.map((thread) => (
<Card
key={thread._id}
href={`/threads/${thread._id}`}
className='block'
role='link'
tabIndex={0}
className='hover:border-primary/50 cursor-pointer shadow-none transition-colors'
onClick={() => router.push(`/threads/${thread._id}`)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
router.push(`/threads/${thread._id}`);
}
}}
>
<Card className='hover:border-primary/50 shadow-none transition-colors'>
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
<div className='min-w-0'>
<div className='flex flex-wrap items-center gap-2'>
<h2 className='truncate font-medium'>{thread.title}</h2>
<Badge variant='outline'>
{thread.source.replaceAll('_', ' ')}
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
<div className='min-w-0'>
<div className='flex flex-wrap items-center gap-2'>
<h2 className='truncate font-medium'>{thread.title}</h2>
{thread.spoonName ? (
<Badge variant='outline'>{thread.spoonName}</Badge>
) : null}
<Badge variant='outline'>
{thread.source.replaceAll('_', ' ')}
</Badge>
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
{thread.maintenanceOutcome ? (
<Badge variant='secondary'>
{thread.maintenanceOutcome.replaceAll('_', ' ')}
</Badge>
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
{thread.maintenanceOutcome ? (
<Badge variant='secondary'>
{thread.maintenanceOutcome.replaceAll('_', ' ')}
</Badge>
) : null}
</div>
<p className='text-muted-foreground mt-1 line-clamp-2 text-sm'>
{thread.summary ??
'No summary has been recorded for this thread yet.'}
</p>
) : null}
</div>
<div className='text-muted-foreground text-xs md:text-right'>
<p>{formatTime(thread.updatedAt)}</p>
<p className='capitalize'>{thread.priority} priority</p>
<p className='text-muted-foreground mt-1 line-clamp-2 text-sm'>
{thread.summary ??
'No summary has been recorded for this thread yet.'}
</p>
</div>
<div className='text-muted-foreground text-xs md:text-right'>
<p>{formatTime(thread.updatedAt)}</p>
<p className='capitalize'>{thread.priority} priority</p>
{thread.latestJobStatus ? (
<p>{thread.latestJobStatus.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}`}
onClick={(event) => event.stopPropagation()}
>
Workspace
</Link>
</Button>
) : null}
{thread.latestJobPullRequestUrl ? (
<Button size='sm' asChild>
<a
href={thread.latestJobPullRequestUrl}
target='_blank'
rel='noreferrer'
onClick={(event) => event.stopPropagation()}
>
PR
</a>
</Button>
) : null}
</div>
</CardContent>
</Card>
</Link>
</div>
</CardContent>
</Card>
))
) : (
<Card className='shadow-none'>