From b328b2df58fbce9c7d759bc6e8b5e361819fa753 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Sat, 31 Oct 2026 16:56:07 -0700 Subject: [PATCH] feat(web): color raised reasoning effort in the composer --- .../components/chat/ComposerModelEffort.tsx | 49 ++-- .../src/components/chat/EffortGlyph.test.ts | 33 ++- apps/web/src/components/chat/EffortGlyph.tsx | 54 ++++- .../components/chat/ProviderModelPicker.tsx | 210 +++++++++--------- 4 files changed, 208 insertions(+), 138 deletions(-) diff --git a/apps/web/src/components/chat/ComposerModelEffort.tsx b/apps/web/src/components/chat/ComposerModelEffort.tsx index 3a863936101b..d4be7018ebac 100644 --- a/apps/web/src/components/chat/ComposerModelEffort.tsx +++ b/apps/web/src/components/chat/ComposerModelEffort.tsx @@ -24,7 +24,13 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { ComposerControlSize } from "./ComposerControl"; import { ComposerPermissionsSection, type RuntimeModeOption } from "./ComposerFooterControls"; import { composerFloatingLayerProps, focusCheckedPermission } from "./composerEventScope"; -import { EffortGlyph, FastBolt, resolveEffortGlyph } from "./EffortGlyph"; +import { + EffortGlyph, + effortTone, + EffortSparkles, + FastBolt, + resolveEffortGlyph, +} from "./EffortGlyph"; import { DockedPickerContext } from "./ProviderModelPicker"; import { getTraitsSectionVisibility, @@ -107,18 +113,19 @@ export function describeComposerEffort( columnControls(traits.provider, visibility.selectDescriptors, visibility.booleanDescriptors) .effort?.descriptor ?? null; const ultrathink = visibility.ultrathinkPromptControlled && reasoning === primary; - const glyph = - reasoning?.type === "select" - ? resolveEffortGlyph( - reasoning, - ultrathink - ? "ultrathink" - : getDescriptorStringValue(reasoning, modelSelection, traits.reportedModelSelection), - ) - : null; - // Workflow levels such as Ultracode always show their name; the setting names every level. + const level = + reasoning?.type !== "select" + ? null + : ultrathink + ? "ultrathink" + : getDescriptorStringValue(reasoning, modelSelection, traits.reportedModelSelection); + const glyph = reasoning?.type === "select" ? resolveEffortGlyph(reasoning, level) : null; + // Scales too long for bars still show their word in the level's color. + const tone = glyph?.tone ?? effortTone(level); const word = glyph - ? (glyph.special ?? (effortDisplay === "label" ? glyph.label : null)) + ? effortDisplay === "label" + ? glyph.label + : null : !reasoning ? null : reasoning.type === "boolean" @@ -141,17 +148,23 @@ export function describeComposerEffort( suffix: ( - {word ? ( - - {word} + {word || glyph ? ( + + {word ? ( + + {word} + + ) : glyph ? ( + + ) : null} + {glyph ? : null} - ) : glyph ? ( - ) : null} {fast ? : null} {contextWindow ? ( diff --git a/apps/web/src/components/chat/EffortGlyph.test.ts b/apps/web/src/components/chat/EffortGlyph.test.ts index 0b73e89e4f83..752b2aa1a554 100644 --- a/apps/web/src/components/chat/EffortGlyph.test.ts +++ b/apps/web/src/components/chat/EffortGlyph.test.ts @@ -17,30 +17,45 @@ describe("resolveEffortGlyph", () => { ); it("fills bars up to the current level and leaves max and workflow levels off the scale", () => { - expect(resolveEffortGlyph(claude, "xhigh")).toEqual({ + expect(resolveEffortGlyph(claude, "xhigh")).toMatchObject({ count: 4, filled: 4, - special: null, label: "Xhigh", + sparkles: 0, }); }); - it("names max instead of drawing it", () => { - expect(resolveEffortGlyph(claude, "max")?.special).toBe("Max"); + it("draws max and workflow levels as full bars plus sparkles", () => { + expect(resolveEffortGlyph(claude, "max")).toMatchObject({ filled: 4, sparkles: 1 }); + expect(resolveEffortGlyph(claude, "ultracode")).toMatchObject({ filled: 4, sparkles: 2 }); + const codex = descriptor(["low", "medium", "high", "xhigh", "max", "ultra"]); + expect(resolveEffortGlyph(codex, "ultra")).toMatchObject({ count: 4, filled: 4, sparkles: 2 }); + const short = descriptor(["high", "ultra"]); + expect(resolveEffortGlyph(short, "ultra")).toMatchObject({ count: 1, filled: 1, sparkles: 2 }); + for (const named of [descriptor(["max", "ultra"]), descriptor(["off", "ultra"])]) { + expect(resolveEffortGlyph(named, "ultra")).toMatchObject({ count: 1, sparkles: 2 }); + } + expect(resolveEffortGlyph(claude, "ultrathink")).toMatchObject({ + filled: 4, + sparkles: 2, + label: "Ultrathink", + }); }); - it("names Ultracode and Ultrathink instead of drawing a level", () => { - expect(resolveEffortGlyph(claude, "ultracode")?.special).toBe("Ultracode"); - expect(resolveEffortGlyph(claude, "ultrathink")?.special).toBe("Ultrathink"); + it("colors only the levels that cost more than usual", () => { + expect(resolveEffortGlyph(claude, "low")?.tone).toBeNull(); + expect(resolveEffortGlyph(claude, "medium")?.tone).toBeNull(); + expect(resolveEffortGlyph(claude, "high")?.tone).toContain("amber"); + expect(resolveEffortGlyph(claude, "xhigh")?.tone).toContain("red"); + expect(resolveEffortGlyph(claude, "max")?.tone).toContain("purple"); }); it("draws the lowest level as one bar, and an off level as none", () => { expect(resolveEffortGlyph(claude, "low")).toMatchObject({ count: 4, filled: 1 }); expect(resolveEffortGlyph(claude, "medium")).toMatchObject({ count: 4, filled: 2 }); - expect(resolveEffortGlyph(descriptor(["off", "low", "high"]), "off")).toEqual({ + expect(resolveEffortGlyph(descriptor(["off", "low", "high"]), "off")).toMatchObject({ count: 2, filled: 0, - special: null, label: "Off", }); }); diff --git a/apps/web/src/components/chat/EffortGlyph.tsx b/apps/web/src/components/chat/EffortGlyph.tsx index 7614e41c496a..c239217fa7f8 100644 --- a/apps/web/src/components/chat/EffortGlyph.tsx +++ b/apps/web/src/components/chat/EffortGlyph.tsx @@ -1,5 +1,5 @@ import type { ProviderOptionDescriptor } from "@t3tools/contracts"; -import { ZapIcon } from "lucide-react"; +import { SparkleIcon, ZapIcon } from "lucide-react"; import type { ComposerControlSize } from "./ComposerControl"; @@ -10,34 +10,57 @@ const BASELINE_INSET = 2; type SelectDescriptor = Extract; -// Ultracode changes the workflow, not only intensity, so it keeps its name like Ultrathink. -// Max is named too, so the full scale stops short of it. -const NAMED_LEVELS = new Set(["ultracode", "max"]); +// Max and workflow levels sit above the scale: full bars plus sparkles rather than more bars. +const NAMED_LEVELS = new Set(["ultra", "ultracode", "max"]); const OFF_LEVELS = new Set(["off", "none"]); // More bars than this no longer fit the glyph's height in whole pixels. const MAX_BARS = 7; +// Color marks cost, not difference from a default, so ordinary levels stay quiet. +// Amber, red, then purple: no green, so the ramp holds up for red/green color blindness. +const EFFORT_TONES: Record = { + high: "text-amber-700 dark:text-amber-500", + xhigh: "text-red-600 dark:text-red-500", + max: "text-purple-600 dark:text-purple-400", + ultra: "text-fuchsia-600 dark:text-fuchsia-400", + ultracode: "text-fuchsia-600 dark:text-fuchsia-400", + ultrathink: "text-fuchsia-600 dark:text-fuchsia-400", +}; + +export function effortTone(level: string | null) { + return (level !== null && EFFORT_TONES[level]) || null; +} + /** * Maps a reasoning select to bars: one per ordinary level, filled up to the current one, with an - * off level as none filled. Max, prompt-injected and workflow levels are named instead of drawn. + * off level as none filled. Max draws full bars and one sparkle; prompt-injected and workflow + * levels draw two. */ export function resolveEffortGlyph(descriptor: SelectDescriptor, current: string | null) { const special = new Set([...(descriptor.promptInjectedValues ?? []), ...NAMED_LEVELS]); const levels = descriptor.options.filter((option) => !special.has(option.id)); - if (levels.length < 2 || levels.length > MAX_BARS || current === null) return null; - const startsAtOff = OFF_LEVELS.has(levels[0]!.id); - const count = startsAtOff ? levels.length - 1 : levels.length; + if (descriptor.options.length < 2 || levels.length > MAX_BARS || current === null) return null; + const startsAtOff = levels[0] !== undefined && OFF_LEVELS.has(levels[0].id); + // A scale of only named levels still draws one bar under its sparkles. + const count = Math.max(1, startsAtOff ? levels.length - 1 : levels.length); if (special.has(current)) { const label = descriptor.options.find((option) => option.id === current)?.label ?? current; - return { count, filled: count, special: label, label }; + return { + count, + filled: count, + label, + sparkles: current === "max" ? 1 : 2, + tone: effortTone(current), + }; } const index = levels.findIndex((option) => option.id === current); if (index < 0) return null; return { count, filled: startsAtOff ? index : index + 1, - special: null, label: levels[index]!.label, + sparkles: 0, + tone: effortTone(current), }; } @@ -88,6 +111,17 @@ export function EffortGlyph(props: { count: number; filled: number; size?: Compo ); } +export function EffortSparkles(props: { count: number }) { + return Array.from({ length: props.count }, (_, index) => ( +