"use client"; import { useContext } from "react"; import { CheckIcon, SettingsIcon, XIcon } from "lucide-react"; import { JobsContext } from "@/components/jobs-context"; import { ChatActionsContext } from "@/components/chat"; import { isModelsRefusal, isProviderKeyRefusal, type JobKind, type JobRecord } from "@/src/core"; import { BrailleSpinner } from "@/components/braille-spinner"; /** * The dispatch card (contracts.ts "THE ASYNC PROTOCOL" §4): the tool-call * box for a background sub-agent. The tool part's output is the FIRE-AND- * FORGET ACK (a string) — never the result. Live status comes from the * chat-view jobs feed via JobsContext, matched by toolCallId: * * job unknown → "dispatched" pulse (the jobs feed hasn't seen it yet) * running → live activity card (activity line + elapsed) * done/error → quiet one-liner ("delivered" / "failed") — the artifact * itself arrives in the steer message right below, so the * box must not duplicate it. */ const KIND_LABEL: Record = { research: "Research agent", demo: "Demo agent", visual: "Figure agent", pdf: "PDF reader", }; export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: JobKind }) { const jobs = useContext(JobsContext); const job = jobs.find((j) => j.toolCallId === toolCallId) ?? null; const label = KIND_LABEL[kind]; if (!job || job.status === "running") { return ; } // BYOK (owner, 2026-09-22): a job refused for missing models is INFO, // not scary-error — gray glyph + "needs model setup" + the deep-link // into the SetupDialog (the same flow the send interception uses). // The provider-key refusal twin (zero-keys wave): a pinned model // whose key is gone reads as the key being needed — the deep-link // lands on the SetupDialog's first-key stage on its own. if (isModelsRefusal(job.error)) { return ; } if (isProviderKeyRefusal(job.error)) { return ; } const failed = job.status === "error"; return (
{failed ? : } {label} {failed ? "failed" : "delivered"}
); } /** The refused card: quiet info tone — nothing red, nothing "failed". */ function DispatchRefusedCard({ label, line, button }: { label: string; line: string; button: string }) { const ctx = useContext(ChatActionsContext); return (
{label} {line} {ctx && ( )}
); } function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: string }) { // Elapsed from createdAt — jobs appear here only via the jobs feed (post-mount), // so no SSR/hydration mismatch. const elapsed = job && job.createdAt ? Math.max(0, Math.round((Date.now() - Date.parse(job.createdAt)) / 1000)) : null; return (
{/* The working-state grammar (owner, streaming round 2026-10-01): the Braille spinner replaces the pulsing dot — one indicator everywhere. This is the ONLY dispatch-card change; the card's shape is untouched. */} {label} {elapsed !== null && · {elapsed}s}
{job?.activity && (
{job.activity}
)} {!job && (
Dispatched…
)}
); }