"use client"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { contextWindow, sessionCost, turnCost, type ChatSettings, type TurnUsage } from "@/src/core"; import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format"; import { ModelControl } from "@/components/model-picker"; /** * 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) pass `leftSlot`: with no session there * IS no session data (the summary would just read "—"), so that left side * is free real estate — the knowledge-context budget slider lives there * (vision-context-injection.md), vertically centered beside the model + * thinking pills by the row's own geometry. * * 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 the model + thinking control (components/ * model-picker.tsx): one controller for the conversation agents, backed by * pi-ai's catalog; the buttons show the EFFECTIVE model (the record's * settings, else config's pin), while the left-side figures still fold the * turn log's actual billed models. */ export function StatsBar({ turns, chatId, settings, onSettingsSaved, leftSlot, dictationSlot, }: { turns: TurnUsage[]; chatId: string; settings?: ChatSettings; onSettingsSaved: (settings: ChatSettings) => void; /** Pre-chat only: replaces the (empty) session-data summary — home and * the /chat/new draft mount the knowledge-context slider here. */ leftSlot?: ReactNode; /** 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; }) { const [open, setOpen] = useState(false); // 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") setOpen(false); }; document.addEventListener("pointerdown", onPointerDown); document.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("keydown", onKeyDown); }; }, [open]); 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 (
{/* 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 left side: the session-data summary, or — pre-chat — the caller's slot (the knowledge-context slider) in its place. */} {leftSlot ?? (
{open && (
Session stats
Context (last call) {ctx ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : "—"}
Total cost {costLabel}
{unpriced > 0 && (
Unpriced turns {unpriced}
)}
{turns.length > 0 && ( <>
Turns
    {turns.map((turn, i) => { const c = turnCost(turn); return (
  • #{i + 1} · {prettyModel(turn.model)} {turn.steps.length} steps {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. */}
); }