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>
147 lines
5.5 KiB
TypeScript
147 lines
5.5 KiB
TypeScript
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>
|
||
);
|
||
}
|