Repository Public
1'use client';2import { useMemo, useState } from 'react';3import { Plus, Search } from 'lucide-react';4import { useMissions, useModels, useProjects } from '@/lib/queries';5import { useUi } from '@/stores/ui';6import { isActive } from '@/lib/status';7import { Button } from '@/components/ui/button';8import { FilterChips } from '@/components/ui/chips';9import { Input } from '@/components/ui/input';10import { NONE, Select } from '@/components/ui/select';11import { EmptyState, ErrorState, Skeleton } from '@/components/ui/misc';12import { IllusMission } from '@/components/ui/illustrations';13import { MissionRow } from '@/components/missions/mission-card';1415type Filter = 'all' | 'active' | 'waiting' | 'finished' | 'failed';1617export default function MissionsPage() {18 const q = useMissions();19 const models = useModels().data ?? [];20 const projects = useProjects().data ?? [];21 const openNew = useUi((s) => s.openNewMission);22 const [filter, setFilter] = useState<Filter>('all');23 const [project, setProject] = useState<string>(NONE);24 const [text, setText] = useState('');25 const all = q.data ?? [];26 const scoped = useMemo(() => {27 const t = text.trim().toLowerCase();28 return all.filter((m) => (project === NONE || m.projectId === project) && (!t || m.title.toLowerCase().includes(t) || m.objective.toLowerCase().includes(t)));29 }, [all, project, text]);30 const match: Record<Filter, (s: (typeof all)[number]) => boolean> = {31 all: () => true,32 active: (m) => isActive(m.status),33 waiting: (m) => m.status === 'waiting',34 finished: (m) => m.status === 'done' || m.status === 'partial',35 failed: (m) => ['failed', 'blocked', 'cancelled', 'budget_exhausted'].includes(m.status),36 };37 const list = scoped.filter(match[filter]);38 const count = (f: Filter) => scoped.filter(match[f]).length;3940 return (41 <div className="flex h-full flex-col" data-testid="missions-page">42 <div className="shrink-0 px-4 pb-3 pt-1 md:px-8 md:pt-6">43 <div className="mx-auto flex w-full max-w-5xl flex-col gap-3">44 <div className="flex items-center gap-3">45 <div className="hidden min-w-0 flex-1 md:block">46 <h1 className="text-xl font-bold tracking-tight">Missions</h1>47 <p className="text-[13px] text-text-2">Every objective given to an agent, with its status and what it cost.</p>48 </div>49 <div className="relative flex-1 md:w-64 md:flex-none">50 <Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted" aria-hidden />51 <Input value={text} onChange={(e) => setText(e.target.value)} placeholder="Search missions" aria-label="Search missions" className="pl-8 pointer-coarse:pl-9" data-testid="missions-search" />52 </div>53 <Button variant="brand" className="h-8 shrink-0 rounded-full px-3.5 font-semibold pointer-coarse:h-12 pointer-coarse:px-4 md:hidden" onClick={() => openNew()} data-testid="new-mission-page" aria-label="New mission">54 <Plus className="size-4" strokeWidth={2.6} /> New55 </Button>56 </div>57 <div className="flex flex-wrap items-center gap-x-4 gap-y-2">58 <div className="min-w-0 flex-1">59 <FilterChips60 label="Status filter"61 value={filter}62 onChange={setFilter}63 options={[64 { id: 'all', label: 'All', count: count('all') },65 { id: 'active', label: 'Active', count: count('active') },66 { id: 'waiting', label: 'Needs you', count: count('waiting') },67 { id: 'finished', label: 'Finished', count: count('finished') },68 { id: 'failed', label: 'Stopped', count: count('failed') },69 ]}70 />71 </div>72 {projects.length > 1 && (73 <Select value={project} onValueChange={setProject} label="Project filter" className="w-full sm:w-52" options={[{ value: NONE, label: 'All projects' }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} />74 )}75 </div>76 </div>77 </div>78 <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-32 md:px-8 md:pb-10">79 <div className="mx-auto w-full max-w-5xl">80 {q.isError ? (81 <div className="card"><ErrorState error={q.error} onRetry={() => q.refetch()} /></div>82 ) : q.isPending ? (83 <div className="flex flex-col gap-2"><Skeleton className="h-14 rounded-md" /><Skeleton className="h-14 rounded-md" /><Skeleton className="h-14 rounded-md" /></div>84 ) : list.length === 0 ? (85 <div className="card">86 {all.length === 0 ? (87 <EmptyState illustration={<IllusMission />} title="No mission yet" description="A mission is an objective, a project and a budget. Start the first one." action={<Button variant="brand" className="h-9 rounded-full px-5 font-semibold pointer-coarse:h-12" onClick={() => openNew()}><Plus className="size-4" strokeWidth={2.6} /> New mission</Button>} />88 ) : (89 <EmptyState title="No mission matches" description="Change the filters or the search text." />90 )}91 </div>92 ) : (93 <div className="card divide-y divide-line overflow-hidden" data-testid="missions-list">94 {list.map((m) => <MissionRow key={m.id} mission={m} models={models} />)}95 </div>96 )}97 </div>98 </div>99 </div>100 );101}102