Files
spoon/apps/next/src/components/spoons/spoon-agent-settings-form.tsx
T
Gabriel Brown d207b8b0b8
Build and Push Spoon Images / quality (push) Successful in 1m41s
Build and Push Spoon Images / build-images (push) Successful in 7m4s
Add features & update project
2026-06-23 02:06:58 -04:00

412 lines
14 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { Bot } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
} from '@spoon/ui';
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
type AgentSettings = {
enabled: boolean;
runtime?: 'opencode' | 'openai_direct';
defaultBaseBranch?: string;
branchPrefix: string;
installCommand?: string;
checkCommand?: string;
testCommand?: string;
agentModel: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
envFilePath?: string;
customEnvFilePath?: string;
materializeEnvFileByDefault?: boolean;
autoDetectCommands?: boolean;
allowUserFileEditing?: boolean;
aiProviderProfileId?: Id<'aiProviderProfiles'>;
};
export const SpoonAgentSettingsForm = ({
spoon,
settings,
}: {
spoon: Doc<'spoons'>;
settings?: AgentSettings | null;
}) => {
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,
);
const defaultProfile = configuredProfiles.find(
(profile) => profile.isDefault,
);
const [enabled, setEnabled] = useState(settings?.enabled ?? true);
const [defaultBaseBranch, setDefaultBaseBranch] = useState(
settings?.defaultBaseBranch ??
spoon.forkDefaultBranch ??
spoon.upstreamDefaultBranch,
);
const [branchPrefix, setBranchPrefix] = useState(
settings?.branchPrefix ?? 'spoon/agent',
);
const [installCommand, setInstallCommand] = useState(
settings?.installCommand ?? '',
);
const [checkCommand, setCheckCommand] = useState(
settings?.checkCommand ?? '',
);
const [testCommand, setTestCommand] = useState(settings?.testCommand ?? '');
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 selectedModelProfile = modelCatalog?.profiles.find(
(profile) =>
profile.profileId ===
(aiProviderProfileId === '__default'
? defaultProfile?._id
: aiProviderProfileId),
);
const [agentModel, setAgentModel] = useState(
settings?.aiProviderProfileId ? settings.agentModel : '',
);
const [reasoningEffort, setReasoningEffort] = useState<
'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
>(
!settings?.aiProviderProfileId
? 'medium'
: settings.reasoningEffort === 'none'
? 'minimal'
: settings.reasoningEffort,
);
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 {
await update({
spoonId: spoon._id,
enabled,
runtime: 'opencode',
defaultBaseBranch,
branchPrefix,
installCommand: installCommand || undefined,
checkCommand: checkCommand || undefined,
testCommand: testCommand || undefined,
agentModel: selectedAgentModel || 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) {
console.error(error);
toast.error('Could not save agent settings.');
}
};
return (
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-base'>
<Bot className='size-4' />
Agent runtime
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between gap-4'>
<Label htmlFor='agentEnabled'>Enable agent jobs</Label>
<Switch
id='agentEnabled'
checked={enabled}
onCheckedChange={setEnabled}
/>
</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'>
Workspaces use this profile. Use default resolves to your account
default provider.
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='defaultBaseBranch'>Default base branch</Label>
<Input
id='defaultBaseBranch'
value={defaultBaseBranch}
onChange={(event) => setDefaultBaseBranch(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='branchPrefix'>Branch prefix</Label>
<Input
id='branchPrefix'
value={branchPrefix}
onChange={(event) => setBranchPrefix(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='agentModel'>Model</Label>
<Select
value={selectedAgentModel}
onValueChange={setAgentModel}
disabled={!selectableModels.length}
>
<SelectTrigger id='agentModel'>
<SelectValue placeholder='Choose a configured model' />
</SelectTrigger>
<SelectContent>
{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 with saved model
options in Settings before choosing a model.
</p>
) : null}
</div>
<div className='grid gap-2'>
<Label>Reasoning effort</Label>
<Select
value={reasoningEffort}
onValueChange={(value) =>
setReasoningEffort(
value as 'minimal' | 'low' | 'medium' | 'high' | 'xhigh',
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{efforts.map((effort) => (
<SelectItem key={effort} value={effort}>
{effort}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='installCommand'>Install command</Label>
<Input
id='installCommand'
value={installCommand}
placeholder='Auto-detect from lockfile'
onChange={(event) => setInstallCommand(event.target.value)}
/>
<p className='text-muted-foreground text-xs'>
Leave blank to inspect the repository and choose bun, pnpm, yarn,
or npm.
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='checkCommand'>Check command</Label>
<Input
id='checkCommand'
value={checkCommand}
placeholder='Auto-detect typecheck or lint'
onChange={(event) => setCheckCommand(event.target.value)}
/>
<p className='text-muted-foreground text-xs'>
Leave blank to read package.json scripts after cloning.
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='testCommand'>Test command</Label>
<Input
id='testCommand'
value={testCommand}
placeholder='Auto-detect test script'
onChange={(event) => setTestCommand(event.target.value)}
/>
<p className='text-muted-foreground text-xs'>
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}
disabled={
!selectedProfile?.configured ||
!selectableModels.some((model) => model.id === selectedAgentModel)
}
>
Save agent settings
</Button>
</CardContent>
</Card>
);
};