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:
@@ -5,6 +5,7 @@ import { ActivityIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, W
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
|
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
|
||||||
import { CONTEXT_LIMITS, jobCost } 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
|
* 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`;
|
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 ─────────────────────────────────────────────────────────────────
|
// ─── The dock ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Narrow viewports (< sm) collapse the dock to a count badge → drawer. */
|
/** Narrow viewports (< sm) collapse the dock to a count badge → drawer. */
|
||||||
|
|||||||
+39
-2
@@ -20,11 +20,12 @@ import { artifactRenderers } from "@/components/artifacts";
|
|||||||
import { SubagentSteerCard } from "@/components/artifacts/steer-card";
|
import { SubagentSteerCard } from "@/components/artifacts/steer-card";
|
||||||
import { Composer } from "@/components/composer";
|
import { Composer } from "@/components/composer";
|
||||||
import { JobsContext } from "@/components/jobs-context";
|
import { JobsContext } from "@/components/jobs-context";
|
||||||
|
import { StatsBar } from "@/components/stats-bar";
|
||||||
import { QuestionDock } from "@/components/question-dock";
|
import { QuestionDock } from "@/components/question-dock";
|
||||||
import { SidebarToggle } from "@/components/sidebar";
|
import { SidebarToggle } from "@/components/sidebar";
|
||||||
import { SubAgentsPanel } from "@/components/sub-agents-panel";
|
import { SubAgentsPanel } from "@/components/sub-agents-panel";
|
||||||
import { TreePanel } from "@/components/tree-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 { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider";
|
||||||
import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core";
|
import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core";
|
||||||
|
|
||||||
@@ -160,7 +161,8 @@ export function Chat({
|
|||||||
const syncFromServer = useCallback(async () => {
|
const syncFromServer = useCallback(async () => {
|
||||||
const res = await fetch(`/api/chats/${chatId}`);
|
const res = await fetch(`/api/chats/${chatId}`);
|
||||||
if (!res.ok) return;
|
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;
|
if (!Array.isArray(record.messages)) return;
|
||||||
setMessages(record.messages);
|
setMessages(record.messages);
|
||||||
// setMessages does NOT fire sendAutomaticallyWhen (the SDK only checks
|
// setMessages does NOT fire sendAutomaticallyWhen (the SDK only checks
|
||||||
@@ -175,6 +177,33 @@ export function Chat({
|
|||||||
}
|
}
|
||||||
}, [chatId, setMessages, sendMessage, status]);
|
}, [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) ──
|
// ── Central job observability (contracts.ts "THE ASYNC PROTOCOL" §4) ──
|
||||||
// ONE poll of /api/jobs drives every job surface (transcript dispatch
|
// ONE poll of /api/jobs drives every job surface (transcript dispatch
|
||||||
// cards, the sub-agents panel) and re-syncs the transcript exactly once
|
// cards, the sub-agents panel) and re-syncs the transcript exactly once
|
||||||
@@ -400,6 +429,7 @@ export function Chat({
|
|||||||
pending={pending}
|
pending={pending}
|
||||||
requiresReveal={requiresReveal}
|
requiresReveal={requiresReveal}
|
||||||
phase={phases.at(-1) ?? "probe"}
|
phase={phases.at(-1) ?? "probe"}
|
||||||
|
turns={turns}
|
||||||
composer={
|
composer={
|
||||||
<Composer
|
<Composer
|
||||||
// Stop applies only to a LOCAL stream: a server-side turn has no
|
// Stop applies only to a LOCAL stream: a server-side turn has no
|
||||||
@@ -453,6 +483,7 @@ function DockOverlay({
|
|||||||
pending,
|
pending,
|
||||||
requiresReveal,
|
requiresReveal,
|
||||||
phase,
|
phase,
|
||||||
|
turns,
|
||||||
composer,
|
composer,
|
||||||
onSubmitAnswer,
|
onSubmitAnswer,
|
||||||
}: {
|
}: {
|
||||||
@@ -461,6 +492,8 @@ function DockOverlay({
|
|||||||
requiresReveal: boolean;
|
requiresReveal: boolean;
|
||||||
/** Session phase at the pending question — scales the thinking field. */
|
/** Session phase at the pending question — scales the thinking field. */
|
||||||
phase: Phase3;
|
phase: Phase3;
|
||||||
|
/** The session's usage log — the stats bar beneath the composer folds it. */
|
||||||
|
turns: TurnUsage[];
|
||||||
composer: React.ReactNode;
|
composer: React.ReactNode;
|
||||||
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -623,6 +656,10 @@ function DockOverlay({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(" ");
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
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
|
// The dock's pure layer: folds, formatting, and blob order — then the SSR
|
||||||
// render (blobs, "—" doctrine, the abort affordance, the narrow badge).
|
// render (blobs, "—" doctrine, the abort affordance, the narrow badge).
|
||||||
|
|||||||
@@ -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<string, number | undefined> = {}) {
|
||||||
|
return { inputTokens, outputTokens, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined, ...over };
|
||||||
|
}
|
||||||
|
|
||||||
|
function turn(over: Partial<TurnUsage> = {}): 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(<StatsBar turns={[]} />);
|
||||||
|
assert.match(html, /data-testid="stats-bar"/);
|
||||||
|
assert.match(html, /data-testid="stats-context">—</);
|
||||||
|
assert.match(html, /data-testid="stats-cost">—</);
|
||||||
|
assert.match(html, /data-testid="stats-model">—</);
|
||||||
|
assert.match(html, /Thinking —</);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds context + cost from the turns log (core/prices, no local math)", () => {
|
||||||
|
const html = renderToStaticMarkup(<StatsBar turns={[turn(), turn()]} />);
|
||||||
|
// 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</);
|
||||||
|
// each turn: 10k fresh input ×0.15 + 1k output ×0.5 = $0.002 → two = $0.0040
|
||||||
|
assert.match(html, /data-testid="stats-cost">\$0\.0040</);
|
||||||
|
// model chip: pretty name from the last turn's model id
|
||||||
|
assert.match(html, /data-testid="stats-model">Glm 5\.3 Flash</);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows — for cost when every turn is unpriceable, never a fake $0", () => {
|
||||||
|
const html = renderToStaticMarkup(<StatsBar turns={[turn({ model: "nope/unknown" })]} />);
|
||||||
|
assert.match(html, /data-testid="stats-cost">—</);
|
||||||
|
// unknown model also kills the context limit
|
||||||
|
assert.match(html, /data-testid="stats-context">—</);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prettyModel strips vendor + date suffix, capitalizes", () => {
|
||||||
|
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "Glm 5.3 Flash");
|
||||||
|
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "Qwen3.8 Max");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user