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'>
@@ -0,0 +1,81 @@
import { NextResponse } from 'next/server';
import { proxyWorker } from '@/lib/agent-worker-proxy';
import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server';
import { fetchMutation, fetchQuery } from 'convex/nextjs';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
const activeJobStatuses = new Set([
'claimed',
'preparing',
'running',
'checks_running',
'changes_ready',
]);
const activeWorkspaceStatuses = new Set(['active', 'idle']);
export const POST = async (
request: Request,
context: { params: Promise<{ threadId: string }> },
) => {
try {
const token = await convexAuthNextjsToken();
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { threadId: rawThreadId } = await context.params;
const threadId = rawThreadId as Id<'threads'>;
const body = (await request.json()) as { content?: string };
const content = body.content?.trim() ?? '';
if (!content) {
return NextResponse.json(
{ error: 'Message is required.' },
{ status: 400 },
);
}
const details = await fetchQuery(api.threads.get, { threadId }, { token });
const latestJob = details.latestJob;
const canSendToWorker =
latestJob &&
activeJobStatuses.has(latestJob.status) &&
activeWorkspaceStatuses.has(latestJob.workspaceStatus ?? '');
if (!canSendToWorker) {
await fetchMutation(
api.threads.appendUserMessage,
{ threadId, content },
{ token },
);
return NextResponse.json({
success: true,
mode: 'note',
message: latestJob
? 'Message was added as a thread note because the latest workspace is not active.'
: 'Message was added as a thread note.',
});
}
const proxied = await proxyWorker(latestJob._id, 'message', {
method: 'POST',
body: JSON.stringify({ content }),
});
if (!proxied.ok) {
const text = await proxied.text();
return NextResponse.json(
{
error: text,
recoverable:
text.includes('workspace is not active') ||
text.includes('not active on this worker'),
},
{ status: proxied.status === 500 ? 409 : proxied.status },
);
}
return proxied;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: message }, { status: 500 });
}
};
@@ -13,12 +13,14 @@ export const AgentThread = ({
events,
interactions,
disabled,
agentTurnActive,
}: {
jobId: string;
messages: Doc<'agentJobMessages'>[];
events: Doc<'agentJobEvents'>[];
interactions: Doc<'agentInteractionRequests'>[];
disabled: boolean;
agentTurnActive: boolean;
}) => {
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
@@ -94,7 +96,7 @@ export const AgentThread = ({
type='button'
variant='outline'
size='sm'
disabled={disabled}
disabled={disabled || !agentTurnActive}
onClick={abort}
>
<Ban className='size-3' />
@@ -6,7 +6,7 @@ import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
import { Button, Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
import type { DiffResponse, FileResponse, FileTreeNode } from './types';
import { AgentThread } from './agent-thread';
@@ -40,6 +40,9 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}) ?? [];
const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId });
const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
const createJobForThread = useMutation(api.agentJobs.createForThread);
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
const markWorkspaceLost = useMutation(api.agentJobs.markWorkspaceLost);
const [tree, setTree] = useState<FileTreeNode | null>(null);
const [files, setFiles] = useState<Record<string, OpenFileState>>({});
const [openFilePaths, setOpenFilePaths] = useState<string[]>([]);
@@ -50,6 +53,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const [vimEnabled, setVimEnabled] = useState(false);
const [hydratedUiState, setHydratedUiState] = useState(false);
const [diff, setDiff] = useState('');
const [workspaceError, setWorkspaceError] = useState<string>();
const [agentTurnActive, setAgentTurnActive] = useState(false);
const workspaceDisabled =
!job ||
@@ -62,6 +67,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as { tree: FileTreeNode | null };
setWorkspaceError(undefined);
setTree(data.tree);
}, [jobId]);
@@ -69,9 +75,20 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as DiffResponse;
setWorkspaceError(undefined);
setDiff(data.diff);
}, [jobId]);
const loadAgentStatus = useCallback(async () => {
const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
if (!response.ok) {
setAgentTurnActive(false);
return;
}
const data = (await response.json()) as { active?: boolean };
setAgentTurnActive(Boolean(data.active));
}, [jobId]);
const loadFile = useCallback(
async (path: string) => {
setFiles((current) => ({
@@ -132,13 +149,27 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const timeout = window.setTimeout(() => {
void loadTree().catch((error: unknown) => {
console.error(error);
setWorkspaceError(
error instanceof Error ? error.message : String(error),
);
});
void loadDiff().catch((error: unknown) => {
console.error(error);
setWorkspaceError(
error instanceof Error ? error.message : String(error),
);
});
void loadAgentStatus();
}, 0);
return () => window.clearTimeout(timeout);
}, [job, loadDiff, loadTree]);
}, [job, loadAgentStatus, loadDiff, loadTree]);
useEffect(() => {
const interval = window.setInterval(() => {
void loadAgentStatus();
}, 5_000);
return () => window.clearInterval(interval);
}, [loadAgentStatus]);
useEffect(() => {
if (!uiState || hydratedUiState) return;
@@ -197,6 +228,23 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
const recoverWorkspace = async () => {
if (!job.threadId) return;
const newJobId = await createJobForThread({
threadId: job.threadId,
jobType: job.jobType ?? 'user_change',
});
window.location.href = `/spoons/${job.spoonId}/agent/${newJobId}`;
};
const deleteStaleWorkspace = async () => {
if (!window.confirm('Delete this stale workspace record?')) return;
await markWorkspaceLost({ jobId });
await deleteWorkspace({ jobId });
window.location.href = job.threadId
? `/threads/${job.threadId}`
: `/spoons/${job.spoonId}`;
};
const saveFile = async (content: string) => {
if (!activeFilePath) return;
@@ -280,6 +328,35 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
return (
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
<JobStatusBar job={job} />
{workspaceError ? (
<div className='border-border bg-background border-b p-4'>
<div className='border-destructive/40 bg-destructive/5 rounded-md border p-4'>
<p className='font-medium'>Workspace not active on this worker</p>
<p className='text-muted-foreground mt-1 text-sm'>
{workspaceError}
</p>
<div className='mt-3 flex flex-wrap gap-2'>
{job.threadId ? (
<Button type='button' onClick={() => void recoverWorkspace()}>
Recreate workspace run
</Button>
) : null}
<Button
type='button'
variant='outline'
onClick={() => void deleteStaleWorkspace()}
>
Delete stale workspace
</Button>
{job.threadId ? (
<Button type='button' variant='outline' asChild>
<a href={`/threads/${job.threadId}`}>Open thread</a>
</Button>
) : null}
</div>
</div>
</div>
) : null}
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
<WorkspaceActions job={job} disabled={workspaceDisabled} />
</div>
@@ -362,6 +439,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
events={events}
interactions={interactions}
disabled={workspaceDisabled}
agentTurnActive={agentTurnActive}
/>
</TabsContent>
</Tabs>
@@ -374,6 +452,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
events={events}
interactions={interactions}
disabled={workspaceDisabled}
agentTurnActive={agentTurnActive}
/>
</aside>
</div>
@@ -1,8 +1,12 @@
'use client';
import type { ProviderModelOption } from '@/lib/models-dev';
import { useEffect, useMemo, useState } from 'react';
import { loadModelsDevOptions } from '@/lib/models-dev';
import type { ProviderModelOption } from '@/lib/provider-model-options';
import { useMemo, useState } from 'react';
import {
modelOptionsFromIds,
suggestedModelOptions,
supportsCustomModelOptions,
} from '@/lib/provider-model-options';
import { useAction, useMutation, useQuery } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { KeyRound, Trash2 } from 'lucide-react';
@@ -11,6 +15,7 @@ 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,
@@ -50,6 +55,7 @@ const saveProfileRef = makeFunctionReference<
secret?: string;
baseUrl?: string;
defaultModel: string;
modelOptions?: string[];
reasoningEffort: ReasoningEffort;
enabled: boolean;
},
@@ -119,33 +125,24 @@ export const AiProviderProfilesPanel = () => {
);
const [secret, setSecret] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [defaultModelValue, setDefaultModelValue] = useState('');
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>([]);
const [defaultModelValue, setDefaultModelValue] = useState(
suggestedModelOptions('openai')[0]?.id ?? '',
);
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>(
suggestedModelOptions('openai'),
);
const [customModelId, setCustomModelId] = useState('');
const [reasoningEffort, setReasoningEffort] =
useState<ReasoningEffort>('medium');
const [enabled, setEnabled] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
loadModelsDevOptions(provider)
.then((options) => {
if (cancelled) return;
setModelOptions(options);
setDefaultModelValue((current) =>
current && options.some((option) => option.id === current)
? current
: (options[0]?.id ?? ''),
);
})
.catch((error: unknown) => {
console.error(error);
if (!cancelled) setModelOptions([]);
});
return () => {
cancelled = true;
};
}, [provider]);
const resetModelOptions = (nextProvider: Provider) => {
const options = suggestedModelOptions(nextProvider);
setModelOptions(options);
setDefaultModelValue(options[0]?.id ?? '');
setCustomModelId('');
};
const reset = () => {
setProfileId(undefined);
@@ -153,6 +150,8 @@ export const AiProviderProfilesPanel = () => {
setSecret('');
setBaseUrl('');
setDefaultModelValue('');
setModelOptions(suggestedModelOptions('openai'));
setCustomModelId('');
setReasoningEffort('medium');
setEnabled(true);
setName('OpenAI');
@@ -165,6 +164,14 @@ export const AiProviderProfilesPanel = () => {
setSecret('');
setBaseUrl(profile.baseUrl ?? '');
setDefaultModelValue(profile.defaultModel);
setModelOptions(
modelOptionsFromIds(
profile.modelOptions?.length
? profile.modelOptions
: [profile.defaultModel],
),
);
setCustomModelId('');
setReasoningEffort(profile.reasoningEffort as ReasoningEffort);
setEnabled(profile.enabled);
};
@@ -181,6 +188,7 @@ export const AiProviderProfilesPanel = () => {
secret: secret.trim() ? secret : undefined,
baseUrl: baseUrl.trim() || undefined,
defaultModel: defaultModelValue,
modelOptions: modelOptions.map((model) => model.id),
reasoningEffort,
enabled,
});
@@ -310,6 +318,7 @@ export const AiProviderProfilesPanel = () => {
onValueChange={(value) => {
const nextProvider = value as Provider;
setProvider(nextProvider);
resetModelOptions(nextProvider);
setName(
providerOptions
.find((option) => option.value === nextProvider)
@@ -397,9 +406,47 @@ export const AiProviderProfilesPanel = () => {
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
Models are loaded from Models.dev, the catalog OpenCode uses
for provider/model metadata.
Saved model options are used by Spoons. Add custom model IDs
for compatible provider gateways.
</p>
<div className='rounded-md border p-2'>
<p className='text-muted-foreground mb-2 text-xs'>
Available model options
</p>
<div className='flex flex-wrap gap-2'>
{modelOptions.map((model) => (
<Badge key={model.id} variant='outline'>
{model.id}
</Badge>
))}
</div>
</div>
{supportsCustomModelOptions(provider) ? (
<div className='flex gap-2'>
<Input
value={customModelId}
placeholder='provider/model-id'
onChange={(event) => setCustomModelId(event.target.value)}
/>
<Button
type='button'
variant='outline'
onClick={() => {
const id = customModelId.trim();
if (!id) return;
setModelOptions((current) =>
current.some((model) => model.id === id)
? current
: [...current, ...modelOptionsFromIds([id])],
);
setDefaultModelValue((current) => current || id);
setCustomModelId('');
}}
>
Add
</Button>
</div>
) : null}
</div>
<div className='grid gap-2'>
<Label>Thinking</Label>
@@ -75,7 +75,7 @@ const features = [
{
title: 'Provider-owned AI',
description:
'Use encrypted provider profiles, OpenCode auth, or user-owned API keys rather than a shared application key.',
'Use encrypted provider profiles: API-key providers run through OpenCode, and Codex login profiles run through the Codex CLI.',
icon: KeyRound,
},
{
@@ -119,7 +119,7 @@ const ownership = [
{
title: 'Your providers',
description:
'AI provider profiles and Codex/OpenCode auth stay encrypted and selected by you.',
'AI provider profiles, API keys, and Codex auth JSON stay encrypted and selected by you.',
icon: ShieldCheck,
},
{
@@ -63,6 +63,14 @@ export default function Footer() {
Integrations
</Link>
</li>
<li>
<Link
href='/settings/worker'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Worker
</Link>
</li>
<li>
<Link
href='https://git.gbrown.org/gib/spoon'
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { RefreshCw, Trash2, Wrench } from 'lucide-react';
import { Copy, RefreshCw, Trash2, Wrench } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
@@ -47,6 +47,25 @@ export const WorkerHealthPanel = () => {
useQuery(api.agentJobs.countOldWorkspaces, { olderThanDays }) ?? 0;
const deleteOldWorkspaces = useMutation(api.agentJobs.deleteOldWorkspaces);
const copy = async (value: string) => {
await navigator.clipboard.writeText(value);
toast.success('Copied.');
};
const DiagnosticValue = ({ value }: { value: string }) => (
<dd className='flex items-center gap-2 font-mono break-all'>
<span>{value}</span>
<Button
type='button'
variant='ghost'
size='icon'
onClick={() => void copy(value)}
>
<Copy className='size-3' />
</Button>
</dd>
);
const refreshHealth = async () => {
setLoadingHealth(true);
setHealthError(undefined);
@@ -151,15 +170,15 @@ export const WorkerHealthPanel = () => {
<dl className='grid gap-3 text-sm md:grid-cols-2'>
<div>
<dt className='text-muted-foreground'>Convex</dt>
<dd className='font-mono break-all'>{health.convexUrl}</dd>
<DiagnosticValue value={health.convexUrl} />
</div>
<div>
<dt className='text-muted-foreground'>Job image</dt>
<dd className='font-mono break-all'>{health.jobImage}</dd>
<DiagnosticValue value={health.jobImage} />
</div>
<div>
<dt className='text-muted-foreground'>Workdir</dt>
<dd className='font-mono break-all'>{health.workdir}</dd>
<DiagnosticValue value={health.workdir} />
</div>
<div>
<dt className='text-muted-foreground'>Network</dt>
@@ -1,8 +1,6 @@
'use client';
import type { ProviderModelOption } from '@/lib/models-dev';
import { useEffect, useState } from 'react';
import { loadModelsDevOptions } from '@/lib/models-dev';
import { useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { Bot } from 'lucide-react';
import { toast } from 'sonner';
@@ -53,6 +51,7 @@ export const SpoonAgentSettingsForm = ({
}) => {
const update = useMutation(api.spoonAgentSettings.update);
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
const modelCatalog = useQuery(api.aiProviderModels.listAvailableForUser);
const configuredProfiles = profiles.filter(
(profile) => profile.enabled && profile.configured,
);
@@ -99,8 +98,12 @@ export const SpoonAgentSettingsForm = ({
? defaultProfile?._id
: aiProviderProfileId),
);
const [availableModels, setAvailableModels] = useState<ProviderModelOption[]>(
[],
const selectedModelProfile = modelCatalog?.profiles.find(
(profile) =>
profile.profileId ===
(aiProviderProfileId === '__default'
? defaultProfile?._id
: aiProviderProfileId),
);
const [agentModel, setAgentModel] = useState(
settings?.aiProviderProfileId ? settings.agentModel : '',
@@ -115,42 +118,17 @@ export const SpoonAgentSettingsForm = ({
: settings.reasoningEffort,
);
useEffect(() => {
if (!selectedProfile?.configured) {
return;
}
let cancelled = false;
loadModelsDevOptions(selectedProfile.provider)
.then((models) => {
if (cancelled) return;
setAvailableModels(models);
setAgentModel((current) =>
current && models.some((model) => model.id === current)
? current
: models.some((model) => model.id === selectedProfile.defaultModel)
? selectedProfile.defaultModel
: (models[0]?.id ?? ''),
);
setReasoningEffort(
selectedProfile.reasoningEffort === 'none'
? 'minimal'
: selectedProfile.reasoningEffort,
);
})
.catch((error: unknown) => {
console.error(error);
if (!cancelled) setAvailableModels([]);
});
return () => {
cancelled = true;
};
}, [
selectedProfile?.configured,
selectedProfile?.defaultModel,
selectedProfile?.provider,
selectedProfile?.reasoningEffort,
]);
const selectableModels = selectedProfile?.configured ? availableModels : [];
const selectableModels = selectedModelProfile?.configured
? selectedModelProfile.models
: [];
const selectedAgentModel =
agentModel && selectableModels.some((model) => model.id === agentModel)
? agentModel
: selectableModels.some(
(model) => model.id === selectedModelProfile?.defaultModel,
)
? (selectedModelProfile?.defaultModel ?? '')
: (selectableModels[0]?.id ?? '');
const save = async () => {
try {
@@ -163,9 +141,7 @@ export const SpoonAgentSettingsForm = ({
installCommand: installCommand || undefined,
checkCommand: checkCommand || undefined,
testCommand: testCommand || undefined,
agentModel: agentModel.trim()
? agentModel
: (selectableModels[0]?.id ?? undefined),
agentModel: selectedAgentModel || undefined,
reasoningEffort,
envFilePath: envFilePath as
| '.env'
@@ -249,7 +225,8 @@ export const SpoonAgentSettingsForm = ({
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
OpenCode jobs and maintenance review threads use this profile.
Workspaces use this profile. Use default resolves to your account
default provider.
</p>
</div>
<div className='grid gap-2'>
@@ -271,7 +248,7 @@ export const SpoonAgentSettingsForm = ({
<div className='grid gap-2'>
<Label htmlFor='agentModel'>Model</Label>
<Select
value={agentModel}
value={selectedAgentModel}
onValueChange={setAgentModel}
disabled={!selectableModels.length}
>
@@ -288,8 +265,8 @@ export const SpoonAgentSettingsForm = ({
</Select>
{!selectableModels.length ? (
<p className='text-muted-foreground text-xs'>
Configure an enabled AI provider profile in Settings before
choosing a model.
Configure an enabled AI provider profile with saved model
options in Settings before choosing a model.
</p>
) : null}
</div>
@@ -423,7 +400,7 @@ export const SpoonAgentSettingsForm = ({
onClick={save}
disabled={
!selectedProfile?.configured ||
!selectableModels.some((model) => model.id === agentModel)
!selectableModels.some((model) => model.id === selectedAgentModel)
}
>
Save agent settings
+16 -3
View File
@@ -9,7 +9,13 @@ const formatDate = (value?: number) =>
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
: 'Never';
export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
type SpoonCardData = Doc<'spoons'> & {
rawUpstreamAheadBy?: number;
effectiveUpstreamAheadBy?: number;
ignoredUpstreamCount?: number;
};
export const SpoonCard = ({ spoon }: { spoon: SpoonCardData }) => (
<Link href={`/spoons/${spoon._id}`} className='group/spoon-card block'>
<Card className='group-hover/spoon-card:border-primary/50 group-hover/spoon-card:bg-muted/20 shadow-none transition-colors'>
<CardHeader className='flex-row items-start justify-between gap-4'>
@@ -45,8 +51,15 @@ export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
<p className='font-medium'>{formatDate(spoon.lastCheckedAt)}</p>
</div>
<div>
<p className='text-muted-foreground'>Upstream waiting</p>
<p className='font-medium'>{spoon.upstreamAheadBy ?? 0}</p>
<p className='text-muted-foreground'>Actionable upstream</p>
<p className='font-medium'>
{spoon.effectiveUpstreamAheadBy ?? spoon.upstreamAheadBy ?? 0}
</p>
{spoon.ignoredUpstreamCount ? (
<p className='text-muted-foreground text-xs'>
{spoon.ignoredUpstreamCount} ignored
</p>
) : null}
</div>
<div>
<p className='text-muted-foreground'>Fork-only commits</p>
-56
View File
@@ -1,56 +0,0 @@
type ModelsDevModel = {
id?: string;
name?: string;
tool_call?: boolean;
reasoning?: boolean;
limit?: { context?: number };
};
type ModelsDevProvider = {
id?: string;
name?: string;
models?: Record<string, ModelsDevModel>;
};
const providerMap = {
openai: 'openai',
anthropic: 'anthropic',
google: 'google',
openrouter: 'openrouter',
requesty: 'requesty',
litellm: 'litellm',
cloudflare_ai_gateway: 'cloudflare',
custom_openai_compatible: '',
opencode_openai_login: 'openai',
} as const;
export type ProviderModelOption = {
id: string;
label: string;
reasoning: boolean;
toolCall: boolean;
context?: number;
};
export const loadModelsDevOptions = async (provider: string) => {
const mapped = providerMap[provider as keyof typeof providerMap];
if (!mapped) return [];
const response = await fetch('https://models.dev/api.json', {
cache: 'force-cache',
});
if (!response.ok) return [];
const catalog = (await response.json()) as Record<string, ModelsDevProvider>;
const providerCatalog = catalog[mapped];
return Object.entries(providerCatalog?.models ?? {})
.map(
([id, model]): ProviderModelOption => ({
id: model.id ?? id,
label: model.name ?? model.id ?? id,
reasoning: Boolean(model.reasoning),
toolCall: Boolean(model.tool_call),
context: model.limit?.context,
}),
)
.filter((model) => model.toolCall)
.sort((a, b) => a.label.localeCompare(b.label));
};
@@ -0,0 +1,72 @@
export type ProviderModelOption = {
id: string;
label: string;
reasoning: boolean;
toolCall: boolean;
context?: number;
};
const options = {
openai: ['gpt-5.1-codex', 'gpt-5.1', 'gpt-5', 'gpt-5-mini'],
opencode_openai_login: ['gpt-5.1-codex', 'gpt-5.1', 'gpt-5'],
anthropic: ['claude-sonnet-4-5', 'claude-opus-4-5', 'claude-haiku-4-5'],
google: ['gemini-3-pro', 'gemini-2.5-pro', 'gemini-2.5-flash'],
openrouter: ['openai/gpt-5.1-codex', 'anthropic/claude-sonnet-4-5'],
requesty: ['openai/gpt-5.1-codex', 'anthropic/claude-sonnet-4-5'],
litellm: ['openai/gpt-5.1-codex', 'anthropic/claude-sonnet-4-5'],
cloudflare_ai_gateway: ['openai/gpt-5.1-codex'],
custom_openai_compatible: ['gpt-5.1-codex'],
} as const;
export type ProviderModelKey = keyof typeof options;
const modelOptionsByProvider: Record<string, readonly string[]> = options;
const labelForModel = (id: string) => {
const label = id
.split('/')
.at(-1)
?.replaceAll('-', ' ')
.replace(/\b\w/g, (letter) => letter.toUpperCase());
return label ?? id;
};
export const suggestedModelOptions = (
provider: string,
): ProviderModelOption[] =>
(modelOptionsByProvider[provider] ?? []).map((id) => ({
id,
label: labelForModel(id),
reasoning: true,
toolCall: true,
}));
export const modelOptionsFromIds = (
ids: string[] | undefined,
): ProviderModelOption[] =>
(ids ?? [])
.map((id) => id.trim())
.filter(Boolean)
.filter((id, index, all) => all.indexOf(id) === index)
.map((id) => ({
id,
label: labelForModel(id),
reasoning: true,
toolCall: true,
}));
export const modelIdsForProfile = (profile?: {
defaultModel?: string;
modelOptions?: string[];
}) =>
[profile?.defaultModel, ...(profile?.modelOptions ?? [])]
.filter((model): model is string => Boolean(model?.trim()))
.filter((model, index, all) => all.indexOf(model) === index);
export const supportsCustomModelOptions = (provider: string) =>
[
'openrouter',
'requesty',
'litellm',
'cloudflare_ai_gateway',
'custom_openai_compatible',
].includes(provider);
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import {
modelIdsForProfile,
modelOptionsFromIds,
suggestedModelOptions,
supportsCustomModelOptions,
} from '../../src/lib/provider-model-options';
describe('provider model options', () => {
it('returns stored profile model ids without duplicates', () => {
expect(
modelIdsForProfile({
defaultModel: 'gpt-5.1-codex',
modelOptions: ['gpt-5.1-codex', 'gpt-5'],
}),
).toEqual(['gpt-5.1-codex', 'gpt-5']);
});
it('provides local suggestions for built-in providers', () => {
expect(
suggestedModelOptions('openai').some(
(model) => model.id === 'gpt-5.1-codex',
),
).toBe(true);
});
it('supports custom model ids only for gateway-style providers', () => {
expect(supportsCustomModelOptions('openrouter')).toBe(true);
expect(supportsCustomModelOptions('openai')).toBe(false);
});
it('normalizes model ids into select options', () => {
expect(modelOptionsFromIds(['openai/gpt-5.1-codex'])[0]).toMatchObject({
id: 'openai/gpt-5.1-codex',
label: 'Gpt 5.1 Codex',
});
});
});