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:
146
apps/web/src/components/ContextMeter.tsx
Normal file
146
apps/web/src/components/ContextMeter.tsx
Normal 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 70–90%, 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user