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>
117 lines
5.0 KiB
TypeScript
117 lines
5.0 KiB
TypeScript
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.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. 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]!;
|
|
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, 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);
|
|
const usable: number | null = pair
|
|
? Math.max(0, pair.max - COMPACTION_BUFFER)
|
|
: modelContextLimit && modelContextLimit > 0
|
|
? Math.max(0, modelContextLimit - COMPACTION_BUFFER)
|
|
: null;
|
|
|
|
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 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 && 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="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>
|
|
);
|
|
}
|