Stats bar round 2 (owner): popover centered over the summary button
Round 1 centered the popover over the whole bar ROW — the owner meant the stats bar's own bottom-left content (the context · cost summary button). The popover now anchors to a relative wrapper around the button and centers over IT (left-1/2 -translate-x-1/2 of the wrapper), so it hangs balanced over the trigger, not offset toward the composer row's center. Outside-click + Esc closing unchanged from round 1.
This commit is contained in:
+74
-70
@@ -14,8 +14,8 @@ import { ModelControl } from "@/components/model-picker";
|
||||
* 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/
|
||||
* 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
|
||||
@@ -61,28 +61,84 @@ export function StatsBar({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={barRef}
|
||||
data-testid="stats-bar"
|
||||
// pointer-events-auto: the sticky composer wrapper is pointer-events-none
|
||||
// (so its transparent gradient area never blocks scrolling) — this row
|
||||
// must re-enable events or every control on it is dead (the model-
|
||||
// control wave caught this live: buttons rendered, clicks never landed).
|
||||
ref={barRef}
|
||||
className="pointer-events-auto relative flex items-center justify-between gap-3 px-3 pt-1.5 text-xs 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>
|
||||
{/* 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. */}
|
||||
<div className="relative">
|
||||
<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>
|
||||
{open && (
|
||||
<div
|
||||
data-testid="stats-detail"
|
||||
className="absolute bottom-full left-1/2 z-40 mb-1.5 w-72 -translate-x-1/2 rounded-xl border bg-card p-3 text-xs shadow-xl"
|
||||
>
|
||||
<div className="mb-2 text-xs 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-xs 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>
|
||||
{/* 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. */}
|
||||
@@ -92,58 +148,6 @@ export function StatsBar({
|
||||
onSaved={onSettingsSaved}
|
||||
fallbackModelId={lastModel}
|
||||
/>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
data-testid="stats-detail"
|
||||
className="absolute bottom-full left-1/2 z-40 mb-1.5 w-72 -translate-x-1/2 rounded-xl border bg-card p-3 text-xs shadow-xl"
|
||||
>
|
||||
<div className="mb-2 text-xs 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-xs 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user