wip: context-meter + model-label UI and provider/inference tweaks

Checkpoint of in-flight work so the orchestrator branch can rebase onto a
clean main: ContextBar → ContextMeter, model-label helper, model/agent picker
+ provider-snapshot/registry changes, inference payload + message-columns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 14:55:38 +00:00
parent 5f4c7a9050
commit 163b5b86f7
21 changed files with 471 additions and 233 deletions

View File

@@ -0,0 +1,146 @@
import { useEffect, useRef, useState } from 'react';
import type { Message } from '@/api/types';
import { cn } from '@/lib/utils';
// Circular context-window meter — a small SVG ring (Paseo-style) that lives in
// the composer footer beside the send button. Tap/click toggles a popover with
// the full detail (% used, used/max tokens, optional session cost). Replaces the
// old inline ContextBar (a horizontal bar in the toolbar row above the box).
interface Props {
messages: Message[];
// Zero-state fallback: the model's full context window from
// chat.model_context_limit (server getModelContext lookup). Lets the ring
// render a meaningful 0% before any assistant turn has reported usage.
modelContextLimit?: number | null;
// Optional session cost (USD). Omitted today (local llama-swap is free); the
// popover line only shows when a positive number is passed.
sessionCostUsd?: number | null;
}
const SIZE = 18;
const CENTER = SIZE / 2;
const RADIUS = 7;
const STROKE = 2.25;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
// Take the latest ctx_used and ctx_max INDEPENDENTLY (newest-first) — they need
// not be on the same message (some agents report the window only intermittently
// while reporting usage every turn). Mirrors the old ContextBar logic.
function latestPair(messages: Message[]): { used: number; max: number } | null {
let used: number | null = null;
let max: number | null = null;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
if (used === null && m.ctx_used != null) used = m.ctx_used;
if (max === null && m.ctx_max != null && m.ctx_max > 0) max = m.ctx_max;
if (used !== null && max !== null) break;
}
return used !== null && max !== null ? { used, max } : null;
}
function formatTokens(v: number): string {
if (v >= 1_000_000) return `${Math.round(v / 1_000_000)}m`;
if (v >= 1_000) return `${Math.round(v / 1_000)}k`;
return `${Math.round(v)}`;
}
function formatCost(v: number): string {
return v < 0.01 ? `$${v.toFixed(4)}` : `$${v.toFixed(2)}`;
}
export function ContextMeter({ messages, modelContextLimit, sessionCostUsd }: Props) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent | TouchEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('touchstart', onDown);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('touchstart', onDown);
};
}, [open]);
const pair = latestPair(messages);
const max = pair?.max ?? (modelContextLimit && modelContextLimit > 0 ? modelContextLimit : null);
const used = pair?.used ?? 0;
const ratio = max ? Math.max(0, Math.min(1, used / max)) : 0;
const rounded = max ? Math.round((used / max) * 100) : null;
const offset = CIRCUMFERENCE - ratio * CIRCUMFERENCE;
// Paseo thresholds on raw usage: muted < 70%, amber 7090%, red > 90%.
const progressClass =
rounded === null
? 'stroke-muted-foreground/40'
: rounded > 90
? 'stroke-red-500'
: rounded >= 70
? 'stroke-amber-500'
: 'stroke-muted-foreground';
const labelClass =
rounded === null
? 'text-muted-foreground'
: rounded > 90
? 'text-red-600 dark:text-red-400'
: rounded >= 70
? 'text-amber-600 dark:text-amber-400'
: 'text-muted-foreground';
const cost =
typeof sessionCostUsd === 'number' && sessionCostUsd > 0 ? formatCost(sessionCostUsd) : null;
return (
<div ref={ref} className="relative shrink-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={`Context window ${rounded ?? 0}% used`}
title={rounded === null ? 'Model context unknown' : `Context window ${rounded}% used`}
className="inline-flex items-center gap-1 rounded-full px-1 text-muted-foreground hover:text-foreground max-md:min-h-[36px]"
>
<svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`} className="-rotate-90 shrink-0">
<circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
className="stroke-muted"
strokeWidth={STROKE}
/>
<circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
className={cn('transition-all duration-300', progressClass)}
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={offset}
/>
</svg>
<span className={cn('text-[11px] font-mono tabular-nums', labelClass)}>
{rounded === null ? '—' : `${rounded}%`}
</span>
</button>
{open && (
<div className="absolute bottom-full right-0 z-50 mb-2 w-max rounded-lg border border-border bg-popover px-3 py-2 text-left shadow-md">
<p className="text-sm text-foreground">Context window</p>
<p className="text-sm text-foreground">{rounded === null ? 'Unknown' : `${rounded}% used`}</p>
{max !== null && (
<p className="text-xs text-muted-foreground">
{formatTokens(used)} / {formatTokens(max)} tokens
</p>
)}
{cost && <p className="text-xs text-muted-foreground">Session cost {cost}</p>}
</div>
)}
</div>
);
}