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;