From 176412e64b3a0e394ff08c58fa8e575081b54b39 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:51:39 -0400 Subject: [PATCH] fix(web): context window categories keep one fixed color each Legend and bar colors were assigned by position in the list Claude sends, so "Messages" changed color from thread to thread and the palette looked random. Colors are now keyed by category name: Messages use the primary blue, each thing the session was given (system prompt, system tools, MCP tools, memory files, skills) has a fixed hue, and the autocompact buffer is gray. Unknown categories share one fallback color. --- .../src/provider/Layers/ClaudeAdapter.ts | 4 +- .../chat/ContextWindowMeter.browser.tsx | 6 +++ .../components/chat/ContextWindowMeter.tsx | 41 +++++++++------- apps/web/src/index.css | 47 +++++++++---------- apps/web/src/lib/contextWindow.ts | 4 +- 5 files changed, 55 insertions(+), 47 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 0c5af7db..3eb283f1 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -803,8 +803,8 @@ function isClaudeDeferredCategory( return category.isDeferred === true || category.name.trim().toLowerCase().endsWith("(deferred)"); } -/** Keeps the SDK's category order: legend colors are assigned by index, so - * reordering would make them flicker between updates. */ +/** Keeps the SDK's category order so snapshot equality stays a cheap + * element-wise compare; the client sorts for display and colors by name. */ function normalizeClaudeContextCategories( value: SDKControlGetContextUsageResponse["categories"] | undefined, ): ThreadTokenUsageSnapshot["contextCategories"] { diff --git a/apps/web/src/components/chat/ContextWindowMeter.browser.tsx b/apps/web/src/components/chat/ContextWindowMeter.browser.tsx index fa3cacbe..899862ae 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.browser.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.browser.tsx @@ -134,6 +134,12 @@ describe("ContextWindowMeter", () => { await expect.element(page.getByText(/1.2m of 1.3m input tokens/)).toBeVisible(); expect(contextSegmentNames()).toEqual(["Messages", "System tools"]); + // Colors follow the category name, not its position in the provider's list. + expect( + document + .querySelector('[data-context-segment="Messages"]') + ?.classList.contains("bg-primary"), + ).toBe(true); const breakdownToggle = page.getByRole("button", { name: "Show context breakdown" }); await expect.element(page.getByText("System tools")).not.toBeInTheDocument(); diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index 618de0f1..8a04cece 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -55,18 +55,27 @@ function AccountUsageBar(props: { ); } -/** Categorical swatches assigned by index (cycled), so a category keeps its - * color as long as the provider keeps its order. */ -const CONTEXT_CATEGORY_COLOR_CLASS_NAMES = [ - "bg-context-cat-1", - "bg-context-cat-2", - "bg-context-cat-3", - "bg-context-cat-4", - "bg-context-cat-5", - "bg-context-cat-6", - "bg-context-cat-7", - "bg-context-cat-8", -] as const; +/** Swatches keyed by the provider's category name so a category looks the same + * in every thread: conversation content is the primary color, the things the + * session was given (prompt, tools, memory, skills) each get a fixed hue, and + * reserved-but-empty space is gray. Unknown names share one fallback. */ +const CONTEXT_CATEGORY_COLOR_CLASS_NAMES: Readonly> = { + messages: "bg-primary", + "system prompt": "bg-context-system-prompt", + "system tools": "bg-context-system-tools", + "mcp tools": "bg-context-mcp-tools", + "memory files": "bg-context-memory-files", + skills: "bg-context-skills", + "autocompact buffer": "bg-muted-foreground/45", +}; +const CONTEXT_CATEGORY_FALLBACK_COLOR_CLASS_NAME = "bg-context-other"; + +function contextCategoryColorClassName(name: string): string { + return ( + CONTEXT_CATEGORY_COLOR_CLASS_NAMES[name.trim().toLowerCase()] ?? + CONTEXT_CATEGORY_FALLBACK_COLOR_CLASS_NAME + ); +} type ContextBreakdownSegment = { readonly key: string; @@ -89,17 +98,15 @@ function buildContextBreakdownSegments( const toPercentage = (tokens: number) => Math.max(0, Math.min(100, (tokens / maxTokens) * 100)); const categories = usage.contextCategories ?? null; if (categories && categories.length > 0) { - // Colors are keyed to the provider's order (stable across updates), then - // the bar and legend display largest-first. + // The bar and legend display largest-first; colors come from the name, so + // the sort never reshuffles them. return categories .map((category, index) => ({ key: `${index}-${category.name}`, name: category.name, tokens: category.tokens, percentage: toPercentage(category.tokens), - colorClassName: - CONTEXT_CATEGORY_COLOR_CLASS_NAMES[index % CONTEXT_CATEGORY_COLOR_CLASS_NAMES.length] ?? - CONTEXT_CATEGORY_COLOR_CLASS_NAMES[0], + colorClassName: contextCategoryColorClassName(category.name), })) .sort((left, right) => right.tokens - left.tokens); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index c2710207..854c0aad 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -76,14 +76,12 @@ --color-secondary: var(--secondary); --color-provider-claude: var(--provider-claude); --color-provider-codex: var(--provider-codex); - --color-context-cat-1: var(--context-cat-1); - --color-context-cat-2: var(--context-cat-2); - --color-context-cat-3: var(--context-cat-3); - --color-context-cat-4: var(--context-cat-4); - --color-context-cat-5: var(--context-cat-5); - --color-context-cat-6: var(--context-cat-6); - --color-context-cat-7: var(--context-cat-7); - --color-context-cat-8: var(--context-cat-8); + --color-context-system-prompt: var(--context-system-prompt); + --color-context-system-tools: var(--context-system-tools); + --color-context-mcp-tools: var(--context-mcp-tools); + --color-context-memory-files: var(--context-memory-files); + --color-context-skills: var(--context-skills); + --color-context-other: var(--context-other); --color-primary-graph: var(--primary-graph); --color-primary-readable: var(--primary-readable); --color-primary-foreground: var(--primary-foreground); @@ -256,16 +254,15 @@ --success-foreground: var(--color-emerald-700); --warning: var(--color-amber-500); --warning-foreground: var(--color-amber-700); - /* Categorical swatches for breakdown bars (context window usage). Assigned by - index, so the hues stay far enough apart to stay distinguishable in order. */ - --context-cat-1: oklch(0.55 0.14 255); - --context-cat-2: oklch(0.55 0.14 55); - --context-cat-3: oklch(0.55 0.12 155); - --context-cat-4: oklch(0.55 0.12 95); - --context-cat-5: oklch(0.55 0.13 5); - --context-cat-6: oklch(0.55 0.12 300); - --context-cat-7: oklch(0.55 0.1 200); - --context-cat-8: oklch(0.55 0.09 30); + /* Context window breakdown swatches, keyed by category name. Messages use + --primary; these cover what the session was given. Shared lightness and + chroma so no category shouts. */ + --context-system-prompt: oklch(0.55 0.12 300); + --context-system-tools: oklch(0.55 0.14 55); + --context-mcp-tools: oklch(0.55 0.1 200); + --context-memory-files: oklch(0.55 0.12 155); + --context-skills: oklch(0.55 0.13 5); + --context-other: oklch(0.55 0.12 95); /* Matches the macOS default text-selection blue so ::selection and the kept transcript-note highlight paint identically. */ --text-selection: oklch(0.85 0.07 258); @@ -323,14 +320,12 @@ --success-foreground: var(--color-emerald-400); --warning: var(--color-amber-500); --warning-foreground: var(--color-amber-400); - --context-cat-1: oklch(0.62 0.14 255); - --context-cat-2: oklch(0.68 0.14 55); - --context-cat-3: oklch(0.65 0.12 155); - --context-cat-4: oklch(0.72 0.12 95); - --context-cat-5: oklch(0.65 0.13 5); - --context-cat-6: oklch(0.62 0.12 300); - --context-cat-7: oklch(0.68 0.1 200); - --context-cat-8: oklch(0.66 0.09 30); + --context-system-prompt: oklch(0.62 0.12 300); + --context-system-tools: oklch(0.68 0.14 55); + --context-mcp-tools: oklch(0.68 0.1 200); + --context-memory-files: oklch(0.65 0.12 155); + --context-skills: oklch(0.65 0.13 5); + --context-other: oklch(0.72 0.12 95); --text-selection: oklch(0.49 0.07 258); } } diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 47b66e74..5bb9a382 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -14,8 +14,8 @@ function asBoolean(value: unknown): boolean | null { type ContextWindowCategories = NonNullable; -/** Keeps the provider's order (legend colors are assigned by index) and drops - * entries the provider sent malformed rather than failing the whole snapshot. */ +/** Keeps the provider's order and drops entries the provider sent malformed + * rather than failing the whole snapshot. */ function asContextCategories(value: unknown): ContextWindowCategories | null { if (!Array.isArray(value)) { return null;