From ef2670dbd2494b30740e569b522c8c6f0e06a55a Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Thu, 29 Oct 2026 15:04:19 -0700 Subject: [PATCH 1/4] fix(web): make composer reasoning bars readable --- .../src/components/chat/EffortGlyph.test.ts | 44 +++++++++++++--- apps/web/src/components/chat/EffortGlyph.tsx | 50 ++++++++++++------- apps/web/src/components/chat/TraitsPicker.tsx | 27 ++++++---- 3 files changed, 86 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/chat/EffortGlyph.test.ts b/apps/web/src/components/chat/EffortGlyph.test.ts index 712e4b796ba61..39f138a66c057 100644 --- a/apps/web/src/components/chat/EffortGlyph.test.ts +++ b/apps/web/src/components/chat/EffortGlyph.test.ts @@ -16,33 +16,63 @@ describe("resolveEffortGlyph", () => { ["ultrathink"], ); - it("fills bars up to the current level and leaves max and workflow levels off the scale", () => { + it("fills bars up to the current level and leaves named modes off the scale", () => { expect(resolveEffortGlyph(claude, "xhigh")).toEqual({ - count: 4, + count: 5, filled: 4, special: null, label: "Xhigh", }); }); - it("names max instead of drawing it", () => { - expect(resolveEffortGlyph(claude, "max")?.special).toBe("Max"); + it("includes max in the scale, including DeepSeek's three enabled levels", () => { + expect(resolveEffortGlyph(claude, "max")).toMatchObject({ + count: 5, + filled: 5, + special: null, + }); + const deepseek = descriptor(["none", "low", "high", "max"]); + expect(resolveEffortGlyph(deepseek, "none")?.filled).toBe(0); + expect(resolveEffortGlyph(deepseek, "high")).toMatchObject({ count: 3, filled: 2 }); + expect(resolveEffortGlyph(deepseek, "max")).toMatchObject({ count: 3, filled: 3 }); }); - it("names Ultracode and Ultrathink instead of drawing a level", () => { + it("names Ultra, Ultracode and Ultrathink instead of drawing a level", () => { + expect(resolveEffortGlyph(descriptor(["low", "high", "ultra"]), "ultra")?.special).toBe( + "Ultra", + ); expect(resolveEffortGlyph(claude, "ultracode")?.special).toBe("Ultracode"); expect(resolveEffortGlyph(claude, "ultrathink")?.special).toBe("Ultrathink"); }); 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(claude, "low")).toMatchObject({ count: 5, filled: 1 }); + expect(resolveEffortGlyph(claude, "medium")).toMatchObject({ count: 5, filled: 2 }); expect(resolveEffortGlyph(descriptor(["off", "low", "high"]), "off")).toEqual({ count: 2, filled: 0, special: null, label: "Off", }); + expect(resolveEffortGlyph(descriptor(["off", "on"]), "off")).toMatchObject({ + count: 3, + filled: 0, + }); + expect(resolveEffortGlyph(descriptor(["off", "on"]), "on")).toMatchObject({ + count: 3, + filled: 3, + }); + }); + + it.each([true, false])("draws boolean thinking with every bar on or off: %s", (enabled) => { + expect( + resolveEffortGlyph({ id: "thinking", label: "Thinking", type: "boolean" }, enabled), + ).toEqual({ + count: 3, + filled: enabled ? 3 : 0, + special: null, + label: `Thinking ${enabled ? "On" : "Off"}`, + }); }); it("falls back to the word when the value is unknown or the scale has one level", () => { diff --git a/apps/web/src/components/chat/EffortGlyph.tsx b/apps/web/src/components/chat/EffortGlyph.tsx index 2e033c6caa8da..7da9e27af14da 100644 --- a/apps/web/src/components/chat/EffortGlyph.tsx +++ b/apps/web/src/components/chat/EffortGlyph.tsx @@ -2,28 +2,39 @@ import type { ProviderOptionDescriptor } from "@t3tools/contracts"; import type { ComposerControlSize } from "./ComposerControl"; -const GLYPH_HEIGHT_SM = 14; -const GLYPH_HEIGHT_XS = 12; +const GLYPH_HEIGHT_SM = 18; +const GLYPH_HEIGHT_XS = 16; // Bars stop this far above the box's bottom, which puts their flat base on the text baseline. 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"]); +const NAMED_LEVELS = new Set(["ultra", "ultracode", "ultrathink"]); const OFF_LEVELS = new Set(["off", "none"]); /** - * 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. + * One bar per reasoning level; binary thinking lights the whole scale or none of it. + * Prompt-injected and named modes keep their labels. */ -export function resolveEffortGlyph(descriptor: SelectDescriptor, current: string | null) { +export function resolveEffortGlyph( + descriptor: ProviderOptionDescriptor, + current: string | boolean | null, +) { + if (descriptor.type === "boolean") { + return typeof current === "boolean" + ? { + count: 3, + filled: current ? 3 : 0, + special: null, + label: `${descriptor.label} ${current ? "On" : "Off"}`, + } + : null; + } + if (typeof current !== "string") return null; const special = new Set([...(descriptor.promptInjectedValues ?? []), ...NAMED_LEVELS]); const levels = descriptor.options.filter((option) => !special.has(option.id)); - if (levels.length < 2 || current === null) return null; + if (levels.length < 2) return null; const startsAtOff = OFF_LEVELS.has(levels[0]!.id); - const count = startsAtOff ? levels.length - 1 : levels.length; + const binary = startsAtOff && levels.length === 2; + const count = binary ? 3 : 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 }; @@ -32,7 +43,7 @@ export function resolveEffortGlyph(descriptor: SelectDescriptor, current: string if (index < 0) return null; return { count, - filled: startsAtOff ? index : index + 1, + filled: binary ? index * count : startsAtOff ? index : index + 1, special: null, label: levels[index]!.label, }; @@ -51,8 +62,11 @@ export function effortBarHeights(count: number, maxBar: number): number[] { */ export function EffortGlyph(props: { count: number; filled: number; size?: ComposerControlSize }) { const height = props.size === "xs" ? GLYPH_HEIGHT_XS : GLYPH_HEIGHT_SM; - const step = 4; - const width = step * props.count - 2; + const step = 5; + const barWidth = 3; + const barsWidth = step * (props.count - 1) + barWidth; + const width = Math.max(24, barsWidth); + const inset = Math.floor((width - barsWidth) / 2); const maxBar = height - BASELINE_INSET; return ( {effortBarHeights(props.count, maxBar).map((barHeight, index) => { - const x = index * step; + const x = inset + index * step; return ( ); })} diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 79a29d293dc49..82665ec105a8b 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -20,6 +20,7 @@ import type { EffortDisplay } from "@t3tools/contracts/settings"; import { buildProviderOptionSelectionsFromDescriptors, getProviderOptionCurrentLabel, + getProviderOptionCurrentValue, getProviderOptionDescriptors, isClaudeUltrathinkPrompt, normalizeModelSlug, @@ -559,7 +560,7 @@ export const TraitsPicker = memo(function TraitsPicker({ const composerFloatingLayerProps = useComposerMenuProps(); const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden || disabled); const triggerRef = useRef(null); - const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = + const { descriptors, primarySelectDescriptor, thinkingDescriptor, ultrathinkPromptControlled } = getTraitsSectionVisibility({ provider, models, @@ -600,21 +601,27 @@ export const TraitsPicker = memo(function TraitsPicker({ const triggerDescription = isComposerOwned ? [`Effort: ${triggerLabel}`, speedLabel, extraMenuHint].filter(Boolean).join(" · ") : triggerLabel; + const reasoningDescriptor = + primarySelectDescriptor && REASONING_SELECT_DESCRIPTOR_IDS.has(primarySelectDescriptor.id) + ? primarySelectDescriptor + : thinkingDescriptor; const effortGlyph = - isComposerOwned && - primarySelectDescriptor && - REASONING_SELECT_DESCRIPTOR_IDS.has(primarySelectDescriptor.id) + isComposerOwned && reasoningDescriptor ? resolveEffortGlyph( - primarySelectDescriptor, + reasoningDescriptor, ultrathinkPromptControlled ? "ultrathink" - : getDescriptorStringValue( - primarySelectDescriptor, + : (getProviderOptionCurrentValue( + reasoningDescriptor, instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null, reportedModelSelection, - ), + ) ?? null), ) : null; + const secondaryLabels = + reasoningDescriptor?.type === "boolean" && effortGlyph + ? otherLabels.filter((label) => label !== effortGlyph.label) + : otherLabels; // Workflow levels such as Ultracode always show their name; the setting names every level. // Traits the bars cannot show, such as an agent mode, appear as words in the composer. const effortWord = effortGlyph @@ -688,7 +695,7 @@ export const TraitsPicker = memo(function TraitsPicker({ {speedLabel === "Fast" || speedLabel === "Ultrafast" ? ( ) : null} - {otherLabels.length > 0 ? ( + {secondaryLabels.length > 0 ? ( ) : null} From 17c4103719920f974cbc9adf8d936c3c1421cf3b Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Thu, 29 Oct 2026 15:12:51 -0700 Subject: [PATCH 2/4] fix(web): clarify binary thinking and short effort scales --- .../src/components/chat/EffortGlyph.test.ts | 38 ++++++++++--------- apps/web/src/components/chat/EffortGlyph.tsx | 29 +++++++------- apps/web/src/components/chat/TraitsPicker.tsx | 30 ++++++++++++--- 3 files changed, 61 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/chat/EffortGlyph.test.ts b/apps/web/src/components/chat/EffortGlyph.test.ts index 39f138a66c057..9ecc1a5677223 100644 --- a/apps/web/src/components/chat/EffortGlyph.test.ts +++ b/apps/web/src/components/chat/EffortGlyph.test.ts @@ -20,7 +20,7 @@ describe("resolveEffortGlyph", () => { expect(resolveEffortGlyph(claude, "xhigh")).toEqual({ count: 5, filled: 4, - special: null, + kind: "bars", label: "Xhigh", }); }); @@ -29,20 +29,25 @@ describe("resolveEffortGlyph", () => { expect(resolveEffortGlyph(claude, "max")).toMatchObject({ count: 5, filled: 5, - special: null, + kind: "bars", }); const deepseek = descriptor(["none", "low", "high", "max"]); - expect(resolveEffortGlyph(deepseek, "none")?.filled).toBe(0); + expect(resolveEffortGlyph(deepseek, "none")).toMatchObject({ kind: "bars", filled: 0 }); expect(resolveEffortGlyph(deepseek, "high")).toMatchObject({ count: 3, filled: 2 }); expect(resolveEffortGlyph(deepseek, "max")).toMatchObject({ count: 3, filled: 3 }); }); it("names Ultra, Ultracode and Ultrathink instead of drawing a level", () => { - expect(resolveEffortGlyph(descriptor(["low", "high", "ultra"]), "ultra")?.special).toBe( - "Ultra", - ); - expect(resolveEffortGlyph(claude, "ultracode")?.special).toBe("Ultracode"); - expect(resolveEffortGlyph(claude, "ultrathink")?.special).toBe("Ultrathink"); + for (const [id, label] of [ + ["ultra", "Ultra"], + ["ultracode", "Ultracode"], + ["ultrathink", "Ultrathink"], + ] as const) { + expect(resolveEffortGlyph(descriptor(["low", "high", id]), id)).toEqual({ + kind: "label", + label, + }); + } }); it("draws the lowest level as one bar, and an off level as none", () => { @@ -51,26 +56,25 @@ describe("resolveEffortGlyph", () => { expect(resolveEffortGlyph(descriptor(["off", "low", "high"]), "off")).toEqual({ count: 2, filled: 0, - special: null, + kind: "bars", label: "Off", }); expect(resolveEffortGlyph(descriptor(["off", "on"]), "off")).toMatchObject({ - count: 3, - filled: 0, + kind: "thinking", + enabled: false, }); expect(resolveEffortGlyph(descriptor(["off", "on"]), "on")).toMatchObject({ - count: 3, - filled: 3, + kind: "thinking", + enabled: true, }); }); - it.each([true, false])("draws boolean thinking with every bar on or off: %s", (enabled) => { + it.each([true, false])("distinguishes binary thinking from an effort scale: %s", (enabled) => { expect( resolveEffortGlyph({ id: "thinking", label: "Thinking", type: "boolean" }, enabled), ).toEqual({ - count: 3, - filled: enabled ? 3 : 0, - special: null, + kind: "thinking", + enabled, label: `Thinking ${enabled ? "On" : "Off"}`, }); }); diff --git a/apps/web/src/components/chat/EffortGlyph.tsx b/apps/web/src/components/chat/EffortGlyph.tsx index 7da9e27af14da..c0327d183747f 100644 --- a/apps/web/src/components/chat/EffortGlyph.tsx +++ b/apps/web/src/components/chat/EffortGlyph.tsx @@ -11,7 +11,7 @@ const NAMED_LEVELS = new Set(["ultra", "ultracode", "ultrathink"]); const OFF_LEVELS = new Set(["off", "none"]); /** - * One bar per reasoning level; binary thinking lights the whole scale or none of it. + * One bar per reasoning level; binary thinking has a distinct icon. * Prompt-injected and named modes keep their labels. */ export function resolveEffortGlyph( @@ -21,9 +21,8 @@ export function resolveEffortGlyph( if (descriptor.type === "boolean") { return typeof current === "boolean" ? { - count: 3, - filled: current ? 3 : 0, - special: null, + kind: "thinking" as const, + enabled: current, label: `${descriptor.label} ${current ? "On" : "Off"}`, } : null; @@ -32,20 +31,22 @@ export function resolveEffortGlyph( const special = new Set([...(descriptor.promptInjectedValues ?? []), ...NAMED_LEVELS]); const levels = descriptor.options.filter((option) => !special.has(option.id)); if (levels.length < 2) return null; - const startsAtOff = OFF_LEVELS.has(levels[0]!.id); - const binary = startsAtOff && levels.length === 2; - const count = binary ? 3 : 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 { kind: "label" as const, label }; } const index = levels.findIndex((option) => option.id === current); if (index < 0) return null; + const label = levels[index]!.label; + const startsAtOff = OFF_LEVELS.has(levels[0]!.id); + if (startsAtOff && levels.length === 2) { + return { kind: "thinking" as const, enabled: index > 0, label }; + } return { - count, - filled: binary ? index * count : startsAtOff ? index : index + 1, - special: null, - label: levels[index]!.label, + kind: "bars" as const, + count: startsAtOff ? levels.length - 1 : levels.length, + filled: startsAtOff ? index : index + 1, + label, }; } @@ -62,8 +63,8 @@ export function effortBarHeights(count: number, maxBar: number): number[] { */ export function EffortGlyph(props: { count: number; filled: number; size?: ComposerControlSize }) { const height = props.size === "xs" ? GLYPH_HEIGHT_XS : GLYPH_HEIGHT_SM; - const step = 5; - const barWidth = 3; + const barWidth = props.count <= 3 ? 5 : 3; + const step = barWidth + 2; const barsWidth = step * (props.count - 1) + barWidth; const width = Math.max(24, barsWidth); const inset = Math.floor((width - barsWidth) / 2); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 82665ec105a8b..6052907ae7b3d 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -25,8 +25,8 @@ import { isClaudeUltrathinkPrompt, normalizeModelSlug, } from "@t3tools/shared/model"; -import { memo, type ReactNode, useRef } from "react"; -import { GaugeIcon, ZapIcon } from "lucide-react"; +import { memo, type ReactNode, type SVGProps, useRef } from "react"; +import { BrainIcon, GaugeIcon, ZapIcon } from "lucide-react"; import { Menu, MenuGroup, @@ -512,6 +512,14 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ); }); +function BrainOffIcon(props: SVGProps) { + return ( + + + + ); +} + /** One bolt for Fast. Ultrafast adds an outlined echo behind it, cut away by the surface color. */ function FastBolt(props: { ultra: boolean }) { if (!props.ultra) { @@ -625,10 +633,14 @@ export const TraitsPicker = memo(function TraitsPicker({ // Workflow levels such as Ultracode always show their name; the setting names every level. // Traits the bars cannot show, such as an agent mode, appear as words in the composer. const effortWord = effortGlyph - ? (effortGlyph.special ?? (effortDisplay === "label" ? effortGlyph.label : null)) + ? effortGlyph.kind === "label" || effortDisplay === "label" + ? effortGlyph.label + : null : isComposerOwned ? triggerLabel : null; + const EffortIcon = + effortGlyph?.kind === "thinking" ? (effortGlyph.enabled ? BrainIcon : BrainOffIcon) : GaugeIcon; return ( {effortGlyph ? ( - {effortWord === null ? ( + {effortWord === null && effortGlyph.kind === "thinking" ? ( + + + + ) : effortWord === null && effortGlyph.kind === "bars" ? ( ) : null} {effortWord !== null ? ( <> {effortWord} {/* Icon-only footers hide labels and reveal this instead. */} -