The stats bar: context + cost beneath the composer, per-turn detail

One quiet row beneath the composer/question shell (not flush to the
screen bottom; static height, outside the morphing shell):

- LEFT: context window + session cost, clickable → per-turn detail
  popover (context, total, unpriced count, one row per turn with
  model/steps/cost). All folds from core/prices.ts — sessionCost,
  contextWindow, turnCost — no cost math in components.
- RIGHT: model + "Thinking —" — a placeholder slot for the coming
  model/thinking-level CONTROL (later work with its contract changes);
  display-only today, model read from the last turn's usage,
  pretty-printed (Glm 5.3 Flash).
- Data: the record's turns log, seeded on mount, refreshed when the
  local stream settles, kept fresh by the existing record re-syncs.
  No turns yet → "—" everywhere; unpriceable turns never fold into a
  fake $0 (the doctrine).
- formatUsd/formatTokens moved to components/usage-format.ts (shared
  with the activity dock); prettyModel joins them, pure + tested.
This commit is contained in:
amosblomqvist
2026-09-11 03:15:55 +03:00
parent e22f0bddb4
commit df58bb87f2
6 changed files with 240 additions and 15 deletions
+39 -2
View File
@@ -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<TurnUsage[]>([]);
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={
<Composer
// Stop applies only to a LOCAL stream: a server-side turn has no
@@ -453,6 +483,7 @@ function DockOverlay({
pending,
requiresReveal,
phase,
turns,
composer,
onSubmitAnswer,
}: {
@@ -461,6 +492,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 +656,10 @@ function DockOverlay({
)}
</div>
</div>
{/* 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. */}
<StatsBar turns={turns} />
</div>
</div>
</div>