2026-09-02 02:34:02 +03:00
|
|
|
"use client";
|
2026-10-25 17:18:44 +03:00
|
|
|
import { useI18n } from "@/components/i18n";
|
|
|
|
|
|
2026-09-02 02:34:02 +03:00
|
|
|
|
|
|
|
|
import { useContext } from "react";
|
2026-09-22 18:12:57 +03:00
|
|
|
import { CheckIcon, SettingsIcon, XIcon } from "lucide-react";
|
2026-09-02 02:34:02 +03:00
|
|
|
import { JobsContext } from "@/components/jobs-context";
|
2026-09-22 18:12:57 +03:00
|
|
|
import { ChatActionsContext } from "@/components/chat";
|
2026-09-24 23:20:59 +03:00
|
|
|
import { isModelsRefusal, isProviderKeyRefusal, type JobKind, type JobRecord } from "@/src/core";
|
2026-10-02 02:09:38 +03:00
|
|
|
import { BrailleSpinner } from "@/components/braille-spinner";
|
2026-09-02 02:34:02 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
2026-10-02 18:03:47 +03:00
|
|
|
* chat-view jobs feed via JobsContext, matched by toolCallId:
|
2026-09-02 02:34:02 +03:00
|
|
|
*
|
2026-10-02 18:03:47 +03:00
|
|
|
* job unknown → "dispatched" pulse (the jobs feed hasn't seen it yet)
|
2026-09-02 02:34:02 +03:00
|
|
|
* running → live activity card (activity line + elapsed)
|
2026-10-26 01:55:52 +02:00
|
|
|
* done/error → quiet one-liner ("finished" / "failed") — the artifact
|
2026-09-02 02:34:02 +03:00
|
|
|
* 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",
|
2026-09-19 15:24:26 +03:00
|
|
|
pdf: "PDF reader",
|
2026-09-02 02:34:02 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: JobKind }) {
|
2026-10-25 17:18:44 +03:00
|
|
|
const { t } = useI18n();
|
2026-09-02 02:34:02 +03:00
|
|
|
const jobs = useContext(JobsContext);
|
|
|
|
|
const job = jobs.find((j) => j.toolCallId === toolCallId) ?? null;
|
|
|
|
|
const label = KIND_LABEL[kind];
|
|
|
|
|
|
|
|
|
|
if (!job || job.status === "running") {
|
2026-10-25 17:18:44 +03:00
|
|
|
return <DispatchActivityCard job={job} label={t(label)} />;
|
2026-09-02 02:34:02 +03:00
|
|
|
}
|
2026-09-22 18:12:57 +03:00
|
|
|
// 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).
|
2026-09-24 23:20:59 +03:00
|
|
|
// 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.
|
2026-09-22 18:12:57 +03:00
|
|
|
if (isModelsRefusal(job.error)) {
|
2026-10-25 17:18:44 +03:00
|
|
|
return <DispatchRefusedCard label={t(label)} line={t("needs model setup")} button={t("Set up models")} />;
|
2026-09-24 23:20:59 +03:00
|
|
|
}
|
|
|
|
|
if (isProviderKeyRefusal(job.error)) {
|
2026-10-25 17:18:44 +03:00
|
|
|
return <DispatchRefusedCard label={t(label)} line={t("needs a provider key")} button={t("Add a key")} />;
|
2026-09-22 18:12:57 +03:00
|
|
|
}
|
2026-09-02 02:34:02 +03:00
|
|
|
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" />}
|
2026-10-25 17:18:44 +03:00
|
|
|
<span className="font-medium uppercase tracking-wide">{t(label)}</span>
|
2026-10-31 13:45:06 +03:00
|
|
|
<span>{failed ? t("failed") : t("finished")}</span>
|
2026-09-02 02:34:02 +03:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 18:12:57 +03:00
|
|
|
/** The refused card: quiet info tone — nothing red, nothing "failed". */
|
2026-09-24 23:20:59 +03:00
|
|
|
function DispatchRefusedCard({ label, line, button }: { label: string; line: string; button: string }) {
|
2026-10-25 17:18:44 +03:00
|
|
|
const { t } = useI18n();
|
2026-09-22 18:12:57 +03:00
|
|
|
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" />
|
2026-10-25 17:18:44 +03:00
|
|
|
<span className="font-medium uppercase tracking-wide">{t(label)}</span>
|
2026-09-24 23:20:59 +03:00
|
|
|
<span>{line}</span>
|
2026-09-22 18:12:57 +03:00
|
|
|
{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"
|
|
|
|
|
>
|
2026-09-24 23:20:59 +03:00
|
|
|
{button}
|
2026-09-22 18:12:57 +03:00
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 02:34:02 +03:00
|
|
|
function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: string }) {
|
2026-10-25 17:18:44 +03:00
|
|
|
const { t } = useI18n();
|
2026-10-02 18:03:47 +03:00
|
|
|
// Elapsed from createdAt — jobs appear here only via the jobs feed (post-mount),
|
2026-09-02 02:34:02 +03:00
|
|
|
// 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">
|
2026-10-02 02:09:38 +03:00
|
|
|
{/* 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. */}
|
2026-10-25 17:18:44 +03:00
|
|
|
<BrailleSpinner label={t("{v0} running", { v0: label })} />
|
|
|
|
|
{t(label)}
|
|
|
|
|
{elapsed !== null && <span className="font-normal">· {t("{v0}s", { v0: elapsed })}</span>}
|
2026-09-02 02:34:02 +03:00
|
|
|
</div>
|
|
|
|
|
{job?.activity && (
|
|
|
|
|
<div className="mt-1 truncate text-sm text-muted-foreground" data-testid="dispatch-activity-line">
|
2026-10-25 17:18:44 +03:00
|
|
|
{t(job.activity)}
|
2026-09-02 02:34:02 +03:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{!job && (
|
|
|
|
|
<div className="mt-1 text-sm text-muted-foreground" data-testid="dispatch-pending">
|
2026-10-25 17:18:44 +03:00
|
|
|
{t("Dispatched…")}</div>
|
2026-09-02 02:34:02 +03:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|