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 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
@@ -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>
|
||||
|
||||
@@ -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(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user