import { useEffect, useMemo, useState } from 'react'; import { Check, ChevronDown } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api/client'; import type { Agent, AgentParseError, ToolCostStat } from '@/api/types'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; interface Props { projectId: string; value: string | null; onChange: (agentId: string | null) => void | Promise; } export function AgentPicker({ projectId, value, onChange }: Props) { const [agents, setAgents] = useState(null); const [parseErrors, setParseErrors] = useState([]); const [error, setError] = useState(null); const [open, setOpen] = useState(false); // v1.13.10: per-tool cost rolling window. Fetched once on mount; would // refresh on remount or page reload. Acceptable for a decision aid — the // 100-call rolling mean doesn't shift fast. const [costStats, setCostStats] = useState([]); // v1.8.1: per-agent parse errors are non-blocking. Silent if any agents // loaded successfully; a gray warning toast fires only when EVERY agent // in AGENTS.md failed to parse. Server logs a console.warn either way. useEffect(() => { let cancelled = false; setAgents(null); setParseErrors([]); setError(null); api.agents .list(projectId) .then((res) => { if (cancelled) return; setAgents(res.agents); setParseErrors(res.errors); if (res.errors.length > 0 && res.agents.length === 0) { toast.warning( `AGENTS.md: ${res.errors.length} agent${res.errors.length === 1 ? '' : 's'} failed to parse, none loaded`, ); } }) .catch((err) => { if (cancelled) return; setError(err instanceof Error ? err.message : 'failed to load agents'); }); return () => { cancelled = true; }; }, [projectId]); // v1.13.10: cost stats are project-independent — the 100-call rolling // window is global across all chats. Fetch once per mount; tolerate failure // silently (cost line hides). useEffect(() => { let cancelled = false; api.tools .costStats() .then((r) => { if (!cancelled) setCostStats(r.stats); }) .catch(() => { if (!cancelled) setCostStats([]); }); return () => { cancelled = true; }; }, []); const costByTool = useMemo( () => Object.fromEntries(costStats.map((s) => [s.tool_name, s])), [costStats], ); const selectedAgent = agents?.find((a) => a.id === value) ?? null; const triggerLabel = value === null ? 'No agent' : selectedAgent?.name ?? value; return ( {error && (
{error}
)} {agents === null && !error && (
Loading…
)} {agents !== null && ( <> void onChange(null)} className="text-xs" > No agent {agents.length > 0 && } {agents.map((a) => { const cost = agentCost(a, costByTool); return ( void onChange(a.id)} className="text-xs flex-col items-start gap-0.5" >
{a.name}
{a.description && ( {a.description} )} {cost.nWithData > 0 && ( ~{formatK(cost.prompt)} prompt / {cost.completion} completion · {cost.nWithData}/{cost.nTools} tools{cost.mostRecent ? ` · last call ${formatAgo(cost.mostRecent)}` : ''} )}
); })} {parseErrors.length > 0 && (
`${e.agent_name}: ${e.reason}`).join('\n')} > {parseErrors.length} agent{parseErrors.length === 1 ? '' : 's'} skipped
)} )}
); } // v1.13.10: sum the per-tool means across an agent's whitelisted tools. // Sum-of-means, not mean-of-sums — we're combining independent rolling // averages. nWithData reflects how many of the agent's tools have any // history yet; the line hides entirely when zero so a fresh deploy doesn't // render "0k / 0 / 0 tools". function agentCost( agent: Agent, costByTool: Record, ): { prompt: number; completion: number; nTools: number; nWithData: number; mostRecent: string | null; } { let prompt = 0; let completion = 0; let nWithData = 0; let mostRecent: string | null = null; for (const t of agent.tools) { const s = costByTool[t]; if (!s) continue; prompt += s.mean_prompt_tokens; completion += s.mean_completion_tokens; nWithData++; if (!mostRecent || s.updated_at > mostRecent) mostRecent = s.updated_at; } return { prompt, completion, nTools: agent.tools.length, nWithData, mostRecent }; } function formatK(n: number): string { if (n < 1000) return String(n); if (n < 10_000) return `${(n / 1000).toFixed(1)}k`; return `${Math.round(n / 1000)}k`; } function formatAgo(iso: string): string { const then = new Date(iso).getTime(); if (Number.isNaN(then)) return '—'; const diff = Date.now() - then; if (diff < 60_000) return 'just now'; if (diff < 3_600_000) return `${Math.round(diff / 60_000)}m ago`; if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)}h ago`; return `${Math.round(diff / 86_400_000)}d ago`; }