297 lines
10 KiB
TypeScript
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>
|
|
);
|
|
};
|