SPB Git

Repository Public

page.tsx 9.6 KB 178 lines tsx

1'use client';2import { Suspense, useEffect, useState } from 'react';3import Link from 'next/link';4import { useSearchParams } from 'next/navigation';5import { useQueryClient } from '@tanstack/react-query';6import { ArrowLeft, Ban, FolderGit2, Globe, GlobeLock, ListTree, SlidersHorizontal } from 'lucide-react';7import type { MissionDto, RunDto } from '@aos/shared';8import { api, errorMessage } from '@/lib/api';9import { qk, upsertMission, useMission, useModels, useRuns } from '@/lib/queries';10import { isActive } from '@/lib/status';11import { fmtDateTime, fmtUsd } from '@/lib/format';12import { useBreakpoint } from '@/hooks/use-viewport';13import { toast } from '@/stores/toasts';14import { Button, IconButton } from '@/components/ui/button';15import { ConfirmDialog } from '@/components/ui/dialog';16import { EmptyState, ErrorState, Skeleton } from '@/components/ui/misc';17import { Sheet } from '@/components/ui/sheet';18import { Switch } from '@/components/ui/switch';19import { BudgetBar, Elapsed, SpendOfBudget, StatusBadge } from '@/components/missions/bits';20import { Artifacts, Composer, Criteria, RunTree } from '@/components/missions/detail-parts';21import { Timeline } from '@/components/timeline/timeline';22import { IllusMission } from '@/components/ui/illustrations';2324export default function MissionPage() {25  return (26    <Suspense fallback={null}>27      <Mission />28    </Suspense>29  );30}3132function PanelSection({ title, children, aside }: { title: string; children: React.ReactNode; aside?: React.ReactNode }) {33  return (34    <section className="border-b border-line last:border-0">35      <header className="flex items-center gap-2 px-4 pt-3">36        <h2 className="flex-1 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted">{title}</h2>37        {aside}38      </header>39      {children}40    </section>41  );42}4344function Mission() {45  const id = useSearchParams().get('id');46  const qc = useQueryClient();47  const bp = useBreakpoint();48  const mobile = bp === 'mobile';49  const mq = useMission(id);50  const rq = useRuns(id);51  const models = useModels().data ?? [];52  const [selected, setSelected] = useState<string | null>(null);53  const [confirmCancel, setConfirmCancel] = useState(false);54  const [overview, setOverview] = useState(false);55  const [showSystem, setShowSystem] = useState(false);56  const m = mq.data;57  const runs = rq.data ?? [];58  useEffect(() => setSelected(null), [id]);59  const run: RunDto | null = runs.find((r) => r.id === selected) ?? runs.find((r) => r.id === m?.rootRunId) ?? runs[0] ?? null;6061  if (!id) return <EmptyState illustration={<IllusMission />} title="No mission selected" description="Open a mission from the list." action={<Link href="/missions/" className="text-sm font-medium text-accent hover:underline">Go to missions</Link>} />;62  if (mq.isError) return <ErrorState error={mq.error} onRetry={() => mq.refetch()} />;63  if (!m) return <div className="flex flex-col gap-3 p-6"><Skeleton className="h-7 w-72" /><Skeleton className="h-4 w-96 max-w-full" /><Skeleton className="mt-4 h-40" /></div>;6465  const active = isActive(m.status);66  const cancel = async () => {67    try {68      const res = await api.post<MissionDto | undefined>(`/missions/${m.id}/cancel`);69      if (res && typeof res === 'object' && 'id' in res) upsertMission(qc, res);70      void qc.invalidateQueries({ queryKey: qk.mission(m.id) });71      void qc.invalidateQueries({ queryKey: qk.runs(m.id) });72      void qc.invalidateQueries({ queryKey: qk.missions });73      toast.info('Cancellation requested', 'The run stops at its next step boundary.');74    } catch (e) {75      toast.error('Could not cancel', errorMessage(e));76      throw e;77    }78  };79  const model = run ? (models.find((x) => x.id === run.model)?.label ?? run.model) : null;8081  const side = (82    <>83      <PanelSection title="Done criteria"><Criteria criteria={m.doneCriteria} active={active} /></PanelSection>84      <PanelSection title="Runs" aside={<span className="tnum text-[11px] text-muted">{runs.length}</span>}>85        {rq.isError ? <p className="px-4 py-3 text-xs text-error">{errorMessage(rq.error)}</p> : <RunTree runs={runs} selected={run?.id ?? null} onSelect={(rid) => { setSelected(rid); setOverview(false); }} models={models} />}86      </PanelSection>87      {run && <PanelSection title="Artifacts"><Artifacts runId={run.id} /></PanelSection>}88      <PanelSection title="Objective">89        <p className="whitespace-pre-wrap break-words px-4 pb-3 pt-1.5 text-[13px] leading-relaxed text-text-2" data-testid="mission-objective-text">{m.objective}</p>90      </PanelSection>91      <PanelSection title="Details">92        <dl className="tnum grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 px-4 pb-3 pt-1.5 text-xs">93          <dt className="text-muted">Project</dt><dd className="truncate text-text-2">{m.projectName ?? '—'}</dd>94          <dt className="text-muted">Network</dt><dd className="text-text-2">{m.network === 'open' ? 'Open' : 'Off'}</dd>95          <dt className="text-muted">Created</dt><dd className="text-text-2">{fmtDateTime(m.createdAt)}</dd>96          <dt className="text-muted">Started</dt><dd className="text-text-2">{fmtDateTime(m.startedAt)}</dd>97          <dt className="text-muted">Ended</dt><dd className="text-text-2">{fmtDateTime(m.endedAt)}</dd>98        </dl>99        <label className="flex items-center gap-2.5 border-t border-line px-4 py-2.5 text-xs text-text-2">100          <Switch checked={showSystem} onCheckedChange={setShowSystem} label="Show kernel steps and allowed policy decisions" />101          Show kernel steps102        </label>103      </PanelSection>104    </>105  );106107  return (108    <div className="flex h-full min-h-0 flex-col" data-testid="mission-page" data-mission-id={m.id} data-status={m.status}>109      <header className="pt-safe shrink-0 border-b border-line bg-bg">110        <div className="flex items-start gap-2 px-2 py-2.5 md:px-6 md:py-3.5">111          <Link href="/missions/" aria-label="Back to missions" className="inline-flex size-9 shrink-0 items-center justify-center rounded-full text-text-2 hover:bg-hover pointer-coarse:size-11">112            <ArrowLeft className="size-[18px]" />113          </Link>114          <div className="min-w-0 flex-1 pt-0.5 md:pt-0">115            <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">116              <h1 className="min-w-0 max-w-full truncate text-[17px] font-bold tracking-tight md:text-lg" data-testid="mission-title">{m.title}</h1>117              <StatusBadge status={m.status} />118            </div>119            <p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted">120              {m.projectName && <span className="inline-flex items-center gap-1"><FolderGit2 className="size-3" aria-hidden />{m.projectName}</span>}121              {model && <span data-testid="mission-model">{model}{run?.effort ? ` · ${run.effort}` : ''}</span>}122              <span className="inline-flex items-center gap-1">{m.network === 'open' ? <Globe className="size-3 text-warning" aria-hidden /> : <GlobeLock className="size-3" aria-hidden />}{m.network === 'open' ? 'network open' : 'no network'}</span>123              <span>elapsed <Elapsed start={m.startedAt} end={m.endedAt} className="text-text-2" /></span>124            </p>125            <div className="mt-2 flex items-center gap-3">126              <BudgetBar spent={m.spentUsd} budget={m.budgetUsd} className="max-w-64 flex-1" />127              <SpendOfBudget spent={m.spentUsd} budget={m.budgetUsd} className="text-xs" />128            </div>129          </div>130          <div className="flex shrink-0 items-center gap-1 pt-0.5">131            {mobile && (132              <IconButton label="Overview: criteria, runs, details" onClick={() => setOverview(true)} data-testid="mission-overview">133                <ListTree className="size-[18px]" />134              </IconButton>135            )}136            {active && (137              mobile ? (138                <IconButton label="Cancel mission" onClick={() => setConfirmCancel(true)} data-testid="mission-cancel" className="text-error"><Ban className="size-[18px]" /></IconButton>139              ) : (140                <Button variant="danger" size="sm" onClick={() => setConfirmCancel(true)} data-testid="mission-cancel"><Ban className="size-3.5" /> Cancel mission</Button>141              )142            )}143          </div>144        </div>145      </header>146      <div className="flex min-h-0 flex-1">147        {!mobile && <aside className="w-[320px] shrink-0 overflow-y-auto border-r border-line bg-surface xl:w-[360px]" aria-label="Mission overview" data-testid="mission-side">{side}</aside>}148        <section className="flex min-w-0 flex-1 flex-col" aria-label="Timeline">149          {run ? (150            <>151              <Timeline key={run.id} run={run} showSystem={showSystem} />152              <Composer run={run} />153            </>154          ) : rq.isPending ? (155            <div className="p-6"><Skeleton className="h-24" /></div>156          ) : (157            <EmptyState illustration={<IllusMission />} title="No run yet" description="The mission has no run to show." />158          )}159        </section>160      </div>161      {mobile && (162        <Sheet open={overview} onOpenChange={setOverview} title="Overview" side="bottom" headerExtra={<SlidersHorizontal className="size-4 text-muted" aria-hidden />}>163          <div data-testid="mission-side">{side}</div>164        </Sheet>165      )}166      <ConfirmDialog167        open={confirmCancel}168        onOpenChange={setConfirmCancel}169        title="Cancel this mission?"170        description={<>The agent stops at its next step boundary and pending approvals are dropped. Work already committed on the mission branch stays. <span className="tnum">{`Spent so far: ${fmtUsd(m.spentUsd)}.`}</span></>}171        confirmLabel="Cancel mission"172        danger173        onConfirm={cancel}174      />175    </div>176  );177}178
Files main
Menu

Theme