fix: align compaction cuts and native usage accounting

This commit is contained in:
amosblomqvist
2026-10-24 19:05:02 +03:00
parent 50a2db775d
commit 624c91a437
45 changed files with 775 additions and 2329 deletions
+26 -25
View File
@@ -1,10 +1,15 @@
"use client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { contextWindow, sessionCost, turnCost, type ChatSettings, type TurnUsage } from "@/src/core";
import type { SessionPricing, TurnUsage } from "@/src/core";
import { formatTokens, formatUsd, 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 —
@@ -16,23 +21,23 @@ import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model
* there died with the injection; a "— · —" summary over an empty
* popover is pure noise) — the dictation strip's middle simply widens.
*
* 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.
* 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
@@ -49,6 +54,7 @@ export function StatsBar({
leadingSlot?: ReactNode;
}) {
const [open, setOpen] = useState(false);
const [selectedModel, setSelectedModel] = useState<string | undefined>();
// 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<HTMLDivElement>(null);
@@ -73,22 +79,17 @@ export function StatsBar({
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
// The live-limits join (context-meter amendment, owner 2026-09-23):
// contextWindow's denominator resolves ONLY from pi's live catalog —
// built client-side over the picker's cached /api/models fetch (the
// same fetch ModelControl below rides; module-level dedupe). Catalog
// pending or failed → undefined index → limit null → USAGE-ONLY (the
// honest-render precedent, never a fake window); the usage-only render
// IS the null path.
// 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 ctx = contextWindow({ turns }, limits);
const { cost, unpriced, subscription = 0 } = sessionCost({ turns });
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 costLabel = subscription ? (pricedCount > 0 ? `${formatUsd(cost)} API + ChatGPT plan` : "ChatGPT plan") : pricedCount > 0 ? formatUsd(cost) : "—";
const lastModel = turns.at(-1)?.model;
return (
<div
@@ -110,7 +111,7 @@ export function StatsBar({
{leadingSlot}
{/* The left side: the session-data summary — only once a session
exists (turns). Pre-chat renders nothing (see the header). */}
{turns.length > 0 && (
{(chatId || turns.length > 0) && (
<div ref={barRef} className="relative">
{/* No tooltip — the numbers describe themselves and the popover
speaks on click (owner, subtlety round 2026-09-25). */}
@@ -122,7 +123,7 @@ export function StatsBar({
className="flex items-center gap-1.5 tabular-nums transition-colors hover:text-foreground"
>
<span data-testid="stats-context">
{ctx ? (ctx.limit !== null ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : formatTokens(ctx.usedTokens)) : "—"}
{contextLabel}
</span>
<span aria-hidden>·</span>
<span data-testid="stats-cost">{costLabel}</span>
@@ -139,7 +140,7 @@ export function StatsBar({
<div className="flex justify-between gap-2">
<span>Context (last call)</span>
<span className="tabular-nums text-card-foreground">
{ctx ? (ctx.limit !== null ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : formatTokens(ctx.usedTokens)) : "—"}
{contextLabel}
</span>
</div>
<div className="flex justify-between gap-2">
@@ -161,7 +162,7 @@ export function StatsBar({
</div>
<ul className="flex max-h-56 flex-col gap-1 overflow-y-auto">
{turns.map((turn, i) => {
const c = turnCost(turn);
const c = pricing?.turnCosts[turn.id] ?? null;
return (
<li key={`${turn.messageId}-${i}`} className="flex items-center justify-between gap-2">
<span className="truncate text-muted-foreground">
@@ -190,7 +191,7 @@ export function StatsBar({
{/* 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. */}
<ModelControl chatId={chatId} />
<ModelControl chatId={chatId} onModelChange={setSelectedModel} />
</div>
);
}