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
+1 -12
View File
@@ -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. */
+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>
+113
View File
@@ -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 (
<div
data-testid="stats-bar"
className="relative flex items-center justify-between gap-3 px-3 pt-1 text-[10px] text-muted-foreground"
>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
title="Session stats — per-turn detail"
data-testid="stats-summary"
className="flex items-center gap-1.5 tabular-nums transition-colors hover:text-foreground"
>
<span data-testid="stats-context">
{ctx ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : "—"}
</span>
<span aria-hidden>·</span>
<span data-testid="stats-cost">{costLabel}</span>
</button>
{/* Model + thinking level — a placeholder for the coming control
(contract changes land with it); display-only today. */}
<div className="flex items-center gap-1.5" data-testid="stats-model-chip">
<span data-testid="stats-model">{lastModel ? prettyModel(lastModel) : "—"}</span>
<span aria-hidden>·</span>
<span data-testid="stats-thinking" title="Thinking level control lands soon">
Thinking —
</span>
</div>
{open && (
<div
data-testid="stats-detail"
className="absolute bottom-full left-0 z-40 mb-1.5 w-72 rounded-xl border bg-card p-3 text-xs shadow-xl"
>
<div className="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Session stats
</div>
<div className="flex flex-col gap-1 text-muted-foreground">
<div className="flex justify-between gap-2">
<span>Context (last call)</span>
<span className="tabular-nums text-card-foreground">
{ctx ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : "—"}
</span>
</div>
<div className="flex justify-between gap-2">
<span>Total cost</span>
<span className="tabular-nums text-card-foreground">{costLabel}</span>
</div>
{unpriced > 0 && (
<div className="flex justify-between gap-2">
<span>Unpriced turns</span>
<span className="tabular-nums text-card-foreground">{unpriced}</span>
</div>
)}
</div>
{turns.length > 0 && (
<>
<div className="mb-1 mt-2.5 border-t pt-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Turns
</div>
<ul className="flex max-h-56 flex-col gap-1 overflow-y-auto">
{turns.map((turn, i) => {
const c = turnCost(turn);
return (
<li key={`${turn.messageId}-${i}`} className="flex items-center justify-between gap-2">
<span className="truncate text-muted-foreground">
#{i + 1} · {prettyModel(turn.model)}
</span>
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
<span className="text-muted-foreground">{turn.steps.length} steps</span>
<span>{c === null ? "—" : formatUsd(c)}</span>
</span>
</li>
);
})}
</ul>
</>
)}
</div>
)}
</div>
);
}
+28
View File
@@ -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(" ");
}