Repository Public
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