diff --git a/apps/web/src/components/chat/ComposerModelEffort.tsx b/apps/web/src/components/chat/ComposerModelEffort.tsx index 5ce77c60b1078..07a8e2b402717 100644 --- a/apps/web/src/components/chat/ComposerModelEffort.tsx +++ b/apps/web/src/components/chat/ComposerModelEffort.tsx @@ -1,12 +1,12 @@ import { buildTraitsTriggerDisplay, - getDescriptorStringValue, REASONING_SELECT_DESCRIPTOR_IDS, selectableProviderOptionChoices, } from "@t3tools/client-runtime/model-traits"; import type { ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; +import { getProviderOptionCurrentValue } from "@t3tools/shared/model"; import type { EffortDisplay } from "@t3tools/contracts/settings"; -import { CheckIcon, ChevronUpIcon } from "lucide-react"; +import { BrainIcon, CheckIcon, ChevronUpIcon } from "lucide-react"; import { type KeyboardEvent, type ReactNode, use, useRef } from "react"; import { cn } from "~/lib/utils"; @@ -94,20 +94,33 @@ export function describeComposerEffort( modelSelection, reportedModelSelection: traits.reportedModelSelection, }); - const glyph = + const reasoningDescriptor = primary && REASONING_SELECT_DESCRIPTOR_IDS.has(primary.id) - ? resolveEffortGlyph( - primary, - visibility.ultrathinkPromptControlled - ? "ultrathink" - : getDescriptorStringValue(primary, modelSelection, traits.reportedModelSelection), - ) - : null; + ? primary + : visibility.thinkingDescriptor; + const glyph = reasoningDescriptor + ? resolveEffortGlyph( + reasoningDescriptor, + visibility.ultrathinkPromptControlled + ? "ultrathink" + : (getProviderOptionCurrentValue( + reasoningDescriptor, + modelSelection, + traits.reportedModelSelection, + ) ?? null), + ) + : null; // Without a reasoning select the whole label is the word; it already names speed and the rest. // Workflow levels such as Ultracode always show their name; the setting names every level. const word = glyph - ? (glyph.special ?? (effortDisplay === "label" ? glyph.label : null)) + ? glyph.kind === "label" || effortDisplay === "label" + ? glyph.label + : null : display.label; + const otherLabels = + reasoningDescriptor?.type === "boolean" && glyph + ? display.otherLabels.filter((label) => label !== glyph.label) + : display.otherLabels; return { detail: display.label, suffix: ( @@ -120,20 +133,24 @@ export function describeComposerEffort( > {word !== null ? ( {word} - ) : glyph ? ( + ) : glyph?.kind === "thinking" ? ( + + ) : glyph?.kind === "bars" ? ( ) : null} {glyph && (display.speedLabel === "Fast" || display.speedLabel === "Ultrafast") ? ( ) : null} - {glyph && display.otherLabels.length > 0 ? ( + {glyph && otherLabels.length > 0 ? ( - {display.otherLabels.join(" · ")} + {otherLabels.join(" · ")} ) : null} @@ -246,7 +263,7 @@ function EffortLevels(props: { traits: TraitsSelectionInput }) { traits.changeBoolean(descriptor.id, value)} /> @@ -395,7 +412,7 @@ function SpeedControl(props: { traits: TraitsSelectionInput }) { ? ([false, true] as const).map((value) => ( traits.changeBoolean(fastToggle.id, value)} /> diff --git a/apps/web/src/components/chat/EffortGlyph.test.ts b/apps/web/src/components/chat/EffortGlyph.test.ts index 712e4b796ba61..a4a5cf61a8644 100644 --- a/apps/web/src/components/chat/EffortGlyph.test.ts +++ b/apps/web/src/components/chat/EffortGlyph.test.ts @@ -16,33 +16,72 @@ 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, + kind: "bars", 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, + kind: "bars", + }); + const deepseek = descriptor(["none", "low", "high", "max"]); + 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 Ultracode and Ultrathink instead of drawing a level", () => { - expect(resolveEffortGlyph(claude, "ultracode")?.special).toBe("Ultracode"); - expect(resolveEffortGlyph(claude, "ultrathink")?.special).toBe("Ultrathink"); + it("names Ultra, Ultracode and Ultrathink instead of drawing a level", () => { + 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", () => { - 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, + kind: "bars", label: "Off", }); + for (const ids of [ + ["off", "on"], + ["on", "off"], + ]) { + expect(resolveEffortGlyph(descriptor(ids), "off")).toMatchObject({ + kind: "thinking", + enabled: false, + }); + expect(resolveEffortGlyph(descriptor(ids), "on")).toMatchObject({ + kind: "thinking", + enabled: true, + }); + } + }); + + it.each([true, false])("distinguishes binary thinking from an effort scale: %s", (enabled) => { + expect( + resolveEffortGlyph({ id: "thinking", label: "Thinking", type: "boolean" }, enabled), + ).toEqual({ + kind: "thinking", + enabled, + 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 39a46d2564901..cf5d2ed500c49 100644 --- a/apps/web/src/components/chat/EffortGlyph.tsx +++ b/apps/web/src/components/chat/EffortGlyph.tsx @@ -3,39 +3,51 @@ import { ZapIcon } from "lucide-react"; 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 has a distinct icon. + * 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" + ? { + kind: "thinking" as const, + enabled: current, + 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; - const startsAtOff = OFF_LEVELS.has(levels[0]!.id); - const count = startsAtOff ? levels.length - 1 : levels.length; + if (levels.length < 2) return null; 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; + if (levels.length === 2 && levels.some((level) => OFF_LEVELS.has(level.id))) { + return { kind: "thinking" as const, enabled: !OFF_LEVELS.has(current), label }; + } + const startsAtOff = OFF_LEVELS.has(levels[0]!.id); return { - count, + kind: "bars" as const, + count: startsAtOff ? levels.length - 1 : levels.length, filled: startsAtOff ? index : index + 1, - special: null, - label: levels[index]!.label, + label, }; } @@ -52,8 +64,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 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); 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 4639c0a3ec043..9234d8c40c4d3 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -18,6 +18,7 @@ import { import { buildProviderOptionSelectionsFromDescriptors, getProviderOptionCurrentLabel, + getProviderOptionCurrentValue, getProviderOptionDescriptors, isClaudeUltrathinkPrompt, normalizeModelSlug, @@ -347,6 +348,8 @@ export function useTraitsSelection({ selected.ultrathinkPromptControlled && descriptor.id === selected.primarySelectDescriptor?.id ? "ultrathink" : (getDescriptorStringValue(descriptor, modelSelection, reportedModelSelection) ?? ""), + booleanValue: (descriptor: Extract) => + getProviderOptionCurrentValue(descriptor, modelSelection, reportedModelSelection) === true, currentLabel: (descriptor: ProviderOptionDescriptor) => getProviderOptionCurrentLabel(descriptor, modelSelection, reportedModelSelection), changeSelect: ( @@ -463,7 +466,7 @@ const TraitsMenuContent = memo(function TraitsMenuContentImpl(props: TraitsSelec {descriptor.label} traits.changeBoolean(descriptor.id, value === "on")} > {(["on", "off"] as const).map((value) => ( diff --git a/packages/client-runtime/src/modelTraits.test.ts b/packages/client-runtime/src/modelTraits.test.ts new file mode 100644 index 0000000000000..068277dbe3317 --- /dev/null +++ b/packages/client-runtime/src/modelTraits.test.ts @@ -0,0 +1,41 @@ +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { getProviderOptionDescriptors } from "@t3tools/shared/model"; +import { expect, it } from "vite-plus/test"; + +import { buildTraitsTriggerDisplay } from "./modelTraits.ts"; + +it.each([ + { reported: true, explicit: undefined, expected: true }, + { reported: false, explicit: undefined, expected: false }, + { reported: true, explicit: false, expected: false }, + { reported: false, explicit: true, expected: true }, +])("resolves thinking labels from reported $reported and explicit $explicit", (input) => { + const modelSelection = { + instanceId: ProviderInstanceId.make("claude"), + model: "thinking-model", + options: input.explicit === undefined ? [] : [{ id: "thinking", value: input.explicit }], + }; + const descriptors = getProviderOptionDescriptors({ + caps: { + optionDescriptors: [ + { id: "thinking", label: "Thinking", type: "boolean", currentValue: !input.reported }, + ], + }, + selections: modelSelection.options, + }); + const label = `Thinking ${input.expected ? "On" : "Off"}`; + + expect( + buildTraitsTriggerDisplay({ + provider: ProviderDriverKind.make("claudeAgent"), + descriptors, + primarySelectDescriptorId: null, + ultrathinkPromptControlled: false, + modelSelection, + reportedModelSelection: { + ...modelSelection, + options: [{ id: "thinking", value: input.reported }], + }, + }), + ).toEqual({ label, speedLabel: null, otherLabels: [label] }); +}); diff --git a/packages/client-runtime/src/modelTraits.ts b/packages/client-runtime/src/modelTraits.ts index 23ada4ea11c46..529a284202fd2 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -147,7 +147,7 @@ export function buildTraitsTriggerDisplay(input: { input.ultrathinkPromptControlled && descriptor.id === input.primarySelectDescriptorId ? "Ultrathink" : descriptor.type === "boolean" - ? `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}` + ? `${descriptor.label} ${getProviderOptionCurrentValue(descriptor, input.modelSelection, input.reportedModelSelection) === true ? "On" : "Off"}` : getProviderOptionCurrentLabel( descriptor, input.modelSelection,