fix: live sub-agent observability — dispatch cards, steer artifacts, sidebar panel

The pi-style ack protocol (previous commit) left the client describing the
old world: artifact cards read the ack string as a result ("could not be
displayed"), the only job poller rendered on input-available parts that no
longer exist (so wake turns landed invisibly until re-navigation), steers
rendered as learner bubbles with no SVG anywhere, and server-persisted
assistant messages carried empty SDK ids (duplicate React keys).

- persistTurn (turn-service): fill empty message ids at the canonical write
  — server-only messages (last turn's, wake turns') now key uniquely;
  client-authored ids untouched.
- chat.tsx: ONE central /api/jobs poll (fast while jobs run, slow discovery
  cadence, off for job-less sessions) drives every job surface, re-syncs
  once when a job settles (the record already holds the steer + wake
  response at flip time), and adopts server-side wake turns via the same
  turnInFlight machinery as returning mid-turn. Steers branch to the
  artifact card — never a bubble. Composer no longer gates on jobs.
- /api/jobs now reports turnInFlight alongside the job list.
- dispatch-card: the tool-call box — live activity by toolCallId while
  running, quiet "delivered"/"failed" one-liner after; the ack is visible
  observability, the artifact lives in the steer.
- steer-card: renders the delivered artifact by kind (research brief /
  sanitized SVG / sandboxed demo iframe) from the data-subagent-artifact
  part; result renderers extracted and exported from the artifact cards.
- sub-agents-panel: right-sidebar section above the plan tree listing
  running sub-agents (kind, elapsed, live activity) — vanishes when idle.
- tests: dispatch/steer/panel render tests re-target the new protocol;
  persistTurn id-hygiene suite. 229/229 green, build green.

Out-of-lane touches for master review: chat.tsx + components/** (cleared
this wave), app/api/jobs/route.ts, src/server/turn-service.ts, contracts
protocol doc §2. Live-verified on :3003 (full loop, SSR DOM checked).
This commit is contained in:
amosblomqvist
2026-09-02 02:34:02 +03:00
parent 99fa3f0847
commit 2d7b004fda
13 changed files with 514 additions and 242 deletions
+9 -83
View File
@@ -2,7 +2,8 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { ChatActionsContext } from "@/components/chat";
import type { ArtifactPartProps, DemoResult, JobRecord } from "@/src/core";
import { DispatchCard } from "@/components/artifacts/dispatch-card";
import type { ArtifactPartProps, DemoResult } from "@/src/core";
/**
* Demo artifact card (W6): while the part is input-available, a job activity
@@ -66,93 +67,18 @@ export function reduceDemoBridgeState(state: DemoBridgeState, data: unknown): De
return state;
}
// ─── shared job activity card (input-available state) ───────────────────────
/** Live activity card for pending job parts — the job-card.tsx idiom,
* parameterized by label. Exported for the visual card (also W6-owned). */
export function JobActivityCard({
toolCallId,
label,
testId,
}: {
toolCallId: string;
label: string;
testId?: string;
}) {
const actions = useContext(ChatActionsContext);
const [job, setJob] = useState<JobRecord | null>(null);
const finishedRef = useRef(false);
useEffect(() => {
if (!actions) return;
let cancelled = false;
const poll = async () => {
if (finishedRef.current) return true;
try {
const res = await fetch(`/api/jobs?chatId=${encodeURIComponent(actions.chatId)}`);
if (!res.ok) return false;
const { jobs } = (await res.json()) as { jobs?: JobRecord[] };
const match = jobs?.find((j) => j.toolCallId === toolCallId) ?? null;
if (cancelled) return false;
setJob(match);
if (match && match.status !== "running") {
// The part has (or is about to be) fulfilled server-side — pull the
// fresh messages once; the continuation resumes the teacher.
finishedRef.current = true;
void actions.syncFromServer();
return true;
}
} catch {
/* transient fetch failure — keep polling */
}
return false;
};
const timer = setInterval(() => {
if (finishedRef.current) {
clearInterval(timer);
return;
}
void poll().then((stop) => {
if (stop) clearInterval(timer);
});
}, 2000);
void poll().then((stop) => {
if (stop) clearInterval(timer);
});
return () => {
cancelled = true;
clearInterval(timer);
};
}, [actions, toolCallId]);
return (
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid={testId ?? "job-activity-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}
</div>
{job?.activity && (
<div className="mt-1 truncate text-sm text-muted-foreground" data-testid="job-activity-line">
{job.activity}
</div>
)}
</div>
);
}
// ─── the demo card ──────────────────────────────────────────────────────────
/** The demo tool-call box: the part's output is the dispatch ack — the box
* is the DispatchCard (live status via the central jobs poll). The demo
* itself renders from the steer message via DemoOutputCard. */
export function DemoCard({ part }: ArtifactPartProps) {
if (part.state === "output-available") {
return <DemoOutputCard output={part.output} />;
}
return <JobActivityCard toolCallId={part.toolCallId} label="Building demo…" testId="demo-activity-card" />;
if (part.state !== "output-available") return null;
return <DispatchCard toolCallId={part.toolCallId} kind="demo" />;
}
function DemoOutputCard({ output }: { output: unknown }) {
/** The demo artifact — exported: the steer card renders the delivered result. */
export function DemoOutputCard({ output }: { output: unknown }) {
const result = output as DemoResult | undefined;
if (!result || typeof result.html !== "string" || typeof result.title !== "string") {
return (
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useContext } from "react";
import { CheckIcon, XIcon } from "lucide-react";
import { JobsContext } from "@/components/jobs-context";
import type { JobKind, 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",
};
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} />;
}
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>
);
}
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>
);
}
+15 -83
View File
@@ -1,96 +1,28 @@
"use client";
import { useContext, useEffect, useRef, useState } from "react";
import { DispatchCard } from "@/components/artifacts/dispatch-card";
import { MessageResponse } from "@/components/ai-elements/message";
import { ChatActionsContext } from "@/components/chat";
import type { ArtifactPartProps, JobRecord, ResearchResult } from "@/src/core";
import type { ArtifactPartProps, ResearchResult } from "@/src/core";
/**
* The research job card (contracts.ts "THE ASYNC PROTOCOL" §4).
* The research tool-call box (contracts.ts "THE ASYNC PROTOCOL" §4).
*
* While the part is input-available: an activity card — poll /api/jobs every
* 2s, find the job by toolCallId, show a pulsing dot + "Researching…" + the
* job's live activity line. When the job flips to done/error, call
* syncFromServer() ONCE (fresh messages replace local state; the part
* re-renders as the brief card and the sendAutomaticallyWhen continuation
* resumes the teacher) and stop polling.
*
* When output-available: the research brief — markdown body + a small source
* link list, in the visual language of answered-card.tsx.
* The part's output is the fire-and-forget dispatch ACK — the box itself is
* the DispatchCard (live status via the central jobs poll; quiet one-liner
* once delivered). The research brief itself no longer renders from the
* tool part at all: it rides the steer message (the user-role delivery
* injected after the wake turn) and renders there via ResearchBriefCard.
*/
export function JobCard({ part }: ArtifactPartProps) {
if (part.state === "output-available") {
return <ResearchBriefCard output={part.output as ResearchResult} />;
}
return <ResearchActivityCard toolCallId={part.toolCallId} />;
if (part.state !== "output-available") return null;
return <DispatchCard toolCallId={part.toolCallId} kind="research" />;
}
function ResearchActivityCard({ toolCallId }: { toolCallId: string }) {
const actions = useContext(ChatActionsContext);
const [job, setJob] = useState<JobRecord | null>(null);
const finishedRef = useRef(false);
useEffect(() => {
if (!actions) return;
let cancelled = false;
const poll = async () => {
if (finishedRef.current) return true;
try {
const res = await fetch(`/api/jobs?chatId=${encodeURIComponent(actions.chatId)}`);
if (!res.ok) return false;
const { jobs } = (await res.json()) as { jobs?: JobRecord[] };
const match = jobs?.find((j) => j.toolCallId === toolCallId) ?? null;
if (cancelled) return false;
setJob(match);
if (match && match.status !== "running") {
// The part has (or is about to be) fulfilled server-side — pull the
// fresh messages once; the continuation resumes the teacher.
finishedRef.current = true;
void actions.syncFromServer();
return true;
}
} catch {
/* transient fetch failure — keep polling */
}
return false;
};
const timer = setInterval(() => {
if (finishedRef.current) {
clearInterval(timer);
return;
}
void poll().then((stop) => {
if (stop) clearInterval(timer);
});
}, 2000);
void poll().then((stop) => {
if (stop) clearInterval(timer);
});
return () => {
cancelled = true;
clearInterval(timer);
};
}, [actions, toolCallId]);
return (
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="research-activity-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" />
Researching…
</div>
{job?.activity && (
<div className="mt-1 truncate text-sm text-muted-foreground" data-testid="research-activity-line">
{job.activity}
</div>
)}
</div>
);
}
function ResearchBriefCard({ output }: { output: unknown }) {
/**
* The research brief — markdown body + a small source link list. Exported:
* the steer card (sub-agent result delivery) renders the same artifact.
*/
export function ResearchBriefCard({ output }: { output: unknown }) {
const result = output as ResearchResult | undefined;
if (!result || typeof result.brief !== "string") {
return (
+36
View File
@@ -0,0 +1,36 @@
"use client";
import type { SubagentArtifactData, TeacherUIMessage } from "@/src/core";
import { DemoOutputCard } from "@/components/artifacts/demo-card";
import { ResearchBriefCard } from "@/components/artifacts/job-card";
import { VisualOutputCard } from "@/components/artifacts/visual-card";
/**
* The sub-agent result delivery card (contracts.ts "THE ASYNC PROTOCOL" §4).
*
* When a background job completes, the wake pipeline injects a STEER message
* into the transcript: a user-role UIMessage whose data part
* ("data-subagent-artifact") carries the result. It must NEVER render as a
* learner bubble: the model-facing text part is protocol glue, and the
* artifact itself renders here, by kind, reusing the same artifact cards
* the result always used.
*/
export function SubagentSteerCard({ message }: { message: TeacherUIMessage }) {
const artifactPart = message.parts.find((p) => p.type === "data-subagent-artifact");
const data = artifactPart?.data as SubagentArtifactData | undefined;
if (!data) {
return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
A sub-agent finished, but its result could not be displayed.
</div>
);
}
switch (data.kind) {
case "research":
return <ResearchBriefCard output={data.result} />;
case "demo":
return <DemoOutputCard output={data.result} />;
case "visual":
return <VisualOutputCard output={data.result} />;
}
}
+15 -12
View File
@@ -1,15 +1,19 @@
"use client";
import { DispatchCard } from "@/components/artifacts/dispatch-card";
import type { ArtifactPartProps, VisualResult } from "@/src/core";
import { JobActivityCard } from "@/components/artifacts/demo-card";
/**
* Visual artifact card (W6): while the part is input-available, a job activity
* card; when output-available, VisualResult.svg renders INLINE. The SVG is
* model-authored for the learner's own machine (their own teacher wrote it,
* verified by looking) — dangerouslySetInnerHTML is acceptable here, with a
* cheap hygiene pass (sanitizeSvg: strip <script> blocks, on* handlers, and
* javascript:/data:text/html URLs) since it lands in the app's DOM.
* Visual artifact card (W6 → ack protocol): the tool part's output is the
* dispatch ack, so the tool-call box is the DispatchCard (live status via
* the central jobs poll). The SVG itself renders from the steer message
* (sub-agent result delivery) via VisualOutputCard.
*
* The SVG is model-authored for the learner's own machine (their own teacher
* wrote it, verified by looking) — dangerouslySetInnerHTML is acceptable
* here, with a cheap hygiene pass (sanitizeSvg: strip <script> blocks, on*
* handlers, and javascript:/data:text/html URLs) since it lands in the
* app's DOM.
*/
/** Cheap SVG hygiene — not a full sanitizer, just removing the obvious exec vectors. */
@@ -22,13 +26,12 @@ export function sanitizeSvg(svg: string): string {
}
export function VisualCard({ part }: ArtifactPartProps) {
if (part.state === "output-available") {
return <VisualOutputCard output={part.output} />;
}
return <JobActivityCard toolCallId={part.toolCallId} label="Drawing figure…" testId="visual-activity-card" />;
if (part.state !== "output-available") return null;
return <DispatchCard toolCallId={part.toolCallId} kind="visual" />;
}
function VisualOutputCard({ output }: { output: unknown }) {
/** The figure itself — exported: the steer card renders the delivered artifact. */
export function VisualOutputCard({ output }: { output: unknown }) {
const result = output as VisualResult | undefined;
if (!result || typeof result.svg !== "string") {
return (
+100 -19
View File
@@ -1,6 +1,6 @@
"use client";
import { createContext, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createContext, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import { useStickToBottomContext } from "use-stick-to-bottom";
@@ -16,21 +16,24 @@ import { Shimmer } from "@/components/ai-elements/shimmer";
import { Separator } from "@/components/ui/separator";
import { AnsweredCard } from "@/components/answered-card";
import { artifactRenderers } from "@/components/artifacts";
import { SubagentSteerCard } from "@/components/artifacts/steer-card";
import { Composer } from "@/components/composer";
import { JobsContext } from "@/components/jobs-context";
import { QuestionDock } from "@/components/question-dock";
import { SidebarToggle } from "@/components/sidebar";
import { SubAgentsPanel } from "@/components/sub-agents-panel";
import { TreePanel } from "@/components/tree-panel";
import type { ArtifactPartProps, QuestionAnswerData, TeacherUIMessage } from "@/src/core";
import { findPendingJobCall, findPendingToolCall, phaseAt } from "@/src/core";
import type { ArtifactPartProps, JobRecord, QuestionAnswerData, TeacherUIMessage } from "@/src/core";
import { findPendingToolCall, phaseAt } from "@/src/core";
/** One width for everything: transcript, cards, and the bottom blob share this column. */
const COLUMN = "mx-auto w-full max-w-3xl px-4";
/**
* Server-sync channel for artifact renderers (job cards): when a background
* job flips its tool part to output-available server-side, the card polls
* /api/jobs, then calls syncFromServer() — fresh messages replace local state
* and the sendAutomaticallyWhen continuation resumes the teacher.
* Server-sync channel for artifact renderers: the CENTRAL jobs poll in Chat
* calls syncFromServer() when a background job settles — the wake turn's
* steer + response are already persisted by then, so fresh messages replace
* local state in one pull. sendText feeds the demo error-fix loop.
* (contracts.ts "THE ASYNC PROTOCOL" §4.)
*/
export const ChatActionsContext = createContext<{
@@ -43,6 +46,12 @@ export const ChatActionsContext = createContext<{
/** useLayoutEffect that is silent during SSR (client components still render on the server in Next). */
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
/** A wake-pipeline steer message: user-role delivery whose metadata marks it
* as a sub-agent result — rendered as an artifact card, never a bubble. */
function isSubagentSteer(m: TeacherUIMessage): boolean {
return (m.metadata as { subagentResult?: unknown } | undefined)?.subagentResult != null;
}
/**
* The teaching session. All chat state lives in useChat; the server persists
* the canonical transcript after every turn.
@@ -134,11 +143,12 @@ export function Chat({
(messages.at(-1)?.parts.some((p) => isToolUIPart(p) && p.state === "input-streaming") ?? false);
const [text, setText] = useState("");
// Jobs gate sending too (an unanswered tool call in the outbound list would
// break provider conversion — contracts.ts "THE ASYNC PROTOCOL" §5), but
// they don't dock: the running job's activity card sits in the transcript.
const pendingJob = findPendingJobCall(messages);
const canSend = text.trim().length > 0 && !pending && !pendingJob && !turnInFlight;
// Jobs never gate the composer (contracts.ts "THE ASYNC PROTOCOL" §4): job
// tools resolve with an ack mid-turn, so the learner can interject while
// sub-agents run — a feature, not a race: the wake pipeline queues steers
// until the current turn ends. Only a QUESTION pending or a turn in
// flight (client or server-side wake turn) gates sending.
const canSend = text.trim().length > 0 && !pending && !turnInFlight;
const syncFromServer = useCallback(async () => {
const res = await fetch(`/api/chats/${chatId}`);
@@ -158,6 +168,65 @@ export function Chat({
}
}, [chatId, setMessages, sendMessage, status]);
// ── Central job observability (contracts.ts "THE ASYNC PROTOCOL" §4) ──
// ONE poll of /api/jobs drives every job surface (transcript dispatch
// cards, the sub-agents panel) and re-syncs the transcript exactly once
// when a job settles: job status flips done/error only AFTER the wake
// turn's messages are persisted, so a completion means the record already
// holds the steer + the teacher's response — one syncFromServer adopts it
// all. Poll fast while jobs run, slow otherwise (new-dispatch discovery),
// and not at all when this session has never dispatched a job.
const [jobs, setJobs] = useState<JobRecord[]>([]);
const jobPartCount = useMemo(
() =>
messages.reduce(
(n, m) =>
n +
m.parts.filter(
(p) => p.type === "tool-research" || p.type === "tool-demo" || p.type === "tool-visual",
).length,
0,
),
[messages],
);
const anyRunning = jobs.some((j) => j.status === "running");
const pollMs = jobPartCount === 0 && jobs.length === 0 ? 0 : anyRunning ? 1000 : 4000;
const localStreamRef = useRef(false);
localStreamRef.current = localStream;
const jobStatusRef = useRef<Map<string, string>>(new Map());
useEffect(() => {
if (pollMs === 0) return;
let cancelled = false;
const poll = async () => {
if (cancelled) return;
try {
const res = await fetch(`/api/jobs?chatId=${encodeURIComponent(chatId)}`);
if (!res.ok) return;
const data = (await res.json()) as { jobs?: JobRecord[]; turnInFlight?: boolean };
if (cancelled) return;
const list = data.jobs ?? [];
for (const j of list) {
const prev = jobStatusRef.current.get(j.id);
if (prev === "running" && j.status !== "running") void syncFromServer();
jobStatusRef.current.set(j.id, j.status);
}
setJobs(list);
// Wake-turn adoption: a server-side teacher turn with no client request
// of ours (sub-agent delivery) — the same machinery as returning
// mid-turn: busy state + record poll to completion.
if (data.turnInFlight === true && !localStreamRef.current) setTurnInFlight(true);
} catch {
/* transient fetch failure — keep polling */
}
};
void poll();
const timer = setInterval(poll, pollMs);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [pollMs, jobPartCount, chatId, syncFromServer]);
// Re-attach after mid-turn navigation (the key fix): poll the record until
// the orphaned turn finishes (turnInFlight flips false server-side in
// onFinish, AFTER persist), then adopt the completed transcript — the
@@ -190,13 +259,14 @@ export function Chat({
const onSend = () => {
const t = text.trim();
if (!t || busy || pending || pendingJob) return;
if (!t || busy || pending) return;
setText("");
void sendMessage({ text: t });
};
return (
<ChatActionsContext.Provider value={{ chatId, syncFromServer, sendText: (t: string) => void sendMessage({ text: t }) }}>
<JobsContext.Provider value={jobs}>
<div className="flex h-dvh flex-col">
<header className="flex items-center gap-3 px-3 py-2">
<SidebarToggle />
@@ -221,11 +291,17 @@ export function Chat({
<Message key={m.id} from={m.role} className={m.role === "assistant" ? "max-w-full" : undefined}>
<MessageContent className={m.role === "assistant" ? "w-full" : undefined}>
{m.role === "user" ? (
// User text renders plain: the model's LaTeX pipeline is not
// applied to learner-authored text ($ stays $, no surprises).
<div className="whitespace-pre-wrap">
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
</div>
isSubagentSteer(m) ? (
// Sub-agent delivery (wake-pipeline steer): render the
// artifact, never a learner bubble (contracts.ts §4).
<SubagentSteerCard message={m} />
) : (
// User text renders plain: the model's LaTeX pipeline is not
// applied to learner-authored text ($ stays $, no surprises).
<div className="whitespace-pre-wrap">
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
</div>
)
) : (
m.parts.map((part, pi) => {
const isLastPart = mi === messages.length - 1 && pi === m.parts.length - 1;
@@ -303,9 +379,14 @@ export function Chat({
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<TreePanel messages={messages} />
{/* Right sidebar: live sub-agent fleet (when running) above the plan tree. */}
<div className="hidden shrink-0 flex-col xl:flex">
<SubAgentsPanel jobs={jobs} />
<TreePanel messages={messages} />
</div>
</div>
</div>
</JobsContext.Provider>
</ChatActionsContext.Provider>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { createContext } from "react";
import type { JobRecord } from "@/src/core";
/**
* Background-job observability channel (contracts.ts "THE ASYNC PROTOCOL" §4).
*
* ONE central poller (in Chat) fetches GET /api/jobs?chatId=… and publishes
* the list here; every job surface consumes it — the transcript dispatch
* cards (the ack box with live status) and the sub-agents sidebar panel.
* Cards used to each run their own poller on input-available parts; under
* the ack-protocol the tool part resolves immediately, so the poller moved
* to the composition root and the cards became pure renderers of this data.
*/
export const JobsContext = createContext<JobRecord[]>([]);
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { BotIcon, PenToolIcon, PlayIcon, SearchIcon } from "lucide-react";
import type { JobKind, JobRecord } from "@/src/core";
/**
* The sub-agents sidebar section (vision §2.5 live observability): what the
* teacher's background fleet is doing RIGHT NOW in this session — one card
* per running sub-agent with its live activity line (current tool + args)
* and elapsed time, fed by the central jobs poll.
*
* Renders nothing when the fleet is idle (zero clutter); finished jobs
* vanish from the list — their artifacts live in the transcript. Sits at
* the top of the right sidebar, above the plan-tree panel.
*/
const KIND_META: Record<JobKind, { label: string; Icon: typeof SearchIcon }> = {
research: { label: "Researcher", Icon: SearchIcon },
demo: { label: "Demo builder", Icon: PlayIcon },
visual: { label: "Figure maker", Icon: PenToolIcon },
};
export function SubAgentsPanel({ jobs }: { jobs: JobRecord[] }) {
const running = jobs.filter((j) => j.status === "running");
if (running.length === 0) return null;
return (
<aside
data-testid="sub-agents-panel"
className="flex w-64 shrink-0 flex-col border-b border-border/60 bg-card/30 px-3 py-2.5"
>
<div className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
<BotIcon className="size-3.5" />
Sub-agents
</div>
<ul className="mt-2 flex flex-col gap-2">
{running.map((job) => (
<li
key={job.id}
className="rounded-lg border bg-card px-2.5 py-2 text-card-foreground"
data-testid="sub-agent-card"
>
<div className="flex items-center gap-2 text-xs font-medium">
{(() => {
const { label, Icon } = KIND_META[job.kind] ?? KIND_META.research;
return <Icon className="size-3.5 shrink-0 text-muted-foreground" />;
})()}
<span className="min-w-0 flex-1 truncate">{KIND_META[job.kind]?.label ?? job.kind}</span>
<span
className="inline-block size-1.5 animate-pulse rounded-full bg-primary"
aria-hidden="true"
/>
<span className="shrink-0 tabular-nums text-muted-foreground" data-testid="sub-agent-elapsed">
{elapsedOf(job)}
</span>
</div>
{job.activity && (
<div
className="mt-1 truncate text-xs text-muted-foreground"
data-testid="sub-agent-activity"
title={job.activity}
>
{job.activity}
</div>
)}
</li>
))}
</ul>
</aside>
);
}
function elapsedOf(job: JobRecord): string {
if (!job.createdAt) return "";
const seconds = Math.max(0, Math.round((Date.now() - Date.parse(job.createdAt)) / 1000));
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}