diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index 4230ecb..0a56cac 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -5,6 +5,7 @@ import { ActivityIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, W 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 @@ -179,18 +180,6 @@ export function formatElapsed(ms: number): string { return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`; } -/** 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); -} - // ─── The dock ───────────────────────────────────────────────────────────────── /** Narrow viewports (< sm) collapse the dock to a count badge → drawer. */ diff --git a/components/chat.tsx b/components/chat.tsx index 9415f7d..bba68f7 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 { 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,33 @@ export function Chat({ } }, [chatId, setMessages, sendMessage, status]); + // ── 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 @@ -400,6 +429,7 @@ export function Chat({ pending={pending} requiresReveal={requiresReveal} phase={phases.at(-1) ?? "probe"} + turns={turns} composer={ void; }) { @@ -623,6 +656,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/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/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/tests/render-activity-dock.test.tsx b/tests/render-activity-dock.test.tsx index 749b22a..09d3c37 100644 --- a/tests/render-activity-dock.test.tsx +++ b/tests/render-activity-dock.test.tsx @@ -1,7 +1,8 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { ActivityDock, formatElapsed, formatTokens, formatUsd, toBlobs, type ActivityFeed } from "../components/activity-dock.tsx"; +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). 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