Files
boocode/apps/web/src/components/ChatContextPopover.tsx
indifferentketchup 782c2b183d feat: persistent context-window tracker in ChatPane
Adds a floating popover above the chat input showing current
context-window usage. Modeled on Paseo's tracker.

- New hook useChatContextStats(chatId, messages) finds the latest
  assistant message in the chat with both ctx_used and ctx_max set,
  computes percent, and returns null when data unavailable.
- New component ChatContextPopover renders a small card with the
  "Context window" label, big percent, and "used / max tokens"
  subline. Hidden when stats is null.
- Color thresholds: <60% muted, 60-85 amber, >85 destructive.
- Not a portal — absolutely positioned inside a new relative
  wrapper around ChatInput in ChatPane.tsx, so it's pane-local
  (multi-pane safe).
- Live updates via the existing messages-array dependency.
- No API / schema / WS changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 04:36:08 +00:00

56 lines
1.9 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 type { ChatContextStats } from '@/hooks/useChatContextStats';
interface Props {
stats: ChatContextStats | null;
}
/**
* Formats a token count into a compact k/m-suffix string.
* - < 1_000 → raw integer (e.g. "42")
* - 1_000999_999 → "Nk" or "N.Nk" (e.g. "30k", "12.5k", "100k")
* - >= 1_000_000 → "Nm" or "N.Nm" (e.g. "1m", "1.5m", "100m")
*
* Drops a trailing ".0" so we get "30k" instead of "30.0k".
*/
function formatTokens(n: number): string {
if (n < 1000) return String(n);
if (n < 1_000_000) {
const k = n / 1000;
return k >= 100 ? `${Math.round(k)}k` : `${k.toFixed(1).replace(/\.0$/, '')}k`;
}
const m = n / 1_000_000;
return m >= 100 ? `${Math.round(m)}m` : `${m.toFixed(1).replace(/\.0$/, '')}m`;
}
/**
* Color thresholds:
* - > 85% → text-destructive
* - >= 60% → text-amber-500
* - else → text-muted-foreground
* (85% itself falls into the amber band.)
*/
function percentColorClass(percent: number): string {
if (percent > 85) return 'text-destructive';
if (percent >= 60) return 'text-amber-500';
return 'text-muted-foreground';
}
export function ChatContextPopover({ stats }: Props) {
if (!stats) return null;
return (
<div className="absolute bottom-full right-4 mb-4 z-20 pointer-events-none">
<div className="rounded-md border border-border bg-card text-card-foreground shadow-sm px-3 py-2 text-xs min-w-[140px]">
<div className="text-muted-foreground/80 text-[10px] uppercase tracking-wide mb-0.5">
Context window
</div>
<div className={`text-base font-medium ${percentColorClass(stats.percent)}`}>
{stats.percent}% used
</div>
<div className="text-muted-foreground text-[10px] font-mono">
{formatTokens(stats.used)} / {formatTokens(stats.max)} tokens
</div>
</div>
</div>
);
}