Repository Public
1'use client';2import Link from 'next/link';3import { ChevronRight, FolderGit2 } from 'lucide-react';4import type { MissionDto, ModelInfo } from '@aos/shared';5import { useRuns } from '@/lib/queries';6import { useTimeline } from '@/stores/timeline';7import { useNow } from '@/hooks/use-viewport';8import { DASH, fmtRelative } from '@/lib/format';9import { isActive } from '@/lib/status';10import { cn } from '@/lib/cn';11import { BudgetBar, Elapsed, SpendOfBudget, StatusBadge } from './bits';1213/**14 * Model of a mission. MissionDto does not carry it (CONTRACT §8), so it is read from the root run;15 * a server that adds `model` to MissionDto (additive) saves that request.16 */17export function useMissionModel(m: MissionDto, models: ModelInfo[]): string | null {18 const direct = (m as MissionDto & { model?: unknown }).model;19 const has = typeof direct === 'string' && direct.length > 0;20 const runs = useRuns(has ? null : m.id).data;21 const id = has ? (direct as string) : (runs?.find((r) => r.id === m.rootRunId) ?? runs?.[0])?.model;22 return id ? (models.find((x) => x.id === id)?.label ?? id) : null;23}2425/** What the mission is doing right now: the last live event, else what its status alone tells. */26export function useActivity(m: MissionDto): { text: string; live: boolean } {27 const a = useTimeline((s) => s.activity[m.id]);28 if (m.status === 'waiting') return { text: m.pendingWaits > 1 ? `Waiting for you — ${m.pendingWaits} requests` : 'Waiting for you', live: false };29 if (m.status === 'queued') return { text: 'Queued — waiting for a worker', live: false };30 if (m.status === 'running') return a ? { text: a.text, live: true } : { text: 'Working', live: true };31 return { text: DASH, live: false };32}3334/** Running mission card (Home): status, elapsed, spend vs budget, current activity. */35export function MissionCard({ mission: m, models }: { mission: MissionDto; models: ModelInfo[] }) {36 const act = useActivity(m);37 const model = useMissionModel(m, models);38 return (39 <Link href={`/mission/?id=${m.id}`} className="card pressable group flex min-w-0 flex-col gap-3 p-4 hover:border-line-3" data-testid="mission-card" data-mission-id={m.id} data-status={m.status}>40 <div className="flex items-start gap-2">41 <div className="min-w-0 flex-1">42 <h3 className="truncate text-[15px] font-semibold tracking-tight text-text">{m.title}</h3>43 <p className="mt-0.5 flex items-center gap-1.5 truncate text-xs text-muted">44 {m.projectName && <><FolderGit2 className="size-3 shrink-0" aria-hidden /><span className="truncate">{m.projectName}</span></>}45 {model && <span className="truncate">· {model}</span>}46 </p>47 </div>48 <StatusBadge status={m.status} />49 </div>50 <p className="flex min-h-5 items-center gap-2 text-[13px] text-text-2" data-testid="mission-activity">51 {act.live && <span className="relative flex size-2 shrink-0"><span className="anim-ping absolute inset-0 rounded-full bg-accent" /><span className="relative size-2 rounded-full bg-accent" /></span>}52 <span className={cn('truncate', m.status === 'waiting' && 'font-medium text-info-ink')}>{act.text}</span>53 </p>54 <div className="flex flex-col gap-1.5">55 <BudgetBar spent={m.spentUsd} budget={m.budgetUsd} />56 <div className="flex items-center justify-between gap-3 text-xs">57 <SpendOfBudget spent={m.spentUsd} budget={m.budgetUsd} />58 <span className="flex items-center gap-1 text-muted">59 <Elapsed start={m.startedAt} end={m.endedAt} />60 <ChevronRight className="size-3.5 text-faint transition-transform group-hover:translate-x-0.5" aria-hidden />61 </span>62 </div>63 </div>64 </Link>65 );66}6768/** Dense row for the missions list. */69export function MissionRow({ mission: m, models }: { mission: MissionDto; models: ModelInfo[] }) {70 const now = useNow(30_000);71 const model = useMissionModel(m, models);72 return (73 <Link href={`/mission/?id=${m.id}`} className="group flex min-h-[60px] items-center gap-3 px-4 py-2.5 hover:bg-hover md:gap-4 md:px-5" data-testid="mission-row" data-mission-id={m.id} data-status={m.status}>74 <div className="min-w-0 flex-1">75 <p className="truncate text-[14px] font-medium text-text">{m.title}</p>76 <p className="mt-0.5 flex items-center gap-1.5 truncate text-xs text-muted">77 <span className="truncate">{m.projectName ?? 'No project'}</span>78 <span className="hidden truncate sm:inline">· {model ?? DASH}</span>79 <span className="tnum shrink-0">· {isActive(m.status) && m.startedAt ? 'started ' : ''}{fmtRelative(isActive(m.status) ? (m.startedAt ?? m.createdAt) : (m.endedAt ?? m.createdAt), now)}</span>80 </p>81 </div>82 <div className="hidden w-40 shrink-0 flex-col gap-1 md:flex">83 <BudgetBar spent={m.spentUsd} budget={m.budgetUsd} />84 <SpendOfBudget spent={m.spentUsd} budget={m.budgetUsd} className="text-[11px]" />85 </div>86 <div className="flex shrink-0 flex-col items-end gap-1">87 <StatusBadge status={m.status} />88 <SpendOfBudget spent={m.spentUsd} budget={m.budgetUsd} className="text-[11px] md:hidden" />89 </div>90 <ChevronRight className="hidden size-4 shrink-0 text-faint md:block" aria-hidden />91 </Link>92 );93}94