Files
spoon/apps/next/src/components/threads/thread-workspace-form.tsx
T

297 lines
10 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useMutation, useQuery } from 'convex/react';
import { MessageSquarePlus } 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,
Textarea,
} from '@spoon/ui';
type RuntimeName = 'codex' | 'opencode' | 'claude';
const runtimeLabels: Record<RuntimeName, string> = {
codex: 'Codex',
opencode: 'OpenCode',
claude: 'Claude',
};
type AgentSettings = {
defaultBaseBranch?: string;
runtime?: RuntimeName | 'openai_direct';
agentModel: string;
reasoningEffort: string;
envFilePath?: string;
customEnvFilePath?: string;
materializeEnvFileByDefault?: boolean;
aiProviderProfileId?: Id<'aiProviderProfiles'>;
};
export const ThreadWorkspaceForm = ({
spoon,
agentSettings,
}: {
spoon: Doc<'spoons'>;
agentSettings?: AgentSettings | null;
}) => {
const router = useRouter();
const secrets =
useQuery(api.spoonSecrets.listForSpoon, {
spoonId: spoon._id,
}) ?? [];
const profiles =
useQuery(api.aiProviderProfiles.listMine, {})?.filter(
(profile) => profile.enabled && profile.configured,
) ?? [];
const defaultProfile = profiles.find((profile) => profile.isDefault);
const createThread = useMutation(api.threads.createUserThread);
const [prompt, setPrompt] = useState('');
const [baseBranch, setBaseBranch] = useState(
agentSettings?.defaultBaseBranch ??
spoon.forkDefaultBranch ??
spoon.upstreamDefaultBranch,
);
const [requestedBranchName, setRequestedBranchName] = useState('');
const [materializeEnvFile, setMaterializeEnvFile] = useState(
agentSettings?.materializeEnvFileByDefault ?? false,
);
const [envFilePath, setEnvFilePath] = useState(
agentSettings?.envFilePath === 'custom'
? (agentSettings.customEnvFilePath ?? '.env.local')
: (agentSettings?.envFilePath ?? '.env.local'),
);
const [aiProviderProfileId, setAiProviderProfileId] = useState(
agentSettings?.aiProviderProfileId ?? '__settings',
);
const [runtime, setRuntime] = useState<RuntimeName | undefined>();
const [submitting, setSubmitting] = useState(false);
// The parent gates rendering on a different query than `agentSettings`, so
// this form can mount before the agent settings record resolves. Re-seed the
// settings-derived fields once the real record arrives (and again if the
// parent swaps to a different spoon) so the form reflects saved config rather
// than the initial defaults.
const hydratedSpoonId = useRef<Id<'spoons'> | null>(null);
useEffect(() => {
if (!agentSettings || hydratedSpoonId.current === spoon._id) return;
const timeout = window.setTimeout(() => {
setBaseBranch(
agentSettings.defaultBaseBranch ??
spoon.forkDefaultBranch ??
spoon.upstreamDefaultBranch,
);
setMaterializeEnvFile(agentSettings.materializeEnvFileByDefault ?? false);
setEnvFilePath(
agentSettings.envFilePath === 'custom'
? (agentSettings.customEnvFilePath ?? '.env.local')
: (agentSettings.envFilePath ?? '.env.local'),
);
setAiProviderProfileId(agentSettings.aiProviderProfileId ?? '__settings');
hydratedSpoonId.current = spoon._id;
}, 0);
return () => window.clearTimeout(timeout);
}, [agentSettings, spoon]);
const effectiveProviderProfileId =
aiProviderProfileId === '__settings'
? (agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
: aiProviderProfileId;
const hasProvider = Boolean(
effectiveProviderProfileId &&
profiles.some((profile) => profile._id === effectiveProviderProfileId),
);
const selectedProfile = profiles.find((profile) =>
aiProviderProfileId === '__settings'
? profile._id ===
(agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
: profile._id === aiProviderProfileId,
);
const supportedRuntimes = (selectedProfile?.supportedRuntimes ??
[]) as RuntimeName[];
const settingsRuntime = agentSettings?.runtime as RuntimeName | undefined;
const effectiveRuntime =
runtime && supportedRuntimes.includes(runtime)
? runtime
: settingsRuntime && supportedRuntimes.includes(settingsRuntime)
? settingsRuntime
: supportedRuntimes[0];
const runtimeValid = Boolean(
effectiveRuntime && supportedRuntimes.includes(effectiveRuntime),
);
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitting(true);
try {
const threadId = await createThread({
spoonId: spoon._id,
prompt,
baseBranch,
requestedBranchName: requestedBranchName || undefined,
runtime: effectiveRuntime,
materializeEnvFile,
envFilePath: materializeEnvFile ? envFilePath : undefined,
aiProviderProfileId:
aiProviderProfileId === '__settings'
? undefined
: (aiProviderProfileId as Id<'aiProviderProfiles'>),
});
setPrompt('');
setRequestedBranchName('');
toast.success('Thread created.');
router.push(`/threads/${threadId}`);
} catch (error) {
console.error(error);
toast.error('Could not create thread workspace.');
} finally {
setSubmitting(false);
}
};
return (
<Card className='shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='flex items-center gap-2 text-base'>
<MessageSquarePlus className='size-4' />
Create thread workspace
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={submit} className='space-y-4'>
<div className='grid gap-2'>
<Label htmlFor='threadPrompt'>Prompt</Label>
<Textarea
id='threadPrompt'
required
minLength={12}
value={prompt}
placeholder='Update this fork to use Authentik as the sole Auth.js provider.'
onChange={(event) => setPrompt(event.target.value)}
/>
</div>
<div className='grid gap-3 md:grid-cols-2'>
<div className='grid gap-2'>
<Label>Workspace runtime</Label>
<Select
value={effectiveRuntime ?? ''}
onValueChange={(value) => setRuntime(value as RuntimeName)}
disabled={!supportedRuntimes.length}
>
<SelectTrigger>
<SelectValue placeholder='Select a provider first' />
</SelectTrigger>
<SelectContent>
{supportedRuntimes.map((name) => (
<SelectItem key={name} value={name}>
{runtimeLabels[name]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label>AI provider</Label>
<Select
value={aiProviderProfileId}
onValueChange={setAiProviderProfileId}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='__settings'>
Use default
{defaultProfile ? ` (${defaultProfile.name})` : ''}
</SelectItem>
{profiles.map((profile) => (
<SelectItem key={profile._id} value={profile._id}>
{profile.name} · {profile.provider.replaceAll('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='baseBranch'>Base branch</Label>
<Input
id='baseBranch'
value={baseBranch}
onChange={(event) => setBaseBranch(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='workBranch'>Work branch</Label>
<Input
id='workBranch'
value={requestedBranchName}
placeholder='Auto-generated if blank'
onChange={(event) => setRequestedBranchName(event.target.value)}
/>
</div>
</div>
<div className='grid gap-3 md:grid-cols-[1fr_1fr]'>
<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 {secrets.length} Spoon secret(s) are available as process
env. When enabled, Spoon also writes them to this file and
refuses to commit .env files.
</p>
</div>
<Switch
checked={materializeEnvFile}
onCheckedChange={setMaterializeEnvFile}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='envFilePath'>Env file path</Label>
<Input
id='envFilePath'
value={envFilePath}
disabled={!materializeEnvFile}
onChange={(event) => setEnvFilePath(event.target.value)}
/>
</div>
</div>
<div className='bg-muted/40 grid gap-1 rounded-md p-3 text-xs'>
<span>
Model:{' '}
<strong>
{selectedProfile?.defaultModel ?? 'Configure an AI provider'}
</strong>
</span>
<span>
Reasoning:{' '}
<strong>{selectedProfile?.reasoningEffort ?? 'medium'}</strong>
</span>
</div>
<Button
type='submit'
disabled={submitting || !hasProvider || !runtimeValid}
>
{submitting ? 'Creating...' : 'Create thread'}
</Button>
</form>
</CardContent>
</Card>
);
};