- existing sessions: a send that lands the 503 models_not_configured envelope renders as the SETUP FLOW, never the error banner — the record refetch drops the refused orphan (the gate refuses before the write-ahead persist), the draft goes home to the composer, and Save auto-replays it (owner ruling); parseModelsRefusal reads the envelope from the transport's verbatim error text (exported pure, tested) - persistent 'Set up your models' CTA above the chat composer while slotsConfigured is false (server-read seed via app/chat/[id]/page.tsx) — legacy per-chat overrides may still send with the pill present; the server gate stays the truth - SetupDialog prefill + 'needed' marks (owner ruling): pinned tiers open with their pick (matched in the federated catalog — lapsed keys don't prefill), unpinned tiers carry the mark; Save still requires all three; prefillFromSlots exported pure, tested - refused jobs read as INFO: dispatch card shows a quiet 'needs model setup' line + 'Set up models' deep-link (ChatActionsContext.openSetup); dock blobs' activity reads 'needs model setup' instead of 'failed'; real failures keep the red tone; isModelsRefusal added to the envelope's home module - agent-section ModelsBody.slots type honest to the nullable contract
111 lines
4.1 KiB
TypeScript
111 lines
4.1 KiB
TypeScript
"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, type JobKind, type JobRecord } from "@/src/core";
|
|
|
|
/**
|
|
* 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
|
|
* central jobs poll via JobsContext, matched by toolCallId:
|
|
*
|
|
* job unknown → "dispatched" pulse (the poll 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<JobKind, string> = {
|
|
research: "Research agent",
|
|
demo: "Demo agent",
|
|
visual: "Figure agent",
|
|
pdf: "PDF reader",
|
|
// The injection is server-initiated — it has no tool part, so never a
|
|
// dispatch card. The entry exists only for the exhaustive record.
|
|
"graph-context": "Graph context",
|
|
};
|
|
|
|
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 <DispatchActivityCard job={job} label={label} />;
|
|
}
|
|
// 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).
|
|
if (isModelsRefusal(job.error)) {
|
|
return <DispatchRefusedCard label={label} />;
|
|
}
|
|
const failed = job.status === "error";
|
|
return (
|
|
<div
|
|
className="flex items-center gap-2 text-xs text-muted-foreground"
|
|
data-testid="dispatch-settled"
|
|
>
|
|
{failed ? <XIcon className="size-3.5 text-red-500" /> : <CheckIcon className="size-3.5" />}
|
|
<span className="font-medium uppercase tracking-wide">{label}</span>
|
|
<span>{failed ? "failed" : "delivered"}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The refused card: quiet info tone — nothing red, nothing "failed". */
|
|
function DispatchRefusedCard({ label }: { label: string }) {
|
|
const ctx = useContext(ChatActionsContext);
|
|
return (
|
|
<div
|
|
className="flex items-center gap-2 text-xs text-muted-foreground"
|
|
data-testid="dispatch-refused"
|
|
>
|
|
<SettingsIcon className="size-3.5" />
|
|
<span className="font-medium uppercase tracking-wide">{label}</span>
|
|
<span>needs model setup</span>
|
|
{ctx && (
|
|
<button
|
|
type="button"
|
|
data-testid="dispatch-setup-link"
|
|
onClick={ctx.openSetup}
|
|
className="ml-1 rounded-sm border border-border bg-card px-2 py-0.5 font-ui normal-case tracking-normal text-foreground transition-colors hover:border-muted-foreground/50"
|
|
>
|
|
Set up models
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: string }) {
|
|
// Elapsed from createdAt — jobs appear here only via the poll (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 (
|
|
<div
|
|
className="rounded-xl border bg-card p-4 text-card-foreground"
|
|
data-testid="dispatch-card"
|
|
>
|
|
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
<span className="inline-block size-2 animate-pulse rounded-full bg-primary" />
|
|
{label}
|
|
{elapsed !== null && <span className="font-normal">· {elapsed}s</span>}
|
|
</div>
|
|
{job?.activity && (
|
|
<div className="mt-1 truncate text-sm text-muted-foreground" data-testid="dispatch-activity-line">
|
|
{job.activity}
|
|
</div>
|
|
)}
|
|
{!job && (
|
|
<div className="mt-1 text-sm text-muted-foreground" data-testid="dispatch-pending">
|
|
Dispatched…
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|