SPB Git

Repository Public

mission-card.tsx 5.2 KB 94 lines tsx

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
Files main
Menu

Theme