Files
boocode/apps/web/src/components/ContextMeter.tsx
indifferentketchup aec209310e feat(web): workspace components — ComparePane, Memory page, McpDialog, error boundaries, message-parts
- Add ComparePane.tsx: side-by-side AI response comparison
- Add Memory.tsx: memory management page with CRUD UI
- Add McpPermissionDialog.tsx: MCP tool permission approval dialog
- Add McpResponseDisplay.tsx: MCP response visualization
- Add MessageBoundary.tsx + MessageListErrorBoundary.tsx: error resilience
- Add EmptyState.tsx: contextual empty state component
- Add KeyboardShortcutsDialog.tsx: keyboard shortcut reference
- Add message-parts/: ActionRow, CompactCard, MistakeRecoverySentinel, ReasoningBlock, SendToTerminalMenu, StatsLine, SummaryCard
- Add useDraftPersistence.ts: draft message persistence hook
- Add useTerminals.ts: terminal session management hook
- Add keyboard-shortcuts.ts + tool-utils.ts: shared utilities
- Extend components: ChatInput, MessageBubble, MessageList, Workspace, panes
- Extend hooks: useTerminalSocket, useSessionStream test suite
- Update pages: Home, Project — workspace layout and session flow
2026-06-08 03:49:22 +00:00

147 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-200 motion-reduce:transition-none', 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>
);
}