Repository Public
1'use client';2import { useEffect, useMemo, useState } from 'react';3import { useRouter } from 'next/navigation';4import { useQueryClient } from '@tanstack/react-query';5import { Globe, GlobeLock, Plus, TriangleAlert, X } from 'lucide-react';6import type { CreateMission, Effort, MissionDto, ModelInfo, ProjectDto, Provider } from '@aos/shared';7import { api, errorMessage } from '@/lib/api';8import { qk, upsertMission, useModels, useProjects, useSettings, useSpend } from '@/lib/queries';9import { useUi } from '@/stores/ui';10import { toast } from '@/stores/toasts';11import { EFFORT_LABEL, PROVIDER_LABEL, TIER_LABEL } from '@/lib/status';12import { DASH, fmtUsd } from '@/lib/format';13import { Dialog } from '@/components/ui/dialog';14import { Button, IconButton } from '@/components/ui/button';15import { Field, Input, Textarea } from '@/components/ui/input';16import { Select } from '@/components/ui/select';17import { Switch } from '@/components/ui/switch';18import { Badge } from '@/components/ui/badge';19import { Skeleton } from '@/components/ui/misc';20import { cn } from '@/lib/cn';2122const NEW = '__new__';23const price = (n: number | null) => (n == null ? DASH : `$${n < 1 ? n.toFixed(2) : n % 1 === 0 ? n.toFixed(0) : n.toFixed(2)}`);2425/** Model picker: grouped by provider, tier label, price per million tokens; unavailable models are disabled with a note. */26export function ModelPicker({ models, value, onChange, loading }: { models: ModelInfo[]; value: string | null; onChange: (m: ModelInfo) => void; loading?: boolean }) {27 const groups = useMemo(() => (['anthropic', 'openai'] as Provider[]).map((p) => ({ provider: p, items: models.filter((m) => m.provider === p) })).filter((g) => g.items.length > 0), [models]);28 if (loading) return <Skeleton className="h-28" />;29 if (groups.length === 0) return <p className="rounded-sm bg-surface-2 px-3 py-2.5 text-xs text-text-2">No model is available. Check the provider keys in System.</p>;30 return (31 <div role="radiogroup" aria-label="Model" className="flex flex-col gap-3" data-testid="model-picker">32 {groups.map((g) => (33 <div key={g.provider}>34 <p className="mb-1.5 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-muted">35 {PROVIDER_LABEL[g.provider]}36 <span className="font-normal normal-case tracking-normal text-faint">USD per million tokens, input / output</span>37 </p>38 <div className="grid gap-1.5 sm:grid-cols-2">39 {g.items.map((m) => {40 const on = value === m.id;41 return (42 <button43 key={m.id}44 type="button"45 role="radio"46 aria-checked={on}47 disabled={!m.available}48 onClick={() => onChange(m)}49 data-testid={`model-${m.id}`}50 className={cn(51 'flex min-h-[52px] flex-col items-start gap-0.5 rounded-[12px] border px-3 py-2 text-left transition-colors',52 on ? 'border-accent bg-accent/[0.07] ring-[3px] ring-accent/15' : 'border-line-2 bg-surface hover:border-line-3',53 !m.available && 'cursor-not-allowed opacity-55 hover:border-line-2',54 )}55 >56 <span className="flex w-full items-center gap-2">57 <span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-text">{m.label}</span>58 <Badge tone={m.tier === 'frontier' ? 'accent' : 'neutral'}>{TIER_LABEL[m.tier]}</Badge>59 </span>60 <span className="tnum flex w-full items-center gap-2 text-[11.5px] text-muted">61 <span className="min-w-0 flex-1 truncate font-mono">{m.id}</span>62 {m.available ? <span className="shrink-0 text-text-2">{price(m.inputUsd)} / {price(m.outputUsd)}</span> : <span className="shrink-0 font-medium text-warning">Not available on this key</span>}63 </span>64 </button>65 );66 })}67 </div>68 </div>69 ))}70 </div>71 );72}7374export function NewProjectFields({ onCreated, onCancel }: { onCreated: (p: ProjectDto) => void; onCancel: () => void }) {75 const qc = useQueryClient();76 const [name, setName] = useState('');77 const [mode, setMode] = useState<'create' | 'existing' | 'clone'>('create');78 const [path, setPath] = useState('');79 const [gitUrl, setGitUrl] = useState('');80 const [busy, setBusy] = useState(false);81 const [error, setError] = useState<string | null>(null);82 const ok = name.trim().length > 0 && (mode !== 'existing' || path.trim()) && (mode !== 'clone' || gitUrl.trim());83 const create = async () => {84 setBusy(true);85 setError(null);86 try {87 const p = await api.post<ProjectDto>('/projects', { name: name.trim(), mode, path: path.trim() || undefined, gitUrl: mode === 'clone' ? gitUrl.trim() : undefined });88 await qc.invalidateQueries({ queryKey: qk.projects });89 onCreated(p);90 } catch (e) {91 setError(errorMessage(e));92 } finally {93 setBusy(false);94 }95 };96 return (97 <div className="flex flex-col gap-3 rounded-[14px] border border-line-2 bg-surface-2/60 p-3" data-testid="new-project-inline">98 <div className="grid gap-3 sm:grid-cols-2">99 <Field label="Project name"><Input value={name} onChange={(e) => setName(e.target.value)} maxLength={80} placeholder="billing-service" data-testid="project-name" /></Field>100 <Field label="Source">101 <Select value={mode} onValueChange={(v) => setMode(v as typeof mode)} label="Project source" options={[{ value: 'create', label: 'New empty repository' }, { value: 'existing', label: 'Existing folder in the workspace' }, { value: 'clone', label: 'Clone a git repository' }]} />102 </Field>103 </div>104 {mode === 'existing' && <Field label="Folder" hint="Relative to the workspace root."><Input value={path} onChange={(e) => setPath(e.target.value)} maxLength={300} placeholder="services/billing" className="font-mono" /></Field>}105 {mode === 'clone' && <Field label="Git URL" hint="https://, ssh:// or git@ URLs."><Input value={gitUrl} onChange={(e) => setGitUrl(e.target.value)} maxLength={500} placeholder="git@example.com:team/repo.git" className="font-mono" /></Field>}106 {error && <p role="alert" className="text-xs text-error">{error}</p>}107 <div className="flex justify-end gap-2">108 <Button variant="ghost" size="sm" onClick={onCancel}>Cancel</Button>109 <Button variant="primary" size="sm" loading={busy} disabled={!ok} onClick={create} data-testid="project-create">Create project</Button>110 </div>111 </div>112 );113}114115export function NewMissionDialog() {116 const { open, projectId: presetProject } = useUi((s) => s.newMission);117 const close = useUi((s) => s.closeNewMission);118 const [state, setState] = useState({ valid: false, busy: false });119 return (120 <Dialog121 open={open}122 onOpenChange={(o) => !o && close()}123 title="New mission"124 description="Describe the outcome. The agent works in a sandbox on its own branch and stops at its budget."125 size="lg"126 className="sm:max-w-2xl"127 footer={128 <div className="-mx-5 -mb-4 -mt-3 flex flex-1 items-center justify-end gap-2 border-t border-line px-5 py-3">129 <Button variant="ghost" onClick={close}>Cancel</Button>130 <Button type="submit" form={FORM_ID} variant="brand" className="h-9 px-5 font-semibold pointer-coarse:h-12" loading={state.busy} disabled={!state.valid} data-testid="mission-submit">Start mission</Button>131 </div>132 }133 >134 {open && <Form presetProject={presetProject} onDone={close} onState={setState} />}135 </Dialog>136 );137}138139const FORM_ID = 'aos-new-mission';140function Form({ presetProject, onDone, onState }: { presetProject: string | null; onDone: () => void; onState: (s: { valid: boolean; busy: boolean }) => void }) {141 const router = useRouter();142 const qc = useQueryClient();143 const projects = useProjects();144 const models = useModels();145 const settings = useSettings();146 const spend = useSpend().data;147 const [projectId, setProjectId] = useState<string>(presetProject ?? '');148 const [objective, setObjective] = useState('');149 const [criteria, setCriteria] = useState<string[]>([]);150 const [modelId, setModelId] = useState<string | null>(null);151 const [effort, setEffort] = useState<Effort | null>(null);152 const [budget, setBudget] = useState<string>('');153 const [network, setNetwork] = useState(false);154 const [busy, setBusy] = useState(false);155 const [error, setError] = useState<string | null>(null);156157 const list = projects.data ?? [];158 useEffect(() => {159 if (!projectId && list.length > 0) setProjectId(list[0]?.id ?? '');160 if (projects.isSuccess && list.length === 0) setProjectId(NEW);161 }, [list, projectId, projects.isSuccess]);162 const all = models.data ?? [];163 useEffect(() => {164 if (modelId || all.length === 0) return;165 const def = all.find((m) => m.id === settings.defaultModel && m.available) ?? all.find((m) => m.provider === settings.defaultProvider && m.available) ?? all.find((m) => m.available);166 if (def) setModelId(def.id);167 }, [all, modelId, settings.defaultModel, settings.defaultProvider]);168 const model = all.find((m) => m.id === modelId) ?? null;169 const efforts = model?.efforts ?? [];170 const effortValue = effort && efforts.includes(effort) ? effort : efforts.includes(settings.defaultEffort) ? settings.defaultEffort : (efforts[efforts.length - 1] ?? null);171 const budgetNum = budget.trim() === '' ? settings.missionBudgetUsd : Number(budget);172 const budgetOk = Number.isFinite(budgetNum) && budgetNum > 0 && budgetNum <= 1000;173 const capReached = spend != null && spend.todayUsd >= spend.dailyBudgetUsd;174 const cleanCriteria = criteria.map((c) => c.trim()).filter(Boolean);175 const valid = !!projectId && projectId !== NEW && objective.trim().length > 0 && !!model?.available && budgetOk;176177 useEffect(() => onState({ valid, busy }), [valid, busy, onState]);178179 const submit = async () => {180 if (!valid || !model) return;181 setBusy(true);182 setError(null);183 const body: CreateMission = { projectId, objective: objective.trim(), doneCriteria: cleanCriteria, provider: model.provider, model: model.id, effort: effortValue ?? undefined, budgetUsd: budgetNum, network: network ? 'open' : 'none' };184 try {185 const m = await api.post<MissionDto>('/missions', body);186 upsertMission(qc, m);187 void qc.invalidateQueries({ queryKey: qk.missions });188 toast.success('Mission started', m.title);189 onDone();190 router.push(`/mission/?id=${m.id}`);191 } catch (e) {192 setError(errorMessage(e));193 } finally {194 setBusy(false);195 }196 };197198 return (199 <form200 id={FORM_ID}201 className="flex flex-col gap-5 pb-1"202 data-testid="new-mission-form"203 onSubmit={(e) => {204 e.preventDefault();205 void submit();206 }}207 >208 <Field label="Project">209 <Select210 value={projectId || NEW}211 onValueChange={setProjectId}212 label="Project"213 options={[...list.map((p) => ({ value: p.id, label: p.name, hint: p.path })), { value: NEW, label: '+ New project…' }]}214 />215 </Field>216 {projectId === NEW && <NewProjectFields onCreated={(p) => setProjectId(p.id)} onCancel={() => setProjectId(list[0]?.id ?? NEW)} />}217218 <Field label="Objective" hint="What should be true when the mission is finished? Be specific about scope and constraints.">219 <Textarea value={objective} onChange={(e) => setObjective(e.target.value)} rows={5} maxLength={8000} autoFocus placeholder="Fix the rounding error in invoice totals and add a regression test…" className="min-h-32 text-[14px] leading-relaxed pointer-coarse:rounded-[14px]" data-testid="mission-objective" />220 </Field>221222 <fieldset className="flex flex-col gap-2">223 <legend className="mb-1.5 text-xs font-medium text-text-2">Done criteria <span className="font-normal text-muted">— optional</span></legend>224 {criteria.map((c, i) => (225 <div key={i} className="flex items-center gap-2">226 <span className="tnum w-5 shrink-0 text-right text-xs text-muted">{i + 1}.</span>227 <Input value={c} onChange={(e) => setCriteria(criteria.map((x, j) => (j === i ? e.target.value : x)))} maxLength={1000} placeholder="A verifiable statement, e.g. “npm test passes”" aria-label={`Done criterion ${i + 1}`} data-testid="criterion-input" />228 <IconButton label={`Remove criterion ${i + 1}`} onClick={() => setCriteria(criteria.filter((_, j) => j !== i))}><X className="size-4" /></IconButton>229 </div>230 ))}231 <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">232 <Button variant="outline" size="sm" onClick={() => setCriteria([...criteria, ''])} disabled={criteria.length >= 50} data-testid="criterion-add"><Plus className="size-3.5" /> Add criterion</Button>233 {cleanCriteria.length === 0 && (234 <p className="min-w-0 flex-1 basis-56 text-xs leading-relaxed text-muted" data-testid="criteria-hint">235 Left empty, the agent proposes criteria first and asks you to confirm them before it writes anything.236 </p>237 )}238 </div>239 </fieldset>240241 <div>242 <p className="mb-1.5 text-xs font-medium text-text-2">Model</p>243 <ModelPicker models={all} value={modelId} onChange={(m) => setModelId(m.id)} loading={models.isPending} />244 {models.isError && <p role="alert" className="mt-1 text-xs text-error">{errorMessage(models.error)}</p>}245 </div>246247 <div className="grid gap-4 sm:grid-cols-2">248 <Field label="Reasoning effort" hint={efforts.length === 0 ? 'This model has no effort setting.' : undefined}>249 <Select value={effortValue ?? ''} onValueChange={(v) => setEffort(v as Effort)} label="Reasoning effort" disabled={efforts.length === 0} placeholder={DASH} options={efforts.map((e) => ({ value: e, label: EFFORT_LABEL[e] }))} />250 </Field>251 <Field252 label="Budget (USD)"253 error={budgetOk ? null : 'Between 0.01 and 1000.'}254 hint={<>Hard stop for this mission. Daily cap {fmtUsd(spend?.dailyBudgetUsd ?? settings.dailyBudgetUsd, { compact: true })}{spend ? <>, {fmtUsd(spend.todayUsd)} spent today</> : null}.</>}255 >256 <Input type="number" inputMode="decimal" min={0.01} max={1000} step="any" value={budget} onChange={(e) => setBudget(e.target.value)} placeholder={String(settings.missionBudgetUsd)} className="tnum" data-testid="mission-budget" />257 </Field>258 </div>259260 <div className={cn('rounded-[14px] border p-3.5 transition-colors', network ? 'border-warning-vivid/50 bg-warning-vivid/[0.08]' : 'border-line-2')}>261 <div className="flex items-center gap-3">262 {network ? <Globe className="size-5 shrink-0 text-warning" aria-hidden /> : <GlobeLock className="size-5 shrink-0 text-muted" aria-hidden />}263 <div className="min-w-0 flex-1">264 <p className="text-[13px] font-semibold text-text">Network access</p>265 <p className="text-xs text-text-2">{network ? 'Open — commands can reach the internet.' : 'Off — the sandbox has no network.'}</p>266 </div>267 <Switch checked={network} onCheckedChange={setNetwork} label="Allow network access" data-testid="mission-network" />268 </div>269 <p className="mt-2.5 text-xs leading-relaxed text-text-2" data-testid="network-risk">270 {network ? (271 <><TriangleAlert className="mr-1 inline size-3.5 -translate-y-px text-warning" aria-hidden />With the network open, shell commands can download and run code and could send workspace content elsewhere. The first command still asks for your approval; file access stays confined to the workspace.</>272 ) : (273 <>Recommended. Installing dependencies or calling external services will fail; turn this on only when the task needs it.</>274 )}275 </p>276 </div>277278 {capReached && <p role="alert" className="rounded-sm bg-warning-vivid/12 px-3 py-2 text-xs text-warning">Today’s spend has reached the daily cap; the server refuses new missions until tomorrow or until the cap is raised in Settings.</p>}279 {error && <p role="alert" className="rounded-sm bg-error-vivid/10 px-3 py-2 text-xs text-error" data-testid="mission-error">{error}</p>}280281 </form>282 );283}284