"use client"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core"; import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format"; import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker"; export function formatContextMeasurement(tokens: number | null, limit: number | null): string { const numerator = tokens === null ? "?" : formatTokens(tokens); return limit === null ? numerator : `${numerator} / ${formatTokens(limit)}`; } /** * 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. * * Pre-chat views (home, /chat/new) render NO left side at all (owner, * wave 7 sweep: the knowledge-context budget slider that used to live * there died with the injection; a "— · —" summary over an empty * popover is pure noise) — the dictation strip's middle simply widens. * * Spending arrives already priced by the server through Pi's catalog and * calculator. Missing cost stays "—", never fake zero. Context is a separate * completed-conversation measurement, never a money-log/summarizer fold or * transcript estimate. Its catalog denominator follows the selected model; * model changes preserve the numerator. Successful compaction clears it to ?. */ export function StatsBar({ turns, contextTokens = null, pricing, dictationSlot, chatId, leadingSlot, }: { turns: TurnUsage[]; contextTokens?: number | null; pricing?: SessionPricing; /** The bound session (per-session models, owner 2026-09-28): present * on the in-session bar — the model control binds THE SESSION'S own * record; absent on the draft surfaces, where it binds the strong * slot (the new-session default). */ chatId?: string; /** The dictation strip (STT wave, 2026-09-17): the row's middle is dead * space between the session data and the model control — the mic * button lives there, and while recording the meter unfurls to fill * whatever width the row has (owner placement). */ dictationSlot?: ReactNode; /** The row's LEFT edge (practice sessions, 2026-10-04): the home * composer's teach | practice switch rides here — before the session * data (which renders nothing pre-chat on that surface). */ leadingSlot?: ReactNode; }) { const [open, setOpen] = useState(false); const [selectedModel, setSelectedModel] = useState(); // Same dropdown discipline as the model control (owner, df4433f treatment, // stats round 1): close on any pointerdown outside the bar or on Escape. const barRef = useRef(null); useEffect(() => { if (!open) return; const onPointerDown = (e: PointerEvent) => { if (barRef.current && !barRef.current.contains(e.target as Node)) setOpen(false); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { // THE ONE-ESC LAW (keyboard round 2026-10-06): claim — the // window chain must not also act under this popover. e.preventDefault(); e.stopPropagation(); setOpen(false); } }; document.addEventListener("pointerdown", onPointerDown); document.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("keydown", onKeyDown); }; }, [open]); // Catalog-only denominator; the picker reports its effective selection. // Missing/pending catalog means usage-only, never a hardcoded maximum. const { catalog } = useModelCatalog(); const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]); const limit = selectedModel ? limits?.get(selectedModel) ?? null : null; const contextLabel = formatContextMeasurement(contextTokens, limit); const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {}; // Honest cost: show dollars only when at least one turn priced; // otherwise "—" (never a fake $0.0000). const pricedCount = turns.length - unpriced - subscription; const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model)); const costLabel = planCostLabel(pricedCount > 0 ? cost : null, providers); return (
{/* The summary button + its popover: the popover anchors to this wrapper, CENTERED over the button itself (owner, stats round 2) — not over the whole bar row, which made it line up oddly. The outside-close ref anchors HERE too (owner, round 3): on the whole bar it wrapped the model control, so clicking model/thinking never counted as outside — the asymmetry the owner observed. */} {/* The leading slot (2026-10-04): the leftmost control surface — the home composer's mode switch. */} {leadingSlot} {/* The left side: the session-data summary — only once a session exists (turns). Pre-chat renders nothing (see the header). */} {(chatId || turns.length > 0) && (
{/* No tooltip — the numbers describe themselves and the popover speaks on click (owner, subtlety round 2026-09-25). */} {open && (
Session stats
Context (last call) {contextLabel}
{subscription ? "Usage" : "Total cost"} {costLabel}
{providers.includes("ChatGPT") && Manage ChatGPT usage} {unpriced > 0 && (
Unpriced turns {unpriced}
)}
{turns.length > 0 && ( <>
Turns
    {turns.map((turn, i) => { const c = pricing?.turnCosts[turn.id] ?? null; return (
  • #{i + 1} · {prettyModel(turn.model, true)} {turn.steps.length} steps {subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))}
  • ); })}
)}
)}
)} {/* The middle slot: dictation (owner, 2026-09-17). flex-1 lets the meter expand into the row's free width; justify-center keeps the lone idle mic button visually centered. */} {dictationSlot && (
{dictationSlot}
)} {/* The model + thinking control — one controller for the conversation agents (prober/teacher); the record's `turns` above still folds the ACTUAL models billed, regardless of what the picker says. */}
); }