diff --git a/app/api/jobs/route.ts b/app/api/jobs/route.ts index 1eed6b2..0af5703 100644 --- a/app/api/jobs/route.ts +++ b/app/api/jobs/route.ts @@ -1,10 +1,12 @@ -import { listJobs } from "@/src/server"; +import { isTurnRunning, listJobs } from "@/src/server"; /** * Job status polling endpoint (contracts.ts "THE ASYNC PROTOCOL" §4): the - * client polls GET /api/jobs?chatId=… while any job tool part is pending, - * finds the job by toolCallId, renders its activity line, and re-syncs the - * chat when the job flips to done/error. + * client's ONE central poller fetches this while any job runs (and slowly + * otherwise) to drive the dispatch cards, the sub-agents panel, and the + * re-sync when a job settles. `turnInFlight` rides along so the client can + * adopt a server-side wake turn (busy state + composer gate) — the same + * machinery as returning to a session mid-turn. */ export async function GET(req: Request) { const chatId = new URL(req.url).searchParams.get("chatId"); @@ -14,5 +16,5 @@ export async function GET(req: Request) { if (!/^[A-Za-z0-9_-]+$/.test(chatId)) { return Response.json({ error: "Invalid chatId" }, { status: 400 }); } - return Response.json({ jobs: listJobs(chatId) }); + return Response.json({ jobs: listJobs(chatId), turnInFlight: isTurnRunning(chatId) }); } diff --git a/components/artifacts/demo-card.tsx b/components/artifacts/demo-card.tsx index 9037e85..29b9441 100644 --- a/components/artifacts/demo-card.tsx +++ b/components/artifacts/demo-card.tsx @@ -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(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 ( -
-
- - {label} -
- {job?.activity && ( -
- {job.activity} -
- )} -
- ); -} - // ─── 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 ; - } - return ; + if (part.state !== "output-available") return null; + return ; } -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 ( diff --git a/components/artifacts/dispatch-card.tsx b/components/artifacts/dispatch-card.tsx new file mode 100644 index 0000000..d5dbf68 --- /dev/null +++ b/components/artifacts/dispatch-card.tsx @@ -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 = { + 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 ; + } + const failed = job.status === "error"; + return ( +
+ {failed ? : } + {label} + {failed ? "failed" : "delivered"} +
+ ); +} + +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 ( +
+
+ + {label} + {elapsed !== null && · {elapsed}s} +
+ {job?.activity && ( +
+ {job.activity} +
+ )} + {!job && ( +
+ Dispatched… +
+ )} +
+ ); +} diff --git a/components/artifacts/job-card.tsx b/components/artifacts/job-card.tsx index 14b3ea7..c6e2950 100644 --- a/components/artifacts/job-card.tsx +++ b/components/artifacts/job-card.tsx @@ -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 ; - } - return ; + if (part.state !== "output-available") return null; + return ; } -function ResearchActivityCard({ toolCallId }: { toolCallId: string }) { - const actions = useContext(ChatActionsContext); - const [job, setJob] = useState(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 ( -
-
- - Researching… -
- {job?.activity && ( -
- {job.activity} -
- )} -
- ); -} - -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 ( diff --git a/components/artifacts/steer-card.tsx b/components/artifacts/steer-card.tsx new file mode 100644 index 0000000..bd7fe4c --- /dev/null +++ b/components/artifacts/steer-card.tsx @@ -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 ( +
+ A sub-agent finished, but its result could not be displayed. +
+ ); + } + switch (data.kind) { + case "research": + return ; + case "demo": + return ; + case "visual": + return ; + } +} diff --git a/components/artifacts/visual-card.tsx b/components/artifacts/visual-card.tsx index 1c7f14b..f96f9f5 100644 --- a/components/artifacts/visual-card.tsx +++ b/components/artifacts/visual-card.tsx @@ -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