v1.11.5: ContextBar inline next to agent picker; remove ChatContextPopover

ContextBar relocated from a dedicated row above MessageList to inline with
the agent-picker row, filling the space to the right of the picker + plus
button. Always-visible (zero-state when no assistant message has run yet)
via chat.model_context_limit, which GET /api/sessions/:id/chats now
populates from a single getModelContext lookup per session.

ChatContextPopover above the input is removed entirely along with its
useChatContextStats hook (no remaining callers). Color tiers and the
auto-compaction threshold tooltip unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 20:11:49 +00:00
parent 3a5cf0c81a
commit 1ffcf67c47
8 changed files with 138 additions and 159 deletions

View File

@@ -2,20 +2,27 @@ import type { Message } from '@/api/types';
interface Props {
messages: Message[];
// v1.11.5: model's full context window from chat.model_context_limit
// (server-side getModelContext lookup). Lets us render a meaningful
// zero-state (0 / max, muted) before any assistant message has run.
// null/undefined means lookup failed — bar still renders, but with an
// "Context — / —" placeholder rather than misleading 0/0 math.
modelContextLimit?: number | null;
}
// 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).
// v1.11.5.1: inline persistent context-usage indicator. Lives in the same
// horizontal row as the agent picker (was a separate row above; user
// pointed at the empty space next to "Code Reviewer ▾ +" and asked for
// the bar there). Caller wraps in a flex container and ContextBar takes
// the remaining width via `flex-1 min-w-0`. Color tiers fire against
// (max - 20k compaction reserve) so the bar warns amber/orange/red at
// the same boundaries the server's auto-compaction triggers.
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.
// walking. Returns null when no usable pair exists in the chat.
function latestPair(messages: Message[]): { used: number; max: number } | null {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
@@ -42,45 +49,68 @@ function tierFor(usablePct: number): ColorTier {
return { text: 'text-muted-foreground', bar: 'bg-muted-foreground/40' };
}
export function ContextBar({ messages }: Props) {
export function ContextBar({ messages, modelContextLimit }: Props) {
// Resolve which of the three render branches applies:
// 1. real pair — actual usage from the latest assistant message
// 2. zero-state — no usage yet but we know the model's limit
// 3. unknown — neither usage nor limit; render placeholder
// The component NEVER returns null per v1.11.5 spec — the bar is
// persistent so the user knows where it lives.
const pair = latestPair(messages);
if (!pair) return null;
const usable: number | null = pair
? Math.max(0, pair.max - COMPACTION_BUFFER)
: modelContextLimit && modelContextLimit > 0
? Math.max(0, modelContextLimit - COMPACTION_BUFFER)
: 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 used = pair?.used ?? 0;
const max = pair?.max ?? (modelContextLimit && modelContextLimit > 0 ? modelContextLimit : null);
// pct/usablePct only meaningful when max is known. The unknown branch
// sets fill width to 0 and tier to muted regardless.
const pct = max ? used / max : 0;
const usablePct = usable && usable > 0 ? used / usable : 0;
const tier = tierFor(usablePct);
// Bar fill is clamped to [0, 100] — over-budget cases (usable < used) still
// Bar fill 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;
const compactionThresholdPct =
max && usable && usable > 0 ? Math.round((usable / max) * 100) : null;
const tooltipText =
compactionThresholdPct !== null
? `Auto-compaction at ~${compactionThresholdPct}%`
: 'Model context unknown.';
// `flex-1 min-w-0` lets the bar consume the remaining width inside the
// picker row's flex container while preventing the numbers (whitespace-
// nowrap) from pushing the bar out of bounds. Two-element row: track on
// the left, numbers on the right.
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 className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden min-w-0">
<div
className={`h-full ${tier.bar} transition-[width] duration-300`}
style={{ width: `${fillPct}%` }}
/>
</div>
<span
className={`${tier.text} text-[10px] font-mono whitespace-nowrap shrink-0`}
title={tooltipText}
>
{max !== null ? (
<>
{/* Absolute counts hidden on very narrow viewports so the
percentage always has room. Tooltip carries full detail. */}
<span className="max-[480px]:hidden">
{used.toLocaleString()} / {max.toLocaleString()}{' '}
</span>
({Math.round(pct * 100)}%)
</>
) : (
<> / </>
)}
</span>
</div>
);
}