SPB Git

Repository Public

new-mission.tsx 16.2 KB 284 lines tsx

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&rsquo;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
Files main
Menu

Theme