ContextBar: persistent context-usage indicator above MessageList

Walks chat messages newest-first for the latest ctx_used/ctx_max pair.
Color tiers fire against (max - 20k compaction reserve) so the bar warns
amber/orange/red at the same boundaries auto-compaction triggers.
"Context" → "Ctx" at <640px, (NN%) drops at <380px.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 19:18:27 +00:00
parent c48de06f42
commit 8cd270a5da
2 changed files with 91 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
import type { Message } from '@/api/types';
interface Props {
messages: Message[];
}
// v1.11.2: persistent context-usage indicator above MessageList. Mirrors the
// server-side compaction.usable() formula — color thresholds are computed
// against (max - 20k buffer), not raw max, so the bar turns amber/orange
// /red at the same boundaries auto-compaction will fire. The popover above
// the input (ChatContextPopover) uses raw-% thresholds and is intentionally
// kept separate (it's a different surface and a different signal).
const COMPACTION_BUFFER = 20_000;
// Walk newest-first; first message with both ctx_used and ctx_max non-null
// AND ctx_max > 0 wins. Older messages may have ctx_used but missing ctx_max
// (early v1 before llama-swap's n_ctx capture worked) — skip them and keep
// walking. If nothing usable in the chat, caller renders null.
function latestPair(messages: Message[]): { used: number; max: number } | null {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
if (m.ctx_used == null || m.ctx_max == null) continue;
if (m.ctx_max <= 0) continue;
return { used: m.ctx_used, max: m.ctx_max };
}
return null;
}
interface ColorTier {
// Tailwind utility for the label / numbers. Uses literal palette names
// rather than design tokens because we want three distinct severities
// (amber → orange → red) and BooCode only defines one warning token
// (`destructive`). Literal classes keep the gradation explicit.
text: string;
bar: string;
}
function tierFor(usablePct: number): ColorTier {
if (usablePct >= 0.95) return { text: 'text-red-600 dark:text-red-400', bar: 'bg-red-500' };
if (usablePct >= 0.80) return { text: 'text-orange-600 dark:text-orange-400', bar: 'bg-orange-500' };
if (usablePct >= 0.60) return { text: 'text-amber-600 dark:text-amber-400', bar: 'bg-amber-500' };
return { text: 'text-muted-foreground', bar: 'bg-muted-foreground/40' };
}
export function ContextBar({ messages }: Props) {
const pair = latestPair(messages);
if (!pair) return null;
const { used, max } = pair;
const usable = Math.max(0, max - COMPACTION_BUFFER);
const pct = used / max;
const usablePct = usable > 0 ? used / usable : 0;
const tier = tierFor(usablePct);
// Bar fill is clamped to [0, 100] — over-budget cases (usable < used) still
// show the bar at 100% red rather than overflowing the track visually.
const fillPct = Math.min(100, Math.max(0, pct * 100));
const compactionThresholdPct = max > 0 ? Math.round((usable / max) * 100) : 0;
return (
<div className="border-b px-4 py-1 shrink-0">
<div className="max-w-[1000px] mx-auto w-full">
<div className="flex items-baseline justify-between text-[10px] font-mono leading-tight">
{/* "Context" on >=sm, "Ctx" on phones to save horizontal space. */}
<span className={tier.text}>
<span className="hidden sm:inline">Context</span>
<span className="sm:hidden">Ctx</span>
</span>
<span
className={tier.text}
title={`Auto-compaction at ~${compactionThresholdPct}%`}
>
{used.toLocaleString()} / {max.toLocaleString()}{' '}
<span className="max-[380px]:hidden">({Math.round(pct * 100)}%)</span>
</span>
</div>
<div className="mt-1 h-1 rounded-full bg-muted overflow-hidden">
<div
className={`h-full ${tier.bar} transition-[width] duration-300`}
style={{ width: `${fillPct}%` }}
/>
</div>
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { useChatContextStats } from '@/hooks/useChatContextStats';
import { MessageList } from '@/components/MessageList';
import { ChatInput } from '@/components/ChatInput';
import { ChatContextPopover } from '@/components/ChatContextPopover';
import { ContextBar } from '@/components/ContextBar';
import {
DropdownMenu,
DropdownMenuContent,
@@ -125,6 +126,10 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
return (
<div className="flex flex-col h-full min-h-0">
{/* v1.11.2: persistent context-usage indicator. Renders null when there
are no assistant messages yet (fresh chat). shrink-0 keeps it out of
the MessageList scroll region — bar stays pinned, list scrolls. */}
<ContextBar messages={chatMessages} />
<MessageList messages={chatMessages} sessionChats={sessionChats} />
{/* Queued messages */}