diff --git a/app/layout.tsx b/app/layout.tsx index f8c1df1..d108f76 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import "katex/dist/katex.min.css"; import "./globals.css"; import { AppShell, SidebarProvider } from "@/components/sidebar"; -import { TreePanelModeProvider } from "@/components/tree-panel"; +import { ActivityDock, ActivityProvider } from "@/components/activity-dock"; export const metadata: Metadata = { title: "Learning Harness", @@ -14,10 +14,13 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - - {children} - - + + {children} + {/* The reserved bottom-right corner of every view — renders + nothing while no background process runs. */} + + + ); diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx new file mode 100644 index 0000000..cdf349d --- /dev/null +++ b/components/activity-dock.tsx @@ -0,0 +1,453 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { ActivityIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon, XIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { CallUsage, JobKind, JobRecord } from "@/src/core"; +import { CONTEXT_LIMITS, jobCost } from "@/src/core"; +import { formatTokens, formatUsd } from "@/components/usage-format"; + +/** + * THE ACTIVITY DOCK (wave 4, vision observability): the bottom-right corner + * of EVERY view, one blob per running BACKGROUND process — session sub-agents + * (research/demo/visual) and global extraction runs. Teaching turns are + * deliberately NOT here: the dock exists to give background processes the + * observability the learner can't otherwise see; an in-flight teaching turn + * is fully visible where it happens (user decision, 2026-09). + * + * Data: ONE poll of GET /api/activity (jobs across ALL chats, title-joined, + * oldest-first + extraction runs), folded client-side with core/prices.ts. + * Cost is INFERRED (jobCost); the context line is the LAST model call's + * input+output against the model's limit (JobUsage.lastStep — pending + * contract) — never a cumulative sum, which would be the wrong number. + * Absent data renders "—", never zero (the doctrine). + * + * Quiet/ambient by design: no entry animations, stable keys, updates land + * in place — polling never flickers. Idle (no jobs, no extractions) renders + * NOTHING. Narrow viewports collapse to a count badge → click opens the + * drawer. Per blob: kind icon, elapsed, cost, context, activity line, and an + * explicit END button (POST /api/activity/abort — the wave-4 backend is + * live): 404/409 mean the process is already gone (refresh, no error); + * real failures degrade to an inline "Abort failed", never a + * dead control). + */ + +// ─── Wire shapes (client-safe mirrors of server/observability.ts) ──────────── + +/** JobUsage extended by the pending lastStep contract (the context line). */ +interface FeedUsage extends NonNullable { + lastStep?: CallUsage; +} + +export interface FeedJob extends JobRecord { + title: string; + usage?: FeedUsage; +} + +export interface FeedExtraction { + chatId: string; + title: string; + phase: string; + startedAt: number; +} + +export interface ActivityFeed { + jobs: FeedJob[]; + extractions: FeedExtraction[]; +} + +const EMPTY_FEED: ActivityFeed = { jobs: [], extractions: [] }; + +// ─── The one poll (composition root: the root layout) ──────────────────────── + +const ActivityContext = createContext<{ feed: ActivityFeed; refresh: () => void }>({ + feed: EMPTY_FEED, + refresh: () => {}, +}); + +/** + * Polls /api/activity for the whole app shell. Adaptive interval: fast while + * anything runs, slow when idle, ~4× slower on hidden tabs. A failed fetch + * keeps the last feed (the dock never flickers to empty on a blip). + */ +export function ActivityProvider({ children }: { children: React.ReactNode }) { + const [feed, setFeed] = useState(EMPTY_FEED); + const [nonce, setNonce] = useState(0); + const refresh = useCallback(() => setNonce((n) => n + 1), []); + const hasRunning = feed.jobs.length > 0 || feed.extractions.length > 0; + + useEffect(() => { + let alive = true; + let timer: ReturnType | undefined; + const poll = async () => { + try { + const res = await fetch("/api/activity"); + if (res.ok) { + const data = (await res.json()) as Partial; + if (alive) + setFeed({ jobs: data.jobs ?? [], extractions: data.extractions ?? [] }); + } + } catch { + // transport blip: keep the last feed, try again + } + if (!alive) return; + const base = hasRunning ? 2500 : 10000; + timer = setTimeout(poll, document.hidden ? base * 4 : base); + }; + void poll(); + return () => { + alive = false; + if (timer) clearTimeout(timer); + }; + }, [hasRunning, nonce]); + + return {children}; +} + +// ─── Pure presentation helpers (exported for tests) ────────────────────────── + +/** One dock blob: the unified view of a job or an extraction run. */ +export interface ActivityBlob { + /** job.id, or the extraction's chatId (one run per chat). */ + id: string; + process: "job" | "extraction"; + /** Sub-agent kind for jobs (extraction blobs have none). */ + jobKind?: JobKind; + /** Extraction phase (extracting/auditing/merging); jobs carry none. */ + phase?: string; + title: string; + chatId: string; + /** ms epoch — blobs order oldest-first and elapsed counts from it. */ + startedAt: number; + /** Inferred USD cost (jobCost) — null renders "—". */ + cost: number | null; + /** Last model call's footprint vs the model's limit — null renders "—". */ + context: { used: number; limit: number } | null; + /** Latest human-readable activity line (""). */ + activity: string; + /** Sub-agent model id, when usage is reported. */ + model?: string; + /** Model calls made so far, when usage is reported. */ + steps?: number; +} + +/** Merge the feed into one oldest-first blob list (the stable blob order). */ +export function toBlobs(feed: ActivityFeed): ActivityBlob[] { + const blobs: ActivityBlob[] = feed.jobs.map((job) => { + const usage = job.usage; + let context: ActivityBlob["context"] = null; + const lastStep = usage?.lastStep; + const limit = usage ? CONTEXT_LIMITS[usage.model] : undefined; + if (usage && lastStep && limit !== undefined && lastStep.inputTokens !== undefined) { + context = { used: lastStep.inputTokens + (lastStep.outputTokens ?? 0), limit }; + } + return { + id: job.id, + process: "job", + jobKind: job.kind, + title: job.title, + chatId: job.chatId, + startedAt: Date.parse(job.createdAt) || 0, + cost: jobCost(job), + context, + activity: job.activity ?? "", + model: usage?.model, + steps: usage?.steps, + }; + }); + for (const run of feed.extractions) { + blobs.push({ + id: run.chatId, + process: "extraction", + phase: run.phase, + title: run.title, + chatId: run.chatId, + startedAt: run.startedAt, + cost: null, // extraction runs don't ride the money channel (v1) + context: null, + activity: "", + }); + } + blobs.sort((a, b) => a.startedAt - b.startedAt); + return blobs; +} + +/** Elapsed as a compact ticking string: 45s / 3m 04s / 1h 12m. */ +export function formatElapsed(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`; + return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`; +} + +// ─── The dock ───────────────────────────────────────────────────────────────── + +/** Narrow viewports (< sm) collapse the dock to a count badge → drawer. */ +function useNarrow(): boolean { + const [narrow, setNarrow] = useState(false); + useEffect(() => { + const mq = window.matchMedia("(max-width: 639px)"); + const apply = () => setNarrow(mq.matches); + apply(); + mq.addEventListener("change", apply); + return () => mq.removeEventListener("change", apply); + }, []); + return narrow; +} + +/** A 1s heartbeat while the dock is on screen — elapsed lines tick. */ +function useNow(active: boolean): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!active) return; + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, [active]); + return now; +} + +const KIND_ICON: Record = { + research: SearchIcon, + demo: PlayIcon, + visual: PenToolIcon, +}; + +/** Abort states per blob id: pending (fired) / failed (backend absent). */ +type AbortState = "pending" | "failed"; + +export function ActivityDock({ feed: feedProp }: { feed?: ActivityFeed }) { + const ctx = useContext(ActivityContext); + const feed = feedProp ?? ctx.feed; + const refresh = ctx.refresh; + const blobs = useMemo(() => toBlobs(feed), [feed]); + const narrow = useNarrow(); + const [drawerOpen, setDrawerOpen] = useState(false); + const [openBlob, setOpenBlob] = useState(null); // detail popover + const [abortStates, setAbortStates] = useState>({}); + const now = useNow(blobs.length > 0); + // Refs for stable abort handlers (no re-created closures per tick). + const abortRef = useRef({ blobs, refresh, setAbortStates }); + abortRef.current = { blobs, refresh, setAbortStates }; + + const totalCost = useMemo(() => { + const priced = blobs.map((b) => b.cost).filter((c): c is number => c !== null); + return priced.length === 0 ? null : priced.reduce((a, c) => a + c, 0); + }, [blobs]); + + const abort = useCallback(async (blob: ActivityBlob) => { + const { setAbortStates: set } = abortRef.current; + set((s) => ({ ...s, [blob.id]: "pending" })); + try { + const res = await fetch("/api/activity/abort", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + kind: blob.process === "job" ? "job" : "extraction", + id: blob.id, + }), + }); + if (!res.ok) { + // 404 not_found / 409 not_running: the process is already gone — + // the desired end state. Refresh and clear; nothing to report. + if (res.status === 404 || res.status === 409) { + abortRef.current.refresh(); + set((s) => { + const { [blob.id]: _drop, ...rest } = s; + return rest; + }); + return; + } + throw new Error(String(res.status)); + } + abortRef.current.refresh(); + } catch { + set((s) => ({ ...s, [blob.id]: "failed" })); + } + }, []); + + if (blobs.length === 0) return null; // idle: the dock is invisible + + const count = blobs.length; + // Narrow: a quiet count badge; the drawer opens on click. + if (narrow && !drawerOpen) { + return ( + + ); + } + + return ( +
+
+ + Background activity + + {totalCost === null ? "—" : formatUsd(totalCost)} + + {narrow && ( + + )} +
+ {blobs.map((blob) => ( + setOpenBlob((id) => (id === blob.id ? null : blob.id))} + abortState={abortStates[blob.id]} + onAbort={() => void abort(blob)} + /> + ))} +
+ ); +} + +/** One blob: icon + elapsed + cost on the face; click → detail popover. */ +function ActivityBlobRow({ + blob, + now, + detailOpen, + onToggleDetail, + abortState, + onAbort, +}: { + blob: ActivityBlob; + now: number; + detailOpen: boolean; + onToggleDetail: () => void; + abortState: AbortState | undefined; + onAbort: () => void; +}) { + const Icon = blob.process === "extraction" ? WaypointsIcon : (blob.jobKind && KIND_ICON[blob.jobKind]) || BotIcon; + const label = + blob.process === "extraction" + ? `Extraction — ${blob.phase ?? "running"}` + : blob.jobKind === "research" + ? "Researcher" + : blob.jobKind === "demo" + ? "Demo builder" + : blob.jobKind === "visual" + ? "Figure maker" + : "Sub-agent"; + return ( +
+ + {/* The explicit END always on the blob's face (SCOPE) — the popover + is for DETAIL, not the control. A failed abort locks the button and + says why; a succeeded one just makes the blob vanish on refresh. */} + + + {detailOpen && ( +
+
+ + {label} + + {formatElapsed(now - blob.startedAt)} + +
+
+
+ {blob.title} +
+ {blob.activity &&
{blob.activity}
} +
+ Cost + + {blob.cost === null ? "—" : formatUsd(blob.cost)} + +
+
+ Context + + {blob.context === null + ? "—" + : `${formatTokens(blob.context.used)} / ${formatTokens(blob.context.limit)}`} + +
+ {blob.model && ( +
+ Model + {blob.model} +
+ )} + {blob.steps !== undefined && ( +
+ Steps + {blob.steps} +
+ )} + + Open session → + +
+
+ {abortState === "failed" && "Abort failed — try again"} + {abortState === "pending" && "Stopping…"} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/components/chat.tsx b/components/chat.tsx index 9415f7d..274ae5c 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -20,11 +20,12 @@ import { artifactRenderers } from "@/components/artifacts"; import { SubagentSteerCard } from "@/components/artifacts/steer-card"; import { Composer } from "@/components/composer"; import { JobsContext } from "@/components/jobs-context"; +import { StatsBar } from "@/components/stats-bar"; +import { PlanBlock, PlanPopup } from "@/components/plan-block"; +import type { PlanBlockData } from "@/src/core"; 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, JobRecord, Phase3, QuestionAnswerData, TeacherUIMessage } from "@/src/core"; +import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TeacherUIMessage, TurnUsage } from "@/src/core"; import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider"; import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core"; @@ -160,7 +161,8 @@ export function Chat({ const syncFromServer = useCallback(async () => { const res = await fetch(`/api/chats/${chatId}`); if (!res.ok) return; - const record = (await res.json()) as { messages?: TeacherUIMessage[] }; + const record = (await res.json()) as { messages?: TeacherUIMessage[]; turns?: TurnUsage[] }; + if (Array.isArray(record.turns)) setTurns(record.turns); if (!Array.isArray(record.messages)) return; setMessages(record.messages); // setMessages does NOT fire sendAutomaticallyWhen (the SDK only checks @@ -175,6 +177,36 @@ export function Chat({ } }, [chatId, setMessages, sendMessage, status]); + // The live plan popup (wave 4): opened by any transcript plan block. + const [planOpen, setPlanOpen] = useState(false); + + // ── Session usage (wave-4 stats bar) ── + // The money channel: the record's turns log, folded client-side by + // core/prices.ts. Seeded once on mount (the page doesn't carry turns), + // refreshed when the local stream settles (each turn appends to the log + // server-side), and kept fresh by syncFromServer whenever it already + // fetches the record. No turns yet → the bar renders "—" everywhere. + const [turns, setTurns] = useState([]); + const syncTurns = useCallback(async () => { + try { + const res = await fetch(`/api/chats/${chatId}`); + if (!res.ok) return; + const record = (await res.json()) as { turns?: TurnUsage[] }; + if (Array.isArray(record.turns)) setTurns(record.turns); + } catch { + // transient blip: stats stay on the last known figures + } + }, [chatId]); + useEffect(() => { + void syncTurns(); + }, [syncTurns]); + // After the learner's own turn ends the log has a fresh entry — refetch. + const prevStatusRef = useRef(status); + useEffect(() => { + if (prevStatusRef.current !== "ready" && status === "ready") void syncTurns(); + prevStatusRef.current = status; + }, [status, syncTurns]); + // ── 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 @@ -360,6 +392,16 @@ export function Chat({ ); case "step-start": return pi > 0 ? : null; + case "data-plan-block": + // The plan in the chat (wave 4): a frozen snapshot + // block — rendered as carried, never re-derived. + return ( + setPlanOpen(true)} + /> + ); case "tool-quiz": case "tool-ask_user_question": { // Pending/streaming questions live in the dock, not the @@ -400,6 +442,7 @@ export function Chat({ pending={pending} requiresReveal={requiresReveal} phase={phases.at(-1) ?? "probe"} + turns={turns} composer={ - {/* Right sidebar, a full-height sibling of the middle column: the - live sub-agent fleet (when running) above the plan tree — the - fleet panel renders nothing while idle, so the column is all plan. */} -
- - -
+ {planOpen && setPlanOpen(false)} />} ); @@ -453,6 +490,7 @@ function DockOverlay({ pending, requiresReveal, phase, + turns, composer, onSubmitAnswer, }: { @@ -461,6 +499,8 @@ function DockOverlay({ requiresReveal: boolean; /** Session phase at the pending question — scales the thinking field. */ phase: Phase3; + /** The session's usage log — the stats bar beneath the composer folds it. */ + turns: TurnUsage[]; composer: React.ReactNode; onSubmitAnswer: (output: QuestionAnswerData) => void; }) { @@ -623,6 +663,10 @@ function DockOverlay({ )} + {/* The stats row: beneath the composer/question shell, not + flush to the screen bottom — static height, outside the + morphing shell so it never joins a height animation. */} + diff --git a/components/plan-block.tsx b/components/plan-block.tsx new file mode 100644 index 0000000..33c6541 --- /dev/null +++ b/components/plan-block.tsx @@ -0,0 +1,534 @@ +"use client"; + +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { PlanBlockData, PlanNodeStatus, PlanTree } from "@/src/core"; +import { derivedNodeStatus, derivePlanStatuses } from "@/src/core"; + +/** + * PLAN BLOCKS — the plan lives in the chat (wave 4): persisted + * `data-plan-block` parts render as DAG blocks in the transcript flow, and + * the right sidebar is retired. + * + * - kind "full": the whole DAG, BORDERLESS — no wrapping chrome, the SVG + * scales to the full column width (viewBox = layout extent, so the + * leftmost/rightmost nodes hug the edges), natural height, no cap. + * - kind "neighborhood": the fragment in its own small container, labeled + * as a FRAGMENT (clearly not the whole plan), the current node glowing. + * - Clicking either opens the full plan in a full-screen popup overlay — + * the LIVE plan: the latest tree from the transcript with statuses + * derived at click time (contracts/plan.ts derivePlanStatuses). Blocks + * are frozen snapshots rendered exactly as carried (never re-derived); + * the popup is the live view (user decision, 2026-09). + * + * Hand-built SVG everywhere (user decision): a deterministic layered + * layout fills exactly and stays SSR-testable — no force simulation, no + * coupling to the graph stream's sigma internals. Pan/zoom in the popup + * is harvested from the retired tree panel's graph (fit-floor semantics: + * fully zoomed out IS the fit; panning locks at the floor). + */ + +// ─── Layout constants + pure helpers (exported for tests) ──────────────────── + +export const NODE_W = 160; +export const NODE_H = 44; +export const GAP_X = 24; +export const GAP_Y = 48; + +/** A node for layout purposes — the payload's shape and the tree's shape agree. */ +export interface LayoutNode { + id: string; + title: string; +} + +/** Topological order + longest-path depth (Kahn, authoring tie-break). */ +export function topoFor(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ from: string; to: string }>) { + const index = new Map(nodes.map((n, i) => [n.id, i] as const)); + const indegree = new Map(nodes.map((n) => [n.id, 0] as const)); + const dependents = new Map(); + for (const e of edges) { + if (!index.has(e.from) || !index.has(e.to)) continue; // defensive: dangling ids + indegree.set(e.to, (indegree.get(e.to) ?? 0) + 1); + const list = dependents.get(e.from); + if (list) list.push(e.to); + else dependents.set(e.from, [e.to]); + } + const ready = nodes.filter((n) => (indegree.get(n.id) ?? 0) === 0).map((n) => n.id); + const order: LayoutNode[] = []; + const depth = new Map(); + const byId = new Map(nodes.map((n) => [n.id, n] as const)); + while (ready.length > 0) { + ready.sort((a, b) => (index.get(a) ?? 0) - (index.get(b) ?? 0)); + const id = ready.shift()!; + const node = byId.get(id); + if (node) order.push(node); + const d = depth.get(id) ?? 0; + depth.set(id, d); + for (const t of dependents.get(id) ?? []) { + if ((depth.get(t) ?? -1) < d + 1) depth.set(t, d + 1); + const left = (indegree.get(t) ?? 0) - 1; + indegree.set(t, left); + if (left === 0) ready.push(t); + } + } + for (const n of nodes) { + if (!depth.has(n.id)) { + order.push(n); + depth.set(n.id, 0); + } + } + return { order, depth }; +} + +/** Layered positions: depth = row (top-down), authoring order within the + * row, each row centered on the widest one. */ +export function blockLayout(nodes: readonly LayoutNode[], edges: ReadonlyArray<{ from: string; to: string }>) { + const { order, depth } = topoFor(nodes, edges); + const layers: LayoutNode[][] = []; + for (const n of order) { + const d = depth.get(n.id) ?? 0; + if (!layers[d]) layers[d] = []; + layers[d].push(n); + } + const rowWidth = (l: LayoutNode[]) => l.length * NODE_W + (l.length - 1) * GAP_X; + let maxWidth = 0; + for (const l of layers) maxWidth = Math.max(maxWidth, rowWidth(l)); + const x = new Map(); + const y = new Map(); + layers.forEach((l, d) => { + const start = (maxWidth - rowWidth(l)) / 2; + l.forEach((n, i) => { + x.set(n.id, start + i * (NODE_W + GAP_X)); + y.set(n.id, d * (NODE_H + GAP_Y)); + }); + }); + const height = layers.length * NODE_H + Math.max(0, layers.length - 1) * GAP_Y; + return { x, y, width: maxWidth, height }; +} + +/** Greedy word wrap to ≤ maxLines lines of ≤ maxChars; overflow ends in an + * ellipsis, a single oversized word is hard-clamped. Pure. */ +export function wrapTitle(title: string, maxChars = 20, maxLines = 2): string[] { + const words = title.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return [""]; + const lines: string[] = []; + for (const w of words) { + const last = lines[lines.length - 1]; + const fits = last !== undefined && last.length + 1 + w.length <= maxChars; + if (fits) { + lines[lines.length - 1] = `${last} ${w}`; + } else if (lines.length < maxLines) { + lines.push(w.length > maxChars ? `${w.slice(0, maxChars - 1)}…` : w); + } else { + const lastLine = lines[lines.length - 1]; + if (lastLine !== undefined && !lastLine.endsWith("…")) { + lines[lines.length - 1] = + lastLine.length >= maxChars ? `${lastLine.slice(0, maxChars - 1)}…` : `${lastLine}…`; + } + } + } + return lines; +} + +// ─── Status styling (the retired panel's color language, unchanged) ────────── + +const BOX: Record = { + done: "fill-phase-teach/10 stroke-phase-teach/50", + current: "fill-primary/10 stroke-primary", + upcoming: "fill-card stroke-border", +}; +const TEXT: Record = { + done: "fill-muted-foreground text-xs", + current: "fill-foreground text-xs font-medium", + upcoming: "fill-muted-foreground/80 text-xs", +}; + +/** The diagram itself: positioned nodes + bezier edges. Pure-ish render — + * every input is data, so SSR render tests pin the whole thing. */ +function PlanDiagram({ + nodes, + edges, + statusOf, + glowId, + markerId, +}: { + nodes: readonly LayoutNode[]; + edges: ReadonlyArray<{ from: string; to: string }>; + statusOf: (id: string) => PlanNodeStatus; + glowId?: string | null; + markerId: string; +}) { + const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]); + return ( + + + + + + + {edges.map((e) => { + const x1 = (layout.x.get(e.from) ?? 0) + NODE_W / 2; + const y1 = (layout.y.get(e.from) ?? 0) + NODE_H; + const x2 = (layout.x.get(e.to) ?? 0) + NODE_W / 2; + const y2 = layout.y.get(e.to) ?? 0; + const mid = (y1 + y2) / 2; + return ( + ${e.to}`} + d={`M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`} + className="fill-none stroke-muted-foreground/40" + strokeWidth={1.5} + markerEnd={`url(#${markerId})`} + /> + ); + })} + {nodes.map((n) => { + const status = statusOf(n.id); + const glow = n.id === glowId; + const lines = wrapTitle(n.title); + return ( + + {glow && ( + // The glow target: a soft primary halo breathing around the box. + + )} + + {lines.map((line, i) => ( + + {line} + + ))} + + ); + })} + + ); +} + +// ─── The transcript block ───────────────────────────────────────────────────── + +/** + * A persisted plan-block part, rendered as carried (snapshot statuses, + * never re-derived). Full = borderless and column-filling; neighborhood = + * a labeled fragment container with the current node glowing. Either + * opens the live plan popup. + */ +export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () => void }) { + const markerId = useId().replace(/[^a-zA-Z0-9-]/g, ""); + const statusMap = useMemo(() => new Map(data.nodes.map((n) => [n.id, n.status] as const)), [data]); + const statusOf = (id: string): PlanNodeStatus => statusMap.get(id) ?? "upcoming"; + + if (data.kind === "full") { + return ( + + ); + } + return ( +
+
+ Plan fragment +
+ +
+ ); +} + +// ─── The live plan popup ────────────────────────────────────────────────────── + +/** Minimal structural message slice (same trick as phases.ts). */ +export interface PopupMessage { + parts: ReadonlyArray<{ type: string; state?: string; input?: unknown; output?: unknown }>; +} + +/** The latest plan tree in the transcript (latest tool-plan_ output). */ +function latestTree(messages: readonly PopupMessage[]): PlanTree | null { + for (let m = messages.length - 1; m >= 0; m--) { + const parts = messages[m]?.parts; + if (!parts) continue; + for (let p = parts.length - 1; p >= 0; p--) { + const part = parts[p]!; + if (!part.type.startsWith("tool-plan_") || part.state !== "output-available") continue; + const output = part.output as { tree?: PlanTree } | undefined; + if (output?.tree && Array.isArray(output.tree.nodes)) return output.tree; + } + } + return null; +} + +/** Zoom bounds: never below fit (fully zoomed out IS the fit), never above 2×. */ +const ZOOM_MAX = 2; +const ZOOM_FLOOR = 0.15; +const FIT_PAD = 16; +const FIT_ZOOM_MAX = 1.5; + +interface GraphView { + zoom: number; + x: number; + y: number; +} + +function fitZoomFor(box: { w: number; h: number }, viewport: { w: number; h: number }): number { + if (!viewport.w || !viewport.h || box.w === 0) return 1; + return Math.max( + ZOOM_FLOOR, + Math.min((viewport.w - FIT_PAD) / box.w, (viewport.h - FIT_PAD) / box.h, FIT_ZOOM_MAX), + ); +} + +function fitGraphView(box: { w: number; h: number }, viewport: { w: number; h: number }, zoom: number): GraphView { + return { zoom, x: (viewport.w - box.w * zoom) / 2, y: (viewport.h - box.h * zoom) / 2 }; +} + +function zoomedGraphView( + view: GraphView, + factor: number, + floor: number, + focus: { x: number; y: number }, + box: { w: number; h: number }, + viewport: { w: number; h: number }, +): GraphView { + const zoom = Math.min(Math.max(view.zoom * factor, floor), ZOOM_MAX); + if (zoom === view.zoom) return view; + if (zoom <= floor) return fitGraphView(box, viewport, floor); + const k = zoom / view.zoom; + return { zoom, x: focus.x - (focus.x - view.x) * k, y: focus.y - (focus.y - view.y) * k }; +} + +/** + * The full plan, live: latest tree + statuses derived at click time. Esc, + * backdrop click, or the X close it; drag pans, wheel zooms to the cursor, + * the zoom floor is the fit (panning locks at it — nothing to move). + */ +export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessage[]; onClose: () => void }) { + const tree = useMemo(() => latestTree(messages), [messages]); + const statuses = useMemo( + () => (tree ? derivePlanStatuses(tree, messages as Parameters[1]) : null), + [tree, messages], + ); + const statusOf = (id: string): PlanNodeStatus => (statuses ? derivedNodeStatus(statuses, id) : "upcoming"); + + // Esc closes. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + // ── pan/zoom viewport (harvested semantics: fit floor = the default) ── + const viewportRef = useRef(null); + const [size, setSize] = useState({ w: 0, h: 0 }); + useEffect(() => { + const el = viewportRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight })); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const layout = useMemo(() => (tree ? blockLayout(tree.nodes, tree.edges) : null), [tree]); + const box = useMemo(() => (layout ? { w: layout.width, h: layout.height } : { w: 0, h: 0 }), [layout]); + const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]); + + const [view, setView] = useState({ x: 0, y: 0, zoom: 1 }); + const userAdjustedRef = useRef(false); + const atFloor = view.zoom <= fitZoom + 1e-9; + + useEffect(() => { + if (atFloor) userAdjustedRef.current = false; + }, [atFloor]); + useEffect(() => { + if (userAdjustedRef.current || !size.w || !size.h) return; + setView(fitGraphView(box, size, fitZoom)); + }, [fitZoom, size, box]); + + useEffect(() => { + const el = viewportRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; + const factor = Math.exp(-dy * 0.0015); + const rect = el.getBoundingClientRect(); + setView((v) => + zoomedGraphView(v, factor, fitZoom, { x: e.clientX - rect.left, y: e.clientY - rect.top }, box, size), + ); + userAdjustedRef.current = true; + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, [fitZoom, box, size]); + + const dragRef = useRef<{ px: number; py: number; x: number; y: number; moved: boolean } | null>(null); + const startDrag = (e: React.PointerEvent) => { + if (e.button !== 0) return; + if ((e.target as HTMLElement).closest("button")) return; + if (atFloor) return; + dragRef.current = { px: e.clientX, py: e.clientY, x: view.x, y: view.y, moved: false }; + e.currentTarget.classList.add("cursor-grabbing"); + e.currentTarget.setPointerCapture(e.pointerId); + }; + const moveDrag = (e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + d.moved = true; + setView((v) => + v.zoom <= fitZoom + 1e-9 ? v : { ...v, x: d.x + (e.clientX - d.px), y: d.y + (e.clientY - d.py) }, + ); + }; + const endDrag = (e: React.PointerEvent) => { + const d = dragRef.current; + if (d) { + dragRef.current = null; + if (d.moved) userAdjustedRef.current = true; + } + e.currentTarget.classList.remove("cursor-grabbing"); + }; + const stepZoom = (factor: number) => { + userAdjustedRef.current = true; + setView((v) => zoomedGraphView(v, factor, fitZoom, { x: size.w / 2, y: size.h / 2 }, box, size)); + }; + const fitView = () => { + userAdjustedRef.current = false; + if (!size.w || !size.h) return; + setView(fitGraphView(box, size, fitZoom)); + }; + + const markerId = useId().replace(/[^a-zA-Z0-9-]/g, ""); + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ The plan + live — statuses derive from your check outcomes + +
+
+ {tree ? ( + <> +
+ +
+
+ Drag to pan · scroll to zoom +
+ + + +
+
+ + ) : ( +
+ No plan in this session yet. +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx new file mode 100644 index 0000000..673a1d8 --- /dev/null +++ b/components/stats-bar.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useState } from "react"; +import { contextWindow, sessionCost, turnCost, type TurnUsage } from "@/src/core"; +import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format"; + +/** + * The below-composer stats bar (wave 4 observability): the session's + * money/context footprint as one quiet row beneath the composer — + * LEFT-aligned: context window + session cost; RIGHT-aligned: model + + * thinking level. Clicking the left side opens the per-turn detail popover. + * + * All folds come from core/prices.ts (sessionCost, contextWindow, turnCost) + * — no cost math lives here. Absent data renders "—", never zero (the + * doctrine): no turns yet → every figure is "—"; turns the price table + * can't price count as unpriced, shown in the popover, never folded into a + * fake $0. The RIGHT side is a PLACEHOLDER for the model + thinking-level + * control (later work, with its contract changes): display-only for now, + * model read from the last turn's usage. + */ +export function StatsBar({ turns }: { turns: TurnUsage[] }) { + const [open, setOpen] = useState(false); + const ctx = contextWindow({ turns }); + const { cost, unpriced } = sessionCost({ turns }); + // Honest cost: show dollars only when at least one turn priced; + // otherwise "—" (never a fake $0.0000). + const pricedCount = turns.length - unpriced; + const costLabel = pricedCount > 0 ? formatUsd(cost) : "—"; + const lastModel = turns.at(-1)?.model; + + return ( +
+ + {/* Model + thinking level — a placeholder for the coming control + (contract changes land with it); display-only today. */} +
+ {lastModel ? prettyModel(lastModel) : "—"} + · + + Thinking — + +
+ + {open && ( +
+
+ Session stats +
+
+
+ Context (last call) + + {ctx ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : "—"} + +
+
+ Total cost + {costLabel} +
+ {unpriced > 0 && ( +
+ Unpriced turns + {unpriced} +
+ )} +
+ {turns.length > 0 && ( + <> +
+ Turns +
+
    + {turns.map((turn, i) => { + const c = turnCost(turn); + return ( +
  • + + #{i + 1} · {prettyModel(turn.model)} + + + {turn.steps.length} steps + {c === null ? "—" : formatUsd(c)} + +
  • + ); + })} +
+ + )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/components/sub-agents-panel.tsx b/components/sub-agents-panel.tsx deleted file mode 100644 index 4e644e0..0000000 --- a/components/sub-agents-panel.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import { BotIcon, PenToolIcon, PlayIcon, SearchIcon } from "lucide-react"; -import type { JobKind, JobRecord } from "@/src/core"; -import { useTreePanelMode } from "@/components/tree-panel"; - -/** - * 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 = { - research: { label: "Researcher", Icon: SearchIcon }, - demo: { label: "Demo builder", Icon: PlayIcon }, - visual: { label: "Figure maker", Icon: PenToolIcon }, -}; - -/** The collapsed fleet: a quiet strip on the sidebar rail — bot icon + - * running count, exactly like the plan's node-count rail below it. Click - * expands the sidebar back to the full fleet list. */ -function SubAgentsRail({ count, onExpand }: { count: number; onExpand: () => void }) { - return ( -
- - - {count} running - -
- ); -} - -export function SubAgentsPanel({ jobs }: { jobs: JobRecord[] }) { - const { mode, go } = useTreePanelMode(); - const running = jobs.filter((j) => j.status === "running"); - if (running.length === 0) return null; - if (mode === "rail") - return go("list")} />; - return ( - - ); -} - -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`; -} diff --git a/components/tree-panel.tsx b/components/tree-panel.tsx deleted file mode 100644 index 1de4af3..0000000 --- a/components/tree-panel.tsx +++ /dev/null @@ -1,877 +0,0 @@ -"use client"; - -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { - CheckIcon, - ChevronsRightIcon, - ListTreeIcon, - MaximizeIcon, - MinusIcon, - NetworkIcon, - PlusIcon, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { planNodeStatus, type PlanNode, type PlanNodeStatus, type PlanTree } from "@/src/core"; - -/** -* The persistent plan-tree panel (vision §2 "Persistent tree view", W7). -* -* A collapsible column on the right side of the session: the whole tree, at -* all times, with per-node status (done / current / next / upcoming) derived -* from the SAME object the teacher edits — no separate state channel. The -* data source is the transcript itself: every plan_* tool returns the full -* updated tree in its output, so the panel scans backward for the LATEST -* output-available plan part and renders its tree. Live during streaming: -* set_current / mark_done outputs flip the panel mid-session. -* -* The panel and the agent's tools share one object with two faces (agent -* edits it, UI renders it) — the phase seam in the transcript -* (PhaseDivider, message-level) marks the transition; THIS panel is the -* surface for every plan_* part (they render nothing inline, by design). -* -* Three modes, one persisted preference: the LIST (nested — the default, -* and the always-visible "collapsed" view), the GRAPH (expanded: a -* hand-built mermaid-style diagram — full styling control, no mermaid -* renderer — with drag-pan + wheel-zoom whose FLOOR is fit-to-panel), and -* the RAIL (thin strip). Quiet register throughout (matches the phase -* dividers): hairline borders, muted text, one highlighted node for the -* current position. Hidden below `xl` (CSS-only). -*/ - -/** localStorage: the mode persists as "lh:tree-panel-mode" - * ("rail" | "list" | "graph"); the pre-graph key - * ("lh:tree-panel-collapsed": "1" rail / "0" list) is the fallback. */ -/** The panel width per mode — the aside AND its fixed inner layer (see - * TreePanel: the inner layer must not squish while the aside animates). */ -const PANEL_WIDTH: Record = { - rail: "w-10", - list: "w-64", - graph: "w-96", -}; - -const MODE_KEY = "lh:tree-panel-mode"; -const LEGACY_KEY = "lh:tree-panel-collapsed"; - -/** The three panel modes: rail (fully collapsed) | list (default) | graph. */ -type PanelMode = "rail" | "list" | "graph"; - -/** -* Minimal structural slice of a UI message — anything with typed parts. -* (Same shape-of-shapes trick as src/core/phases.ts.) -*/ -export interface TreePanelMessage { - parts: ReadonlyArray<{ type: string; state?: string; output?: unknown }>; -} - -/** -* The latest plan tree in the transcript: scans messages (and parts) backward -* for the LAST `tool-plan_*` part with an output available and returns its -* `output.tree`. A failed op ({ok:false}) still carries the current tree, so -* it counts; a malformed/missing output falls through to the next candidate. -* Null when no plan output exists yet (probe phase / pre-plan sessions). -*/ -export function latestTree( - messages: readonly TreePanelMessage[], -): PlanTree | null { - for (let m = messages.length - 1; m >= 0; m--) { - const parts = messages[m]?.parts; - if (!parts) continue; - for (let p = parts.length - 1; p >= 0; p--) { - const part = parts[p]!; - if ( - !part.type.startsWith("tool-plan_") || - part.state !== "output-available" - ) - continue; - const output = part.output as { tree?: PlanTree } | undefined; - if (output?.tree && Array.isArray(output.tree.nodes)) return output.tree; - } - } - return null; -} - -/** Topological layout of a tree: display order + dependency depth per node. */ -export interface TopoLayout { - /** Nodes in topological order (prerequisites first), deterministically tie-broken by authoring order. */ - order: PlanNode[]; - /** Longest-path depth from a root — drives indentation (a DAG, not a tree: a node indents under its DEEPEST prerequisite). */ - depth: Map; -} - -/** Kahn's algorithm over the tree's `requires` edges. Server ops already guarantee a DAG. */ -export function topoLayout(tree: PlanTree): TopoLayout { - const index = new Map(tree.nodes.map((n, i) => [n.id, i] as const)); - const indegree = new Map( - tree.nodes.map((n) => [n.id, 0] as const), - ); - const dependents = new Map(); - for (const e of tree.edges) { - if (!index.has(e.from) || !index.has(e.to)) continue; // defensive: dangling ids - indegree.set(e.to, (indegree.get(e.to) ?? 0) + 1); - const list = dependents.get(e.from); - if (list) list.push(e.to); - else dependents.set(e.from, [e.to]); - } - - // Deterministic: among the ready nodes always emit the earliest-authored first. - const ready = tree.nodes - .filter((n) => (indegree.get(n.id) ?? 0) === 0) - .map((n) => n.id); - const order: PlanNode[] = []; - const depth = new Map(); - const byId = new Map(tree.nodes.map((n) => [n.id, n] as const)); - while (ready.length > 0) { - ready.sort((a, b) => (index.get(a) ?? 0) - (index.get(b) ?? 0)); - const id = ready.shift()!; - const node = byId.get(id); - if (node) order.push(node); - let d = 0; - for (const e of tree.edges) { - if (e.to === id) d = Math.max(d, (depth.get(e.from) ?? 0) + 1); - } - depth.set(id, d); - for (const t of dependents.get(id) ?? []) { - const left = (indegree.get(t) ?? 0) - 1; - indegree.set(t, left); - if (left === 0) ready.push(t); - } - } - // Unreachable only if the DAG invariant broke; keep every node visible regardless. - for (const n of tree.nodes) { - if (!depth.has(n.id)) { - order.push(n); - depth.set(n.id, 0); - } - } - return { order, depth }; -} - -/** -* The "next" node (vision §2 lists it as a distinct state): the first -* upcoming node in topological order whose prerequisites are ALL done — the -* head of the unlocked frontier. Null when nothing is ready (or all done). -*/ -export function nextNodeId( - tree: PlanTree, - order: readonly PlanNode[], -): string | null { - const done = new Set(tree.doneNodeIds); - for (const n of order) { - if (planNodeStatus(tree, n.id) !== "upcoming") continue; - const blocked = tree.edges.some((e) => e.to === n.id && !done.has(e.from)); - if (!blocked) return n.id; - } - return null; -} - -// ─── panel ─────────────────────────────────────────────────────────────────── - -/** - * The panel mode lives in the ROOT layout, not in TreePanel. TreePanel is - * per-page (chat sessions remount it on every navigation), so a local copy - * would paint its default first and then "correct" to the stored mode — - * with the animated frame, opening a session visibly opened-then-closed - * the panel. In the root layout the mode survives navigations untouched, - * exactly like the left sidebar's open state (SidebarProvider). - */ -const TreePanelModeContext = createContext<{ mode: PanelMode; go: (next: PanelMode) => void }>({ - mode: "list", - go: () => {}, -}); - -/** The right sidebar's shared mode — every section of the sidebar (plan, - * sub-agents) collapses and expands in lockstep through this hook. */ -export function useTreePanelMode() { - return useContext(TreePanelModeContext); -} - -/** Root-layout persistence of the panel mode (rail | list | graph). */ -export function TreePanelModeProvider({ children }: { children: React.ReactNode }) { - // SSR default: "list" — server and client first-render agree (no - // hydration mismatch). The stored preference applies on mount, and - // the provider itself never remounts, so after that first correction - // the mode is simply THERE on every session you open. - const [mode, setMode] = useState("list"); - - useEffect(() => { - try { - const stored = window.localStorage.getItem(MODE_KEY); - if (stored === "rail" || stored === "list" || stored === "graph") { - setMode(stored); - } else { - // legacy key (pre-graph): "1" = the collapsed rail - const legacy = window.localStorage.getItem(LEGACY_KEY); - if (legacy === "1") setMode("rail"); - } - } catch { - // private mode / storage disabled — the preference is simply not persisted - } - }, []); - - const go = useCallback((next: PanelMode) => { - setMode(next); - try { - window.localStorage.setItem(MODE_KEY, next); - } catch { - // see above - } - }, []); - - return {children}; -} - -export function TreePanel({ - messages, -}: { - messages: readonly TreePanelMessage[]; -}) { - const tree = useMemo(() => latestTree(messages), [messages]); - const { mode, go } = useTreePanelMode(); - - // Empty state: no tree yet → the panel does not render at all. It appears - // the moment plan_init's output lands and updates live from there. - if (!tree || tree.nodes.length === 0) return null; - - // ONE animated container, exactly like the left sidebar: the aside - // animates its width (sidebar-motion transition cascades from the - // provider and is mount-gated there) while the inner layer keeps a - // FIXED width and is clipped by overflow-hidden — content never - // reflows mid-transition, so no scrollbar flash or overshoot. The - // mode's content just swaps instantly inside the steady frame. - return ( - - ); -} - -/** The collapsed panel: a thin strip with the map icon + quiet progress. */ -export function TreePanelRail({ - doneCount, - totalCount, - onExpand, -}: { - doneCount: number; - totalCount: number; - onExpand: () => void; -}) { - return ( -
- - - {doneCount}/{totalCount} - -
- ); -} - -/** The expanded panel (list mode): header + the node list in topological order. */ -export function TreePanelBody({ - tree, - onShowGraph, - onCollapse, -}: { - tree: PlanTree; - /** Expand into the mermaid-style graph view. */ - onShowGraph: () => void; - onCollapse: () => void; -}) { - const { order, depth } = useMemo(() => topoLayout(tree), [tree]); - const nextId = useMemo(() => nextNodeId(tree, order), [tree, order]); - const doneCount = tree.doneNodeIds.length; - const totalCount = tree.nodes.length; - - return ( -
-
- - - Plan - - - {doneCount}/{totalCount} - - - -
- - {/* Keyed by tree.version: every mutation (contracts.ts) bumps it, so a - set_current/mark_done visibly re-animates the list mid-session. */} - -
- ); -} - -/** The per-state glyph: a settled check, the position dot, an unlocked ring, a hollow ring. */ -function StatusMark({ - status, - next = false, -}: { - status: PlanNodeStatus; - next?: boolean; -}) { - switch (status) { - case "done": - return ( - - ); - case "current": - return ( - - ); - case "upcoming": - return ( - - ); - } -} - -// ─── the graph (expanded view) ──────────────────────────────────────────────── -// -// A hand-built mermaid-style diagram — NOT mermaid's renderer: the styling -// stays in this app's design language (phase colors, quiet strokes) and the -// status coloring maps 1:1 onto the list's marks. Boxes top-down (vertical -// space is the cheap axis here), layered by topoLayout depth, authoring -// order within a layer, edges as vertical bezier curves. - -/** Graph geometry (px): node boxes and the gaps that carry the arrows. */ -export const NODE_W = 176; -export const NODE_H = 48; -export const GAP_X = 28; -export const GAP_Y = 64; - -/** Top-left position per node + the diagram's extent. */ -export interface GraphLayout { - x: Map; - y: Map; - width: number; - height: number; -} - -/** - * Layered positions: depth = row (top-down), authoring order within the - * row, each row centered on the widest one. No crossing-reduction pass — - * the teacher authors nodes in dependency order as a rule, and v1 keeps - * the layout deterministic and readable over prettier. - */ -export function graphLayout(order: readonly PlanNode[], depth: ReadonlyMap): GraphLayout { - const layers: PlanNode[][] = []; - for (const n of order) { - const d = depth.get(n.id) ?? 0; - if (!layers[d]) layers[d] = []; - layers[d].push(n); - } - const rowWidth = (l: PlanNode[]) => l.length * NODE_W + (l.length - 1) * GAP_X; - let maxWidth = 0; - for (const l of layers) maxWidth = Math.max(maxWidth, rowWidth(l)); - const x = new Map(); - const y = new Map(); - layers.forEach((l, d) => { - const start = (maxWidth - rowWidth(l)) / 2; - l.forEach((n, i) => { - x.set(n.id, start + i * (NODE_W + GAP_X)); - y.set(n.id, d * (NODE_H + GAP_Y)); - }); - }); - const height = layers.length * NODE_H + (layers.length - 1) * GAP_Y; - return { x, y, width: maxWidth, height: Math.max(height, 0) }; -} - -/** - * Wrap a node title to at most `maxLines` lines of at most `maxChars` - * (greedy word wrap); overflow ends in an ellipsis, a single oversized - * word is hard-clamped. Pure — unit-tested. - */ -export function wrapTitle(title: string, maxChars = 22, maxLines = 2): string[] { - const words = title.trim().split(/\s+/).filter(Boolean); - if (words.length === 0) return [""]; - const lines: string[] = []; - for (const w of words) { - const last = lines[lines.length - 1]; - const fits = last !== undefined && last.length + 1 + w.length <= maxChars; - if (fits) { - lines[lines.length - 1] = `${last} ${w}`; - } else if (lines.length < maxLines) { - lines.push(w.length > maxChars ? `${w.slice(0, maxChars - 1)}…` : w); - } else { - // out of lines: mark the last one as truncated (once) - const lastLine = lines[lines.length - 1]; - if (lastLine !== undefined && !lastLine.endsWith("…")) { - lines[lines.length - 1] = lastLine.length >= maxChars - ? `${lastLine.slice(0, maxChars - 1)}…` - : `${lastLine}…`; - } - } - } - return lines; -} - -/** Box + label styling per status — the graph's face of the list's marks: - * done = colored in (the teach-phase green), current = the position, - * next = the unlocked ring, upcoming = quiet. */ -const GRAPH_BOX: Record = { - done: "fill-phase-teach/10 stroke-phase-teach/50", - current: "fill-primary/10 stroke-primary", - upcoming: "fill-card stroke-border", -}; -const GRAPH_TEXT: Record = { - done: "fill-muted-foreground text-xs", - current: "fill-foreground text-xs font-medium", - upcoming: "fill-muted-foreground/80 text-xs", -}; - -const GRAPH_HEADER_BTN = - "flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"; -const GRAPH_ZOOM_BTN = - "pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"; - -/** Zoom bounds: never below fit-to-panel (the graph can't disappear into - * nothing — fully zoomed out IS the fit), never above 2×. */ -const ZOOM_MAX = 2; -const ZOOM_FLOOR = 0.15; -/** The fit view's breathing room. The fit is the default view — slim - * margins fit more graph. */ -const FIT_PAD = 16; -/** The fit may scale a SMALL graph up to fill the panel (wave-2 fix: the - * old ≤1× cap left small diagrams tiny with big empty margins), but - * never past this — a lone node shouldn't become a billboard. */ -const FIT_ZOOM_MAX = 1.5; - -/** The graph's view transform: zoom + translation (viewport px). */ -export interface GraphView { - zoom: number; - x: number; - y: number; -} - -/** The zoom that fits a bw×bh graph into a w×h viewport: scales DOWN - * large graphs (floor-clamped) and UP small ones (FIT_ZOOM_MAX-capped) - * so the panel is used, not padded with empty margins. */ -export function fitZoomFor( - box: { w: number; h: number }, - viewport: { w: number; h: number }, -): number { - if (!viewport.w || !viewport.h || box.w === 0) return 1; - return Math.max( - ZOOM_FLOOR, - Math.min( - (viewport.w - FIT_PAD) / box.w, - (viewport.h - FIT_PAD) / box.h, - FIT_ZOOM_MAX, - ), - ); -} - -/** The fit-centered view: the whole bw×bh graph centered in the w×h - * viewport — fully zoomed out is EXACTLY this, nothing to pan. */ -export function fitGraphView( - box: { w: number; h: number }, - viewport: { w: number; h: number }, - zoom: number, -): GraphView { - return { zoom, x: (viewport.w - box.w * zoom) / 2, y: (viewport.h - box.h * zoom) / 2 }; -} - -/** - * One zoom step toward `focus` (viewport px), clamped to [floor, ZOOM_MAX]. - * At the floor the view SNAPS to the fit-centered position: fully zoomed - * out is THE fit view — centered, pan locked (the component enforces it). - */ -export function zoomedGraphView( - view: GraphView, - factor: number, - floor: number, - focus: { x: number; y: number }, - box: { w: number; h: number }, - viewport: { w: number; h: number }, -): GraphView { - const zoom = Math.min(Math.max(view.zoom * factor, floor), ZOOM_MAX); - if (zoom === view.zoom) return view; - if (zoom <= floor) return fitGraphView(box, viewport, floor); - const k = zoom / view.zoom; - return { zoom, x: focus.x - (focus.x - view.x) * k, y: focus.y - (focus.y - view.y) * k }; -} - -/** - * The interactive diagram: drag to pan, wheel to zoom (to the cursor), the - * fit button resets. The zoom floor is fit-to-panel — at it, the view is - * EXACTLY the centered fit and panning is locked (there is nothing to - * move). Auto-fits on first measure and on layout changes while the view - * sits at the floor; once the learner zooms in or pans, the view is theirs - * (until they return to the floor or press fit). SSR-safe: all measurement - * lives in effects. The zoom controls sit INSIDE the pan surface but - * never start a drag (pointer capture would swallow their clicks). - */ -export function TreeGraph({ tree }: { tree: PlanTree }) { - const { order, depth } = useMemo(() => topoLayout(tree), [tree]); - const nextId = useMemo(() => nextNodeId(tree, order), [tree, order]); - const layout = useMemo(() => graphLayout(order, depth), [order, depth]); - - const viewportRef = useRef(null); - const [size, setSize] = useState({ w: 0, h: 0 }); - - useEffect(() => { - const el = viewportRef.current; - if (!el || typeof ResizeObserver === "undefined") return; - const ro = new ResizeObserver(() => { - setSize({ w: el.clientWidth, h: el.clientHeight }); - }); - ro.observe(el); - return () => ro.disconnect(); - }, []); - - // The zoom floor: fully zoomed out, the whole diagram fills the panel - // (small graphs scale UP to use the space, capped at FIT_ZOOM_MAX). - const box = useMemo(() => ({ w: layout.width, h: layout.height }), [layout]); - const fitZoom = useMemo(() => fitZoomFor(box, size), [box, size]); - - const [view, setView] = useState({ x: 0, y: 0, zoom: 1 }); - const userAdjustedRef = useRef(false); - // Epsilon: zoom is float; "at the floor" needs a tolerance. - const atFloor = view.zoom <= fitZoom + 1e-9; - - // Being AT the floor re-arms auto-fit: fully zoomed out is the default - // view, so later layout changes (tree edits) keep it fitting. - useEffect(() => { - if (atFloor) userAdjustedRef.current = false; - }, [atFloor]); - - // Auto-fit (first measure, layout changes) — suppressed once the learner - // takes manual control, until they return to the floor (or press fit). - useEffect(() => { - if (userAdjustedRef.current || !size.w || !size.h) return; - setView(fitGraphView(box, size, fitZoom)); - }, [fitZoom, size, box]); - - // Wheel zoom to the cursor. Non-passive so the page never scrolls instead. - useEffect(() => { - const el = viewportRef.current; - if (!el) return; - const onWheel = (e: WheelEvent) => { - e.preventDefault(); - const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; // Firefox line mode - const factor = Math.exp(-dy * 0.0015); - const rect = el.getBoundingClientRect(); - setView((v) => - zoomedGraphView( - v, - factor, - fitZoom, - { x: e.clientX - rect.left, y: e.clientY - rect.top }, - box, - size, - ), - ); - userAdjustedRef.current = true; - }; - el.addEventListener("wheel", onWheel, { passive: false }); - return () => el.removeEventListener("wheel", onWheel); - }, [fitZoom, box, size]); - - // Drag pan. Guards: the zoom controls sit inside the pan surface — their - // pointerdown must NOT capture (capture redirects the click away from - // the button — the bug that made them feel dead). And at the fit floor - // there is nothing to pan: don't even begin (the cursor stays honest). - const dragRef = useRef<{ - px: number; - py: number; - x: number; - y: number; - moved: boolean; - } | null>(null); - const startDrag = (e: React.PointerEvent) => { - if (e.button !== 0) return; - if ((e.target as HTMLElement).closest("button")) return; - if (atFloor) return; - dragRef.current = { px: e.clientX, py: e.clientY, x: view.x, y: view.y, moved: false }; - e.currentTarget.classList.add("cursor-grabbing"); - e.currentTarget.setPointerCapture(e.pointerId); - }; - const moveDrag = (e: React.PointerEvent) => { - const d = dragRef.current; - if (!d) return; - d.moved = true; - setView((v) => - v.zoom <= fitZoom + 1e-9 - ? v - : { ...v, x: d.x + (e.clientX - d.px), y: d.y + (e.clientY - d.py) }, - ); - }; - const endDrag = (e: React.PointerEvent) => { - const d = dragRef.current; - if (d) { - dragRef.current = null; - if (d.moved) userAdjustedRef.current = true; - } - e.currentTarget.classList.remove("cursor-grabbing"); - }; - - // Button zoom steps toward the viewport center (same math as wheel). - const stepZoom = (factor: number) => { - userAdjustedRef.current = true; - setView((v) => - zoomedGraphView(v, factor, fitZoom, { x: size.w / 2, y: size.h / 2 }, box, size), - ); - }; - const fitView = () => { - userAdjustedRef.current = false; - if (!size.w || !size.h) return; - setView(fitGraphView(box, size, fitZoom)); - }; - - return ( -
-
- - - - - - - {tree.edges.map((e) => { - const x1 = (layout.x.get(e.from) ?? 0) + NODE_W / 2; - const y1 = (layout.y.get(e.from) ?? 0) + NODE_H; - const x2 = (layout.x.get(e.to) ?? 0) + NODE_W / 2; - const y2 = layout.y.get(e.to) ?? 0; - const mid = (y1 + y2) / 2; - return ( - ${e.to}`} - d={`M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`} - className="fill-none stroke-muted-foreground/40" - strokeWidth={1.5} - markerEnd="url(#lh-plan-arrow)" - /> - ); - })} - {order.map((n) => { - const status: PlanNodeStatus = planNodeStatus(tree, n.id); - const lines = wrapTitle(n.title); - return ( - - {n.summary ? `${n.title} — ${n.summary}` : n.title} - - {status === "current" && ( - - )} - {lines.map((line, i) => ( - - {line} - - ))} - - ); - })} - -
- - {/* Quiet affordances: the hint and the zoom controls. */} -
- Drag to pan · scroll to zoom -
- - - -
-
-
- ); -} - -/** The expanded panel (graph mode): wider, the diagram, and the mode toggles. */ -export function TreeGraphPanel({ - tree, - onShowList, - onCollapse, -}: { - tree: PlanTree; - onShowList: () => void; - onCollapse: () => void; -}) { - const doneCount = tree.doneNodeIds.length; - const totalCount = tree.nodes.length; - return ( -
-
- - Plan - - {doneCount}/{totalCount} - - - -
- -
- ); -} diff --git a/components/usage-format.ts b/components/usage-format.ts new file mode 100644 index 0000000..0584f3a --- /dev/null +++ b/components/usage-format.ts @@ -0,0 +1,28 @@ +/** + * Money/token presentation helpers (wave-4 observability UI) — shared by the + * activity dock and the stats bar. Pure; exported for tests. + */ + +/** USD for cent-scale money: >= $1 keeps two decimals, smaller keeps four. */ +export function formatUsd(c: number): string { + return c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(4)}`; +} + +/** 38000 → "38k", 1234567 → "1.2M" — trimmed, never "38.0k". */ +export function formatTokens(n: number): string { + if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`; + if (n >= 1e3) return `${+(n / 1e3).toFixed(1)}k`; + return String(n); +} + +/** + * "z-ai/glm-5.3-flash" → "Glm 5.3 Flash" — vendor prefix dropped, dashes to + * spaces, capitalized; a trailing all-digit segment (OpenRouter's date + * suffix, "qwen3.8-max-0902") is dropped. Quiet-chip friendly. + */ +export function prettyModel(id: string): string { + const short = id.includes("/") ? id.slice(id.lastIndexOf("/") + 1) : id; + const parts = short.split("-"); + const trimmed = /^\d+$/.test(parts[parts.length - 1] ?? "") ? parts.slice(0, -1) : parts; + return trimmed.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" "); +} \ No newline at end of file diff --git a/src/core/ui-message.ts b/src/core/ui-message.ts index 1de809e..80d7ce0 100644 --- a/src/core/ui-message.ts +++ b/src/core/ui-message.ts @@ -1,5 +1,5 @@ import type { UIMessage } from "ai"; -import type { DemoToolInput, ResearchToolInput, SubagentArtifactData, VisualToolInput } from "./contracts.ts"; +import type { DemoToolInput, PlanBlockData, ResearchToolInput, SubagentArtifactData, VisualToolInput } from "./contracts.ts"; import type { AskToolInput, QuestionAnswerData, QuizToolInput } from "./question-core.ts"; /** @@ -24,6 +24,8 @@ export type TeacherUITools = { export type TeacherUIDataParts = { "subagent-artifact": SubagentArtifactData; + /** Plan blocks (wave 4): frozen plan snapshots rendered in the chat flow. */ + "plan-block": PlanBlockData; }; export type TeacherUIMessage = UIMessage; diff --git a/tests/demos.test.tsx b/tests/demos.test.tsx index 69c3ad8..5a36f1b 100644 --- a/tests/demos.test.tsx +++ b/tests/demos.test.tsx @@ -43,7 +43,6 @@ import { SubagentSteerCard } from "../components/artifacts/steer-card.tsx"; import { DispatchCard } from "../components/artifacts/dispatch-card.tsx"; import { JobCard } from "../components/artifacts/job-card.tsx"; import { JobsContext } from "../components/jobs-context.ts"; -import { SubAgentsPanel } from "../components/sub-agents-panel.tsx"; import { demoInputSchema, jobTools, visualInputSchema } from "../src/agent/tools/jobs.ts"; import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts"; import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts"; @@ -393,25 +392,6 @@ describe("steer card rendering (the delivered artifact)", () => { }); }); -describe("sub-agents panel (sidebar observability)", () => { - const runningJob = (kind: "research" | "visual"): JobRecord => - ({ id: `j-${kind}`, kind, chatId: "c", toolCallId: "t", status: "running", activity: 'web_search: "sigma"', createdAt: new Date(Date.now() - 40000).toISOString(), updatedAt: new Date().toISOString() }) as JobRecord; - - it("lists running sub-agents with kind, activity, and elapsed", () => { - const html = renderToStaticMarkup(); - assert.match(html, /Sub-agents/); - assert.match(html, /Researcher/); - assert.match(html, /Figure maker/); - assert.match(html, /web_search: ("|")sigma/); - assert.match(html, /40s|\d+m/); - }); - - it("renders nothing when the fleet is idle (done jobs vanish)", () => { - const done = { ...runningJob("research"), status: "done" }; - assert.equal(renderToStaticMarkup(), ""); - assert.equal(renderToStaticMarkup(), ""); - }); -}); describe("sanitizeSvg", () => { it("strips script blocks, event handlers, and javascript: urls, keeps geometry", () => { diff --git a/tests/render-activity-dock.test.tsx b/tests/render-activity-dock.test.tsx new file mode 100644 index 0000000..09d3c37 --- /dev/null +++ b/tests/render-activity-dock.test.tsx @@ -0,0 +1,173 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { ActivityDock, formatElapsed, toBlobs, type ActivityFeed } from "../components/activity-dock.tsx"; +import { formatTokens, formatUsd } from "../components/usage-format.ts"; + +// The dock's pure layer: folds, formatting, and blob order — then the SSR +// render (blobs, "—" doctrine, the abort affordance, the narrow badge). + +const NOW = Date.parse("2026-09-09T12:00:00Z"); + +/** A full CallUsage (its fields are undefined-valued, not optional). */ +function step(inputTokens: number, outputTokens: number) { + return { inputTokens, outputTokens, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined }; +} + +function job(over: Partial = {}): ActivityFeed["jobs"][number] { + return { + id: "job-1", + kind: "research", + chatId: "c1", + toolCallId: "t1", + status: "running", + activity: 'web_search: "sigma.js v3"', + createdAt: new Date(NOW - 45_000).toISOString(), + updatedAt: new Date(NOW - 1000).toISOString(), + title: "Limits lesson", + ...over, + }; +} + +describe("activity dock pure layer", () => { + it("merges jobs + extractions oldest-first (the stable blob order)", () => { + const feed: ActivityFeed = { + jobs: [job({ id: "new", createdAt: new Date(NOW - 10_000).toISOString() })], + extractions: [{ chatId: "cx", title: "Old lesson", phase: "auditing", startedAt: NOW - 100_000 }], + }; + const blobs = toBlobs(feed); + assert.equal(blobs.length, 2); + const first = blobs[0]!; + assert.equal(first.id, "cx"); // extraction started first + assert.equal(first.process, "extraction"); + assert.equal(first.phase, "auditing"); + assert.equal(blobs[1]!.id, "new"); + }); + + it("folds cost with jobCost; absent usage is null, never zero", () => { + const priced = job({ + usage: { + model: "z-ai/glm-5.3-flash", + inputTokens: 10_000, + outputTokens: 1000, + cacheReadTokens: 5000, + cacheWriteTokens: 0, + reasoningTokens: 0, + steps: 2, + }, + }); + const blob = toBlobs({ jobs: [priced], extractions: [] })[0]!; + // (10000-5000)/1e6*0.15 + 5000/1e6*0.03 + 1000/1e6*0.5 = 0.0014 + assert.ok(blob.cost !== null && blob.cost > 0.0013 && blob.cost < 0.0015); + const absent = toBlobs({ jobs: [job()], extractions: [] })[0]!; + assert.equal(absent.cost, null); + }); + + it("context line = LAST step's tokens vs the model limit, never a cumulative sum", () => { + const base = { + model: "z-ai/glm-5.3-flash", + inputTokens: 400_000, // running total (much bigger — must NOT feed the line) + outputTokens: 8_000, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + steps: 5, + }; + const withLast = job({ + usage: { ...base, lastStep: step(90_000, 2_000) }, + }); + const blob = toBlobs({ jobs: [withLast], extractions: [] })[0]!; + assert.equal(blob.context?.used, 92_000); + assert.equal(blob.context?.limit, 1310720); + // No lastStep yet (capture pending) → "—", even though usage exists. + const stripped = toBlobs({ jobs: [job({ usage: { ...base } })], extractions: [] })[0]!; + assert.equal(stripped.context, null); + // Unknown model → no limit → "—". + const unknownModel = toBlobs({ + jobs: [job({ usage: { ...base, model: "nope/unknown", lastStep: step(5, 5) } })], + extractions: [], + })[0]!; + assert.equal(unknownModel.context, null); + }); + + it("extraction blobs carry no money/context (—, the doctrine)", () => { + const blob = toBlobs({ + jobs: [], + extractions: [{ chatId: "cx", title: "T", phase: "extracting", startedAt: NOW }], + })[0]!; + assert.equal(blob.cost, null); + assert.equal(blob.context, null); + }); + + it("formatElapsed ticks compactly", () => { + assert.equal(formatElapsed(45_000), "45s"); + assert.equal(formatElapsed(184_000), "3m 04s"); + assert.equal(formatElapsed(4_320_000), "1h 12m"); + }); + + it("formatUsd keeps four decimals under $1, two above", () => { + assert.equal(formatUsd(0.0014), "$0.0014"); + assert.equal(formatUsd(1.5), "$1.50"); + }); + + it("formatTokens trims (never 38.0k)", () => { + assert.equal(formatTokens(38_000), "38k"); + assert.equal(formatTokens(1_234_567), "1.2M"); + assert.equal(formatTokens(950), "950"); + }); +}); + +describe("activity dock render", () => { + it("renders NOTHING when idle (no jobs, no extractions)", () => { + assert.equal(renderToStaticMarkup(), ""); + }); + + it("renders one blob per running process with cost, elapsed, and the abort affordance", () => { + const html = renderToStaticMarkup( + , + ); + assert.match(html, /data-testid="activity-dock"/); + assert.match(html, /data-testid="activity-blob-job"/); + assert.match(html, /data-testid="activity-blob-extraction"/); + assert.match(html, /Chain-rule demo/); + assert.match(html, /building sandbox…/); + assert.match(html, /Extraction — merging/); + // Unpriced job → "—", never $0.0000 (doctrine); both blobs show it. + assert.match(html, /activity-blob-cost[^>]*>—]*>— { + const usage = { + model: "z-ai/glm-5.3-flash", + inputTokens: 10_000, + outputTokens: 1000, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + steps: 1, + }; + const html = renderToStaticMarkup( + , + ); + // two identical priced jobs → 2 × $0.002 = $0.0040 total + assert.match(html, /activity-total-cost[^>]*>\$0\.0040 { + it("layers a chain top-down, one node per depth row", () => { + const { order, depth } = topoFor(CHAIN.nodes, CHAIN.edges); + assert.deepEqual(order.map((n) => n.id), ["a", "b", "c"]); + assert.equal(depth.get("c"), 2); + const layout = blockLayout(CHAIN.nodes, CHAIN.edges); + assert.equal(layout.width, NODE_W); // widest row is the single-node rows + assert.equal(layout.height, 3 * NODE_H + 2 * 48); + }); + + it("centers same-depth nodes over the widest row; tolerates dangling edges", () => { + const nodes = [ + { id: "r", title: "Root" }, + { id: "x", title: "Left" }, + { id: "y", title: "Right" }, + ]; + const layout = blockLayout(nodes, [ + { from: "r", to: "x" }, + { from: "r", to: "y" }, + { from: "ghost", to: "r" }, // dangling: ignored defensively + ]); + assert.equal(layout.width, 2 * NODE_W + GAP_X); + // Root centered over its two children: (2*W+GAP - W)/2 + assert.equal(layout.x.get("r"), (2 * NODE_W + GAP_X - NODE_W) / 2); + assert.ok(layout.x.get("x") === 0 && layout.x.get("y") === NODE_W + GAP_X); + }); + + it("wrapTitle wraps greedily and ellipsizes overflow", () => { + assert.deepEqual(wrapTitle("Chain rule intuition"), ["Chain rule intuition"]); // exactly 20 chars — fits one line + assert.deepEqual(wrapTitle("Chain rule intuition and fluency"), ["Chain rule intuition", "and fluency"]); + assert.deepEqual(wrapTitle("supercalifragilisticexpialidocious word"), ["supercalifragilisti…", "word"]); // oversized word hard-clamped, overflow dropped + assert.deepEqual(wrapTitle("supercalifragilistic"), ["supercalifragilistic"]); // exactly maxChars — fits + }); +}); + +describe("plan block render", () => { + it("full block: borderless, column-filling, statuses AS CARRIED (never re-derived)", () => { + const data: PlanBlockData = { + kind: "full", + at: "2026-09-09T12:00:00Z", + current: null, + nodes: [ + { id: "a", title: "Alpha node", status: "done" }, + { id: "b", title: "Beta node", status: "upcoming" }, + ], + edges: [{ from: "a", to: "b" }], + }; + const html = renderToStaticMarkup( {}} />); + assert.match(html, /data-testid="plan-block-full"/); + // Borderless: the wrapper carries no border/rounded chrome. + assert.ok(!/data-testid="plan-block-full"[^>]*border/.test(html), "no wrapping chrome"); + // Fills the column: the svg is w-full with the layout extent as viewBox + // (leftmost/rightmost nodes hug the edges), height natural. + assert.match(html, /]*class="block h-auto w-full"/); + // Two stacked nodes: 2*NODE_H + GAP_Y = 136 natural height, width = one node column. + assert.match(html, /viewBox="0 0 160 136"/); + // Snapshot statuses render as carried. + assert.match(html, /data-testid="plan-node-a" data-status="done"/); + assert.match(html, /data-testid="plan-node-b" data-status="upcoming"/); + assert.match(html, /Open the full plan/); + }); + + it("neighborhood block: own small container, labeled fragment, glowing current", () => { + const data: PlanBlockData = { + kind: "neighborhood", + at: "2026-09-09T12:00:00Z", + current: "mid", + nodes: [ + { id: "up", title: "Upstream", status: "done" }, + { id: "mid", title: "Current node", status: "current" }, + { id: "down", title: "Downstream", status: "upcoming" }, + ], + edges: [ + { from: "up", to: "mid" }, + ], + }; + const html = renderToStaticMarkup( {}} />); + assert.match(html, /data-testid="plan-block-neighborhood"/); + assert.match(html, /class="[^"]*border/, "the fragment has its own container"); + assert.match(html, /Plan fragment/); + // The glow: a pulsing primary halo around the current node. + assert.match(html, /stroke-primary\/70 animate-pulse/); + assert.match(html, /data-testid="plan-node-mid" data-status="current"/); + }); +}); + +describe("plan popup (live derivation)", () => { + function messages(): PopupMessage[] { + return [ + { + parts: [ + { + type: "tool-plan_init", + state: "output-available", + output: { ok: true, tree: CHAIN }, + }, + ], + }, + { + parts: [ + { + type: "tool-quiz", + state: "output-available", + input: { nodeId: "a" }, + output: { correct: true }, + }, + ], + }, + { + parts: [ + { + type: "tool-quiz", + state: "input-available", + input: { nodeId: "b" }, + }, + ], + }, + ]; + } + + it("derives statuses at click time: passed → done, live check → current (glowing)", () => { + const html = renderToStaticMarkup( {}} />); + assert.match(html, /data-testid="plan-popup"/); + assert.match(html, /data-testid="plan-node-a" data-status="done"/); + assert.match(html, /data-testid="plan-node-b" data-status="current"/); + assert.match(html, /data-testid="plan-node-c" data-status="upcoming"/); + // The current node is the glow target. + assert.match(html, /stroke-primary\/70 animate-pulse/); + assert.match(html, /live — statuses derive from your check outcomes/); + }); + + it("renders the empty state when the session has no plan", () => { + const html = renderToStaticMarkup( {}} />); + assert.match(html, /No plan in this session yet\./); + }); +}); \ No newline at end of file diff --git a/tests/render-stats-bar.test.tsx b/tests/render-stats-bar.test.tsx new file mode 100644 index 0000000..b12e388 --- /dev/null +++ b/tests/render-stats-bar.test.tsx @@ -0,0 +1,57 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { StatsBar } from "../components/stats-bar.tsx"; +import { prettyModel } from "../components/usage-format.ts"; +import type { TurnUsage } from "../src/core/contracts.ts"; + +// The stats bar: folds only (no cost math in the component), the "—" +// doctrine for absent data, honest cost (never a fake $0), and the +// model/thinking placeholder chip for the coming control. + +function step(inputTokens: number, outputTokens: number, over: Record = {}) { + return { inputTokens, outputTokens, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined, ...over }; +} + +function turn(over: Partial = {}): TurnUsage { + return { + messageId: "m1", + at: "2026-09-09T12:00:00Z", + model: "z-ai/glm-5.3-flash", + steps: [step(10_000, 1_000)], + ...over, + }; +} + +describe("stats bar", () => { + it("renders — everywhere when the record has no turns yet (doctrine)", () => { + const html = renderToStaticMarkup(); + assert.match(html, /data-testid="stats-bar"/); + assert.match(html, /data-testid="stats-context">——— { + const html = renderToStaticMarkup(); + // last turn's last call: 10k in + 1k out = 11k; limit 1,310,720 → "1.3M" + assert.match(html, /data-testid="stats-context">11k \/ 1\.3M\$0\.0040Glm 5\.3 Flash { + const html = renderToStaticMarkup(); + assert.match(html, /data-testid="stats-cost">—— { + assert.equal(prettyModel("z-ai/glm-5.3-flash"), "Glm 5.3 Flash"); + assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "Qwen3.8 Max"); + }); +}); \ No newline at end of file diff --git a/tests/render-tree-panel.test.tsx b/tests/render-tree-panel.test.tsx deleted file mode 100644 index 3014e35..0000000 --- a/tests/render-tree-panel.test.tsx +++ /dev/null @@ -1,498 +0,0 @@ -import React from "react"; -(globalThis as Record).React = React; - -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { renderToStaticMarkup } from "react-dom/server"; -import { - latestTree, - nextNodeId, - topoLayout, - TreePanel, - TreePanelRail, - type TreePanelMessage, -} from "../components/tree-panel.tsx"; -import type { PlanTree } from "../src/core/contracts.ts"; - -/** - * Render + derivation tests for the persistent plan-tree panel (W7, vision - * §2 "Persistent tree view"). Pattern follows render-cards.test.tsx: static - * HTML via react-dom/server, assertions on the markup — plus unit coverage - * for the pure helpers (latestTree / topoLayout / nextNodeId) that drive the - * panel from the transcript's plan tool outputs. - */ - -/** A small DAG authored in NON-topological node order (b, a, c): a → b → c. */ -const chainTree: PlanTree = { - nodes: [ - { id: "b", title: "Chain middle", summary: "The middle step." }, - { id: "a", title: "Foundation", summary: "Where it all starts." }, - { id: "c", title: "Summit", summary: "The payoff." }, - ], - edges: [ - { from: "a", to: "b" }, - { from: "b", to: "c" }, - ], - currentNodeId: "b", - doneNodeIds: ["a"], - version: 3, -}; - -function planPart(tool: string, tree: PlanTree, ok = true) { - return { - type: `tool-${tool}`, - state: "output-available", - toolCallId: `${tool}-1`, - output: { ok, tree }, - }; -} - -/** - * Test literals carry real UI-part fields (toolCallId, text, …) beyond the - * narrow structural slice TreePanelMessage needs — typed once at the boundary - * so the fixtures can read like real transcript parts. - */ -function asMessages(raw: unknown[]): TreePanelMessage[] { - return raw as TreePanelMessage[]; -} - -describe("latestTree (the transcript is the data source)", () => { - it("returns null when no plan tool output exists (probe phase / pre-plan sessions)", () => { - const messages = asMessages([ - { parts: [{ type: "text", text: "hello" }] }, - { parts: [{ type: "tool-quiz", state: "output-available", toolCallId: "q1", output: {} }] }, - ]); - assert.equal(latestTree(messages), null); - }); - - it("returns the LAST output-available plan tree (set_current overrides init)", () => { - const initTree: PlanTree = { ...chainTree, currentNodeId: null, doneNodeIds: [], version: 1 }; - const messages = asMessages([ - { parts: [planPart("plan_init", initTree)] }, - { parts: [planPart("plan_set_current", chainTree)] }, - ]); - const tree = latestTree(messages); - assert.equal(tree?.currentNodeId, "b"); - assert.equal(tree?.version, 3); - }); - - it("skips plan parts still streaming or pending their output", () => { - const initTree: PlanTree = { ...chainTree, version: 1 }; - const messages = asMessages([ - { - parts: [ - planPart("plan_init", initTree), - { type: "tool-plan_mark_done", state: "input-streaming", toolCallId: "md1" }, - ], - }, - { parts: [{ type: "tool-plan_set_current", state: "input-available", toolCallId: "sc1" }] }, - ]); - // The pending mark_done must not shadow the init output behind it. - assert.equal(latestTree(messages)?.version, 1); - }); - - it("counts a failed op's output (it carries the current tree) but skips malformed output", () => { - const failedOp = asMessages([{ parts: [planPart("plan_add_edge", chainTree, false)] }]); - assert.equal(latestTree(failedOp)?.version, 3, "ok:false still carries the live tree"); - - const malformed = asMessages([ - { parts: [{ type: "tool-plan_init", state: "output-available", toolCallId: "p1", output: { ok: true } }] }, - { parts: [planPart("plan_mark_done", chainTree)] }, - ]); - assert.equal(latestTree(malformed)?.version, 3, "falls through a treeless output to the earlier one"); - }); -}); - -describe("topoLayout (order + depth)", () => { - it("orders nodes topologically even when authored out of order", () => { - const { order } = topoLayout(chainTree); - assert.deepEqual( - order.map((n) => n.id), - ["a", "b", "c"], - ); - }); - - it("computes longest-path depth for indentation", () => { - const { depth } = topoLayout(chainTree); - assert.equal(depth.get("a"), 0); - assert.equal(depth.get("b"), 1); - assert.equal(depth.get("c"), 2); - }); - - it("keeps every node visible even if an edge references an unknown id", () => { - const broken: PlanTree = { - ...chainTree, - edges: [{ from: "ghost", to: "a" }], - }; - assert.equal(topoLayout(broken).order.length, 3); - }); -}); - -describe("nextNodeId (the unlocked frontier)", () => { - it("picks the first upcoming node whose prerequisites are all done", () => { - const { order } = topoLayout(chainTree); - // a done, b current (not done), c upcoming but blocked by b → no next. - assert.equal(nextNodeId(chainTree, order), null); - - const unblocked: PlanTree = { ...chainTree, doneNodeIds: ["a", "b"], currentNodeId: null }; - assert.equal(nextNodeId(unblocked, order), "c"); - }); - - it("an independent root is next as soon as it is upcoming", () => { - const tree: PlanTree = { - nodes: [ - { id: "x", title: "X" }, - { id: "y", title: "Y" }, - ], - edges: [], - currentNodeId: "x", - doneNodeIds: [], - version: 1, - }; - assert.equal(nextNodeId(tree, topoLayout(tree).order), "y"); - }); -}); - -describe("TreePanel rendering", () => { - it("renders nothing before any tree exists (probe phase) — and for a failed first init", () => { - assert.equal(renderToStaticMarkup(), ""); - const probing = asMessages([ - { parts: [{ type: "tool-quiz", state: "output-available", toolCallId: "q1", output: {} }] }, - ]); - assert.equal(renderToStaticMarkup(), ""); - - const failedInit = asMessages([{ parts: [planPart("plan_init", { ...chainTree, nodes: [] }, false)] }]); - assert.equal(renderToStaticMarkup(), "", "empty tree → no panel"); - }); - - it("renders the panel with nodes in topological order, statuses, and progress", () => { - const messages = asMessages([{ parts: [planPart("plan_init", chainTree)] }]); - const html = renderToStaticMarkup(); - assert.ok(html.includes('data-testid="tree-panel"'), "the panel renders once a tree lands"); - - // Topological order in the markup (authored order was b, a, c). - const ia = html.indexOf("Foundation"); - const ib = html.indexOf("Chain middle"); - const ic = html.indexOf("Summit"); - assert.ok(ia > -1 && ib > -1 && ic > -1); - assert.ok(ia < ib && ib < ic, "prerequisites render before dependents"); - - // Per-node status from contracts.planNodeStatus. - assert.ok(html.includes('data-status="done"'), "a: done"); - assert.ok(html.includes('data-status="current"'), "b: current"); - assert.ok(html.includes('data-status="upcoming"'), "c: upcoming"); - - // Current-position highlight + its summary surfaced (hover has it too). - assert.ok(/data-status="current"[^>]*bg-primary\/10/.test(html), "current row is the highlighted one"); - assert.ok(html.includes("The middle step."), "current node's summary is visible"); - - // Progress counter in the header. - assert.match(html, /1\/3/); - }); - - it("dependency depth indents dependents (marginLeft grows with depth)", () => { - const messages = asMessages([{ parts: [planPart("plan_init", chainTree)] }]); - const html = renderToStaticMarkup(); - assert.ok(html.includes("margin-left:0"), "root at depth 0"); - assert.ok(html.includes("margin-left:12px"), "depth 1"); - assert.ok(html.includes("margin-left:24px"), "depth 2"); - }); - - it("node summaries are available on hover (title attribute)", () => { - const messages = asMessages([{ parts: [planPart("plan_init", chainTree)] }]); - const html = renderToStaticMarkup(); - assert.ok(html.includes('title="Where it all starts."'), "done node's summary on hover"); - }); - - it("marks the next unlocked node (data-next + the quiet label)", () => { - const tree: PlanTree = { - nodes: [ - { id: "a", title: "Foundation" }, - { id: "b", title: "Middle" }, - { id: "c", title: "Summit" }, - ], - edges: [ - { from: "a", to: "b" }, - { from: "b", to: "c" }, - ], - currentNodeId: null, - doneNodeIds: ["a"], - version: 2, - }; - const html = renderToStaticMarkup( - , - ); - // b's prerequisite (a) is done → b is next; c stays blocked. - assert.ok(/data-testid="tree-node-b"[^>]*data-next="true"/.test(html), "b is the next node"); - assert.ok(!/data-testid="tree-node-c"[^>]*data-next/.test(html), "c is not next"); - assert.match(html, /next/, "the quiet next label"); - }); - - it("live updates: a later plan output replaces the earlier tree", () => { - const init: PlanTree = { - nodes: [{ id: "a", title: "Only node" }], - edges: [], - currentNodeId: null, - doneNodeIds: [], - version: 1, - }; - const before = renderToStaticMarkup( - , - ); - assert.ok(before.includes("Only node")); - - const progressed: PlanTree = { - ...init, - nodes: [ - { id: "a", title: "Only node" }, - { id: "z", title: "Detour" }, - ], - currentNodeId: "z", - doneNodeIds: ["a"], - version: 2, - }; - const after = renderToStaticMarkup( - , - ); - assert.ok(after.includes("Detour"), "the new node appears"); - assert.ok(after.includes('data-status="current"'), "position moved"); - }); - - it("the collapsed rail: thin strip with the expand affordance and quiet progress", () => { - const html = renderToStaticMarkup( {}} />); - assert.ok(html.includes('data-testid="tree-panel-rail"')); - assert.ok(html.includes('aria-label="Show plan"'), "an accessible expand action"); - assert.match(html, /1\/3/, "progress survives the collapse"); - assert.ok(!html.includes("Foundation"), "no node content in the strip"); - }); -}); - -// ─── the graph (expanded view) ──────────────────────────────────────────────── - -import { - TreeGraph, - graphLayout, - wrapTitle, - NODE_H, - NODE_W, -} from "../components/tree-panel.tsx"; - -describe("wrapTitle (box labels)", () => { - it("wraps to lines within the char budget", () => { - assert.deepEqual(wrapTitle("Chain rule intuition for composites"), [ - "Chain rule intuition", - "for composites", - ]); - }); - - it("a title that fits stays one line", () => { - assert.deepEqual(wrapTitle("Foundation"), ["Foundation"]); - }); - - it("overflowing titles end in an ellipsis", () => { - const lines = wrapTitle("Substitution and the differential of implicit functions"); - assert.equal(lines.length, 2); - assert.ok(lines.at(-1)?.endsWith("…")); - }); - - it("a single oversized word is hard-clamped", () => { - const lines = wrapTitle("antidifferentiation-techniques-overview"); - assert.ok(lines.every((l) => l.length <= 22)); - assert.ok(lines.at(-1)?.endsWith("…")); - }); - - it("empty titles render one empty line (never an empty array)", () => { - assert.deepEqual(wrapTitle(""), [""]); - }); -}); - -describe("graphLayout (layered, top-down)", () => { - it("one node per depth row, authoring order within the row, rows centered", () => { - const { order, depth } = topoLayout(chainTree); - const layout = graphLayout(order, depth); - assert.equal(layout.height, NODE_H * 3 + 64 * 2, "three rows plus arrow gaps"); - // b, a: depth 0; c: depth 1 → wait: a is authored second but is the root. - assert.equal(depth.get("a"), 0); - assert.equal(depth.get("b"), 1); - assert.equal(depth.get("c"), 2); - // each row of one node is centered on the (single-column) width. - assert.equal(layout.x.get("a"), 0); - assert.equal(layout.x.get("b"), 0); - assert.equal(layout.x.get("c"), 0); - assert.equal(layout.y.get("a"), 0); - assert.equal(layout.y.get("b"), NODE_H + 64); - }); - - it("two same-depth nodes sit side by side, centered over the widest row", () => { - const tree: PlanTree = { - nodes: [ - { id: "r", title: "Root" }, - { id: "x", title: "X" }, - { id: "y", title: "Y" }, - ], - edges: [ - { from: "r", to: "x" }, - { from: "r", to: "y" }, - ], - currentNodeId: null, - doneNodeIds: [], - version: 1, - }; - const { order, depth } = topoLayout(tree); - const layout = graphLayout(order, depth); - const xX = layout.x.get("x")!; - const xY = layout.x.get("y")!; - assert.equal(xY - xX, NODE_W + 28, "one box + one gap apart"); - // row 1 is the widest → row 0 centers over it: the root sits exactly - // between the two dependents' centers (88 and 292 → 190 - 88 = 102). - assert.equal(layout.x.get("r"), (2 * NODE_W + 28 - NODE_W) / 2, "the single root centers over the two-wide row"); - assert.equal(layout.width, 2 * NODE_W + 28); - }); -}); - -describe("TreeGraph rendering", () => { - it("renders one box per node with its status, one arrow per edge", () => { - const html = renderToStaticMarkup(); - for (const [id, status] of [ - ["a", "done"], - ["b", "current"], - ["c", "upcoming"], - ] as const) { - assert.ok( - new RegExp(`data-testid="graph-node-${id}"[^>]*data-status="${status}"`).test(html), - `${id} is ${status}`, - ); - } - assert.equal(html.match(/marker-end="url\(#lh-plan-arrow\)"/g)?.length, 2, "two edges, two arrows"); - }); - - it("colors progress: done boxes carry the teach-phase tint", () => { - const html = renderToStaticMarkup(); - assert.match(html, /data-status="done"[\s\S]*?fill-phase-teach\/10/); - assert.match(html, /data-status="current"[\s\S]*?stroke-primary/); - }); - - it("marks the next unlocked node", () => { - const tree: PlanTree = { - nodes: [ - { id: "a", title: "Foundation" }, - { id: "b", title: "Middle" }, - ], - edges: [{ from: "a", to: "b" }], - currentNodeId: null, - doneNodeIds: ["a"], - version: 2, - }; - const html = renderToStaticMarkup(); - assert.match(html, /data-testid="graph-node-b"[^>]*data-next="true"/); - }); - - it("node titles wrap inside the box (two lines max, ellipsis on overflow)", () => { - const tree: PlanTree = { - nodes: [{ id: "long", title: "Substitution and the differential of implicit functions" }], - edges: [], - currentNodeId: null, - doneNodeIds: [], - version: 1, - }; - const html = renderToStaticMarkup(); - const texts = html.match(/]*>([^<]*)<\/text>/g) ?? []; - assert.equal(texts.length, 2, "two wrapped lines"); - assert.ok(texts.at(-1)?.includes("…")); - }); - - it("hover shows title and summary", () => { - const html = renderToStaticMarkup(); - assert.ok(html.includes("Foundation — Where it all starts.")); - }); -}); - -// ─── graph view math (pan lock at fit, zoom-to-cursor) ──────────────────────── - -import { - fitGraphView, - zoomedGraphView, - type GraphView, -} from "../components/tree-panel.tsx"; - -describe("fitGraphView / zoomedGraphView", () => { - const box = { w: 1000, h: 800 }; - const viewport = { w: 300, h: 400 }; - - it("fit centers the whole graph in the viewport at the given zoom", () => { - const v = fitGraphView(box, viewport, 0.3); - assert.equal(v.zoom, 0.3); - assert.equal(v.x, (300 - 1000 * 0.3) / 2); - assert.equal(v.y, (400 - 800 * 0.3) / 2); - }); - - it("zooming in moves the point under the cursor to stay put (zoom-to-cursor)", () => { - const v: GraphView = { zoom: 0.5, x: -100, y: -50 }; - // world point at cursor (50, 60) pre-zoom: (50 - (-100))/0.5 = 300 - const next = zoomedGraphView(v, 2, 0.1, { x: 50, y: 60 }, box, viewport); - assert.equal(next.zoom, 1); - // the same world point maps back to the cursor: (50 - next.x)/1 = 300 - assert.equal(50 - next.x, 300); - assert.equal(60 - next.y, 220); - }); - - it("zooming out past the floor SNAPS to the centered fit (fully zoomed out = the fit)", () => { - const v: GraphView = { zoom: 1.8, x: -900, y: -400 }; - const next = zoomedGraphView(v, 1 / 8, 0.3, { x: 100, y: 100 }, box, viewport); - assert.equal(next.zoom, 0.3, "clamped at the floor"); - assert.deepEqual(next, fitGraphView(box, viewport, 0.3), "and re-centered: nothing to pan"); - }); - - it("a no-op zoom returns the same view (no drift)", () => { - const v: GraphView = { zoom: 2, x: 10, y: 10 }; - // factor 1 → same zoom; at MAX already → clamped to same → same view - assert.equal(zoomedGraphView(v, 1, 0.3, { x: 0, y: 0 }, box, viewport), v); - }); - - it("zoom-in clamps at the ceiling (2×)", () => { - const v: GraphView = { zoom: 1.9, x: 0, y: 0 }; - const next = zoomedGraphView(v, 3, 0.3, { x: 10, y: 10 }, box, viewport); - assert.equal(next.zoom, 2); - }); -}); - -// ─── fit sizing (wave-2: small graphs must USE the panel, not float tiny) ───── - -import { fitZoomFor } from "../components/tree-panel.tsx"; - -describe("fitZoomFor", () => { - it("a graph smaller than the panel scales UP to use it — no tiny diagram with empty margins", () => { - // 5-node chain: 176 wide × 448 tall in a 384×600 panel: height-bound. - // Old bug: the ≤1× cap rendered such graphs at natural size, centered, - // tiny, with big empty areas above and below. - const z = fitZoomFor({ w: 176, h: 448 }, { w: 384, h: 600 }); - assert.equal(z, (600 - 16) / 448, "height-bound: fills the tall panel"); - assert.ok(z > 1, "scales UP past 1×"); - }); - - it("the scale-up is capped: a lone node doesn't become a billboard", () => { - const z = fitZoomFor({ w: 176, h: 48 }, { w: 384, h: 600 }); - assert.equal(z, 1.5, "capped at FIT_ZOOM_MAX"); - }); - - it("a wide graph is width-bound; a tall graph is height-bound", () => { - assert.equal(fitZoomFor({ w: 1000, h: 100 }, { w: 384, h: 600 }), (384 - 16) / 1000); - assert.equal(fitZoomFor({ w: 100, h: 1000 }, { w: 384, h: 600 }), (600 - 16) / 1000); - }); - - it("a graph larger than the panel scales DOWN, floor-clamped", () => { - assert.equal(fitZoomFor({ w: 1500, h: 1500 }, { w: 384, h: 600 }), (384 - 16) / 1500); - // absurdly large → clamped at the floor, never disappears - assert.equal(fitZoomFor({ w: 5000, h: 5000 }, { w: 384, h: 600 }), 0.15); - assert.equal(fitZoomFor({ w: 1e6, h: 1e6 }, { w: 384, h: 600 }), 0.15); - }); - - it("an unmeasured viewport (SSR / first paint) stays at 1×", () => { - assert.equal(fitZoomFor({ w: 1000, h: 800 }, { w: 0, h: 0 }), 1); - assert.equal(fitZoomFor({ w: 0, h: 800 }, { w: 384, h: 600 }), 1); - }); -});