Move to threads based system.

This commit is contained in:
Gabriel Brown
2026-06-22 10:37:26 -04:00
parent 8ae6c4b533
commit 206b64176b
82 changed files with 6169 additions and 1930 deletions
@@ -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>