From 8cd270a5da6bb48603c0f8ee4d2b77bf484ba474 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Wed, 20 May 2026 19:18:27 +0000 Subject: [PATCH] ContextBar: persistent context-usage indicator above MessageList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/src/components/ContextBar.tsx | 86 ++++++++++++++++++++++ apps/web/src/components/panes/ChatPane.tsx | 5 ++ 2 files changed, 91 insertions(+) create mode 100644 apps/web/src/components/ContextBar.tsx diff --git a/apps/web/src/components/ContextBar.tsx b/apps/web/src/components/ContextBar.tsx new file mode 100644 index 0000000..d45a06d --- /dev/null +++ b/apps/web/src/components/ContextBar.tsx @@ -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 ( +
+
+
+ {/* "Context" on >=sm, "Ctx" on phones to save horizontal space. */} + + Context + Ctx + + + {used.toLocaleString()} / {max.toLocaleString()}{' '} + ({Math.round(pct * 100)}%) + +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/panes/ChatPane.tsx b/apps/web/src/components/panes/ChatPane.tsx index 4eb2d49..618b0d7 100644 --- a/apps/web/src/components/panes/ChatPane.tsx +++ b/apps/web/src/components/panes/ChatPane.tsx @@ -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 (
+ {/* 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. */} + {/* Queued messages */}