Move to threads based system.

This commit is contained in:
Gabriel Brown committed 2026-06-22 10:37:26 -04:00
1 parent 8ae6c4b533
commit 206b64176b
82 files changed
+6169 -1930

No files matched your search

@@ -9,12 +9,12 @@ const formatDate = (value: number) =>
export const SpoonActivityTimeline = ({
syncRuns,
reviews,
requests,
threads,
jobs,
}: {
syncRuns: Doc<'syncRuns'>[];
reviews: Doc<'aiReviews'>[];
requests: Doc<'agentRequests'>[];
threads: Doc<'threads'>[];
jobs: Doc<'agentJobs'>[];
}) => {
const items = [
...syncRuns.map((item) => ({
@@ -24,18 +24,18 @@ export const SpoonActivityTimeline = ({
summary: item.summary ?? item.error ?? 'Sync run recorded.',
time: item.createdAt,
})),
...reviews.map((item) => ({
...threads.map((item) => ({
id: item._id,
kind: 'AI review',
kind: item.source.replaceAll('_', ' '),
status: item.status,
summary: item.outputSummary ?? item.inputSummary,
summary: item.summary ?? item.title,
time: item.createdAt,
})),
...requests.map((item) => ({
...jobs.map((item) => ({
id: item._id,
kind: 'Agent request',
kind: item.jobType?.replaceAll('_', ' ') ?? 'workspace job',
status: item.status,
summary: item.prompt,
summary: item.summary ?? item.prompt,
time: item.createdAt,
})),
].sort((a, b) => b.time - a.time);
@@ -62,7 +62,7 @@ export const SpoonActivityTimeline = ({
) : (
<Card className='shadow-none'>
<CardContent className='text-muted-foreground p-6 text-sm'>
Refreshes, AI reviews, and queued requests will build this timeline.
Refreshes, threads, and workspace jobs will build this timeline.
</CardContent>
</Card>
)}
@@ -1,11 +1,13 @@
'use client';
import { useState } from 'react';
import { useMutation } from 'convex/react';
import type { ProviderModelOption } from '@/lib/models-dev';
import { useEffect, useState } from 'react';
import { loadModelsDevOptions } from '@/lib/models-dev';
import { useMutation, useQuery } from 'convex/react';
import { Bot } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
@@ -24,17 +26,9 @@ import {
} from '@spoon/ui';
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
const modelOptions = [
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
{ value: 'gpt-5.5', label: 'GPT-5.5' },
{ value: 'gpt-5.5-pro', label: 'GPT-5.5 Pro' },
{ value: 'gpt-5.4', label: 'GPT-5.4' },
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
] as const;
type AgentModel = (typeof modelOptions)[number]['value'];
type AgentSettings = {
enabled: boolean;
runtime?: 'opencode' | 'openai_direct';
defaultBaseBranch?: string;
branchPrefix: string;
installCommand?: string;
@@ -42,13 +36,14 @@ type AgentSettings = {
testCommand?: string;
agentModel: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
envFilePath?: string;
customEnvFilePath?: string;
materializeEnvFileByDefault?: boolean;
autoDetectCommands?: boolean;
allowUserFileEditing?: boolean;
aiProviderProfileId?: Id<'aiProviderProfiles'>;
};
const toAgentModel = (value?: string): AgentModel =>
modelOptions.some((option) => option.value === value)
? (value as AgentModel)
: 'gpt-5.1-codex';
export const SpoonAgentSettingsForm = ({
spoon,
settings,
@@ -57,6 +52,13 @@ export const SpoonAgentSettingsForm = ({
settings?: AgentSettings | null;
}) => {
const update = useMutation(api.spoonAgentSettings.update);
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
const configuredProfiles = profiles.filter(
(profile) => profile.enabled && profile.configured,
);
const defaultProfile = configuredProfiles.find(
(profile) => profile.isDefault,
);
const [enabled, setEnabled] = useState(settings?.enabled ?? true);
const [defaultBaseBranch, setDefaultBaseBranch] = useState(
settings?.defaultBaseBranch ??
@@ -73,29 +75,113 @@ export const SpoonAgentSettingsForm = ({
settings?.checkCommand ?? '',
);
const [testCommand, setTestCommand] = useState(settings?.testCommand ?? '');
const [agentModel, setAgentModel] = useState<AgentModel>(
toAgentModel(settings?.agentModel),
const [envFilePath, setEnvFilePath] = useState(
settings?.envFilePath ?? '.env.local',
);
const [customEnvFilePath, setCustomEnvFilePath] = useState(
settings?.customEnvFilePath ?? '',
);
const [materializeEnvFileByDefault, setMaterializeEnvFileByDefault] =
useState(settings?.materializeEnvFileByDefault ?? false);
const [autoDetectCommands, setAutoDetectCommands] = useState(
settings?.autoDetectCommands ?? true,
);
const [allowUserFileEditing, setAllowUserFileEditing] = useState(
settings?.allowUserFileEditing ?? true,
);
const [aiProviderProfileId, setAiProviderProfileId] = useState(
settings?.aiProviderProfileId ?? '__default',
);
const selectedProfile = profiles.find(
(profile) =>
profile._id ===
(aiProviderProfileId === '__default'
? defaultProfile?._id
: aiProviderProfileId),
);
const [availableModels, setAvailableModels] = useState<ProviderModelOption[]>(
[],
);
const [agentModel, setAgentModel] = useState(
settings?.aiProviderProfileId ? settings.agentModel : '',
);
const [reasoningEffort, setReasoningEffort] = useState<
'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
>(
settings?.reasoningEffort === 'none'
? 'minimal'
: (settings?.reasoningEffort ?? 'high'),
!settings?.aiProviderProfileId
? 'medium'
: settings.reasoningEffort === 'none'
? 'minimal'
: 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 save = async () => {
try {
await update({
spoonId: spoon._id,
enabled,
runtime: 'opencode',
defaultBaseBranch,
branchPrefix,
installCommand: installCommand || undefined,
checkCommand: checkCommand || undefined,
testCommand: testCommand || undefined,
agentModel,
agentModel: agentModel.trim()
? agentModel
: (selectableModels[0]?.id ?? undefined),
reasoningEffort,
envFilePath: envFilePath as
| '.env'
| '.env.local'
| '.env.production'
| '.env.production.local'
| 'custom',
customEnvFilePath: customEnvFilePath || undefined,
materializeEnvFileByDefault,
autoDetectCommands,
allowUserFileEditing,
aiProviderProfileId:
aiProviderProfileId === '__default'
? undefined
: (aiProviderProfileId as Id<'aiProviderProfiles'>),
clearAiProviderProfile: aiProviderProfileId === '__default',
});
toast.success('Agent settings saved.');
} catch (error) {
@@ -122,6 +208,50 @@ export const SpoonAgentSettingsForm = ({
/>
</div>
<div className='grid gap-3 md:grid-cols-2'>
<div className='grid gap-2'>
<Label>Runtime</Label>
<Input value='OpenCode workspace' disabled />
</div>
<div className='grid gap-2'>
<Label>AI provider profile</Label>
<Select
value={aiProviderProfileId}
onValueChange={(value) => {
setAiProviderProfileId(value);
const nextProfile = profiles.find(
(profile) =>
profile._id ===
(value === '__default' ? defaultProfile?._id : value),
);
if (!nextProfile?.configured) setAgentModel('');
if (nextProfile?.configured) {
setReasoningEffort(
nextProfile.reasoningEffort === 'none'
? 'minimal'
: nextProfile.reasoningEffort,
);
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='__default'>
Use account default
{defaultProfile ? ` (${defaultProfile.name})` : ''}
</SelectItem>
{configuredProfiles.map((profile) => (
<SelectItem key={profile._id} value={profile._id}>
{profile.name} · {profile.provider.replaceAll('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
OpenCode jobs and maintenance review threads use this profile.
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='defaultBaseBranch'>Default base branch</Label>
<Input
@@ -142,19 +272,26 @@ export const SpoonAgentSettingsForm = ({
<Label htmlFor='agentModel'>Model</Label>
<Select
value={agentModel}
onValueChange={(value) => setAgentModel(value as AgentModel)}
onValueChange={setAgentModel}
disabled={!selectableModels.length}
>
<SelectTrigger id='agentModel'>
<SelectValue />
<SelectValue placeholder='Choose a configured model' />
</SelectTrigger>
<SelectContent>
{modelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{selectableModels.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{!selectableModels.length ? (
<p className='text-muted-foreground text-xs'>
Configure an enabled AI provider profile in Settings before
choosing a model.
</p>
) : null}
</div>
<div className='grid gap-2'>
<Label>Reasoning effort</Label>
@@ -215,8 +352,80 @@ export const SpoonAgentSettingsForm = ({
Leave blank to run the detected test script when one exists.
</p>
</div>
<div className='grid gap-2'>
<Label>Env file path</Label>
<Select value={envFilePath} onValueChange={setEnvFilePath}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='.env'>.env</SelectItem>
<SelectItem value='.env.local'>.env.local</SelectItem>
<SelectItem value='.env.production'>.env.production</SelectItem>
<SelectItem value='.env.production.local'>
.env.production.local
</SelectItem>
<SelectItem value='custom'>Custom path</SelectItem>
</SelectContent>
</Select>
</div>
{envFilePath === 'custom' ? (
<div className='grid gap-2'>
<Label htmlFor='customEnvFilePath'>Custom env path</Label>
<Input
id='customEnvFilePath'
value={customEnvFilePath}
placeholder='.env.spoon'
onChange={(event) => setCustomEnvFilePath(event.target.value)}
/>
</div>
) : null}
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
<div>
<Label>Materialize env file by default</Label>
<p className='text-muted-foreground text-xs'>
Write all Spoon secrets into the chosen .env file for new
workspaces.
</p>
</div>
<Switch
checked={materializeEnvFileByDefault}
onCheckedChange={setMaterializeEnvFileByDefault}
/>
</div>
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
<div>
<Label>Auto-detect commands</Label>
<p className='text-muted-foreground text-xs'>
Inspect package files after cloning.
</p>
</div>
<Switch
checked={autoDetectCommands}
onCheckedChange={setAutoDetectCommands}
/>
</div>
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
<div>
<Label>Allow browser file editing</Label>
<p className='text-muted-foreground text-xs'>
Let users edit workspace files manually.
</p>
</div>
<Switch
checked={allowUserFileEditing}
onCheckedChange={setAllowUserFileEditing}
/>
</div>
</div>
<Button type='button' onClick={save}>
<Button
type='button'
onClick={save}
disabled={
!selectedProfile?.configured ||
!selectableModels.some((model) => model.id === agentModel)
}
>
Save agent settings
</Button>
</CardContent>
@@ -1,75 +0,0 @@
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
export const SpoonAiReviewPanel = ({
latestReview,
reviews,
}: {
latestReview?: Doc<'aiReviews'> | null;
reviews: Doc<'aiReviews'>[];
}) => (
<div className='space-y-4'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>Latest compatibility review</CardTitle>
</CardHeader>
<CardContent>
{latestReview ? (
<div className='space-y-4'>
<div className='flex flex-wrap gap-2'>
<Badge>{latestReview.risk}</Badge>
<Badge variant='outline'>{latestReview.recommendedAction}</Badge>
{latestReview.requiresHumanReview ? (
<Badge variant='secondary'>Human review required</Badge>
) : null}
</div>
<p className='text-sm'>
{latestReview.outputSummary ?? latestReview.inputSummary}
</p>
{latestReview.reasoningSummary ? (
<p className='text-muted-foreground text-sm'>
{latestReview.reasoningSummary}
</p>
) : null}
{latestReview.potentialConflicts?.length ? (
<div>
<p className='text-sm font-medium'>Potential conflicts</p>
<ul className='text-muted-foreground mt-2 list-disc space-y-1 pl-5 text-sm'>
{latestReview.potentialConflicts.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
) : null}
</div>
) : (
<p className='text-muted-foreground text-sm'>
Run an AI review after a GitHub refresh to get compatibility notes.
</p>
)}
</CardContent>
</Card>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>Review history</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{reviews.length ? (
reviews.map((review) => (
<div key={review._id} className='border-border border p-3 text-sm'>
<div className='flex flex-wrap gap-2'>
<Badge variant='outline'>{review.status}</Badge>
<Badge variant='secondary'>{review.risk}</Badge>
</div>
<p className='mt-2'>
{review.outputSummary ?? review.inputSummary}
</p>
</div>
))
) : (
<p className='text-muted-foreground text-sm'>No AI reviews yet.</p>
)}
</CardContent>
</Card>
</div>
);
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
import { useAction } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { Brain, RefreshCw, RotateCw } from 'lucide-react';
import { RefreshCw, RotateCw } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -27,12 +27,6 @@ const syncRef = makeFunctionReference<
unknown
>('githubSync:syncForkWithUpstream');
const reviewRef = makeFunctionReference<
'action',
{ spoonId: Id<'spoons'> },
{ reviewId: Id<'aiReviews'>; risk: string; recommendedAction: string }
>('aiReviewActions:reviewLatestUpstreamChanges');
export const SpoonDetailHeader = ({
spoon,
state,
@@ -42,7 +36,6 @@ export const SpoonDetailHeader = ({
}) => {
const refresh = useAction(refreshRef);
const sync = useAction(syncRef);
const review = useAction(reviewRef);
const [busy, setBusy] = useState<string | null>(null);
const canSync =
spoon.provider === 'github' &&
@@ -110,14 +103,6 @@ export const SpoonDetailHeader = ({
<RefreshCw className='size-4' />
Refresh
</Button>
<Button
variant='outline'
onClick={() => run('AI review', () => review({ spoonId: spoon._id }))}
disabled={Boolean(busy)}
>
<Brain className='size-4' />
Review with AI
</Button>
<Button
onClick={() => run('Sync', () => sync({ spoonId: spoon._id }))}
disabled={Boolean(busy) || !canSync}
@@ -2,7 +2,7 @@ import {
Clock,
GitCommit,
GitPullRequest,
ShieldCheck,
MessagesSquare,
TrendingUp,
} from 'lucide-react';
@@ -17,11 +17,11 @@ const formatDate = (value?: number) =>
export const SpoonMetrics = ({
spoon,
state,
latestReview,
latestThread,
}: {
spoon: Doc<'spoons'>;
state?: Doc<'spoonRepositoryStates'> | null;
latestReview?: Doc<'aiReviews'> | null;
latestThread?: Doc<'threads'> | null;
}) => {
const metrics = [
{
@@ -42,9 +42,9 @@ export const SpoonMetrics = ({
icon: GitPullRequest,
},
{
label: 'Latest AI risk',
value: latestReview?.risk ?? 'unknown',
icon: ShieldCheck,
label: 'Latest thread',
value: latestThread?.status.replaceAll('_', ' ') ?? 'none',
icon: MessagesSquare,
},
{
label: 'Last check',
@@ -93,7 +93,7 @@ export const SpoonSettingsForm = ({
onChange: setAutoRefreshEnabled,
},
{
label: 'Auto AI review',
label: 'Auto maintenance threads',
value: autoReviewEnabled,
onChange: setAutoReviewEnabled,
},
@@ -103,7 +103,7 @@ export const SpoonSettingsForm = ({
onChange: setAutoSyncEnabled,
},
{
label: 'Require low AI risk for sync',
label: 'Require low-risk thread decision for sync',
value: requireAiLowRiskForSync,
onChange: setRequireAiLowRiskForSync,
},