diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index f6d099dfbb059..7cc4fab81ce11 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -24,7 +24,7 @@ import { normalizeModelSlug, } from "@t3tools/shared/model"; import { memo } from "react"; -import { BrainIcon, GaugeIcon } from "lucide-react"; +import { BrainIcon, Icon } from "lucide-react"; import { Menu, MenuGroup, @@ -115,6 +115,62 @@ function DefaultBadge() { ); } +const EFFORT_GAUGE_RANKS = new Map([ + ["none", 0], + ["off", 0], + ["minimal", 1], + ["low", 2], + ["medium", 3], + ["high", 4], + ["xhigh", 5], + ["ultracode", 5], + ["max", 6], + ["ultra", 7], +]); + +export function getEffortGaugeAngle( + descriptor: ProviderOptionDescriptor | null, + value: string | boolean | null, +) { + // Boolean On keeps the six-level Extra High position. + if (typeof value === "boolean") return value ? 24 : -120; + if (descriptor?.type !== "select" || value === null) return 45; + + const levels = [ + ...new Set( + descriptor.options + .filter((option) => !descriptor.promptInjectedValues?.includes(option.id)) + .map((option) => EFFORT_GAUGE_RANKS.get(option.id)) + .filter((rank) => rank !== undefined), + ), + ].toSorted((left, right) => left - right); + const index = levels.indexOf(EFFORT_GAUGE_RANKS.get(value) ?? -1); + return index >= 0 && levels.length > 1 ? -120 + (index / (levels.length - 1)) * 240 : 45; +} + +function EffortGaugeIcon({ + descriptor, + value, + size, +}: { + descriptor: ProviderOptionDescriptor | null; + value: string | boolean | null; + size: ComposerControlSize; +}) { + const angle = getEffortGaugeAngle(descriptor, value); + + return ( + + ); +} + function getSelectedTraits( provider: ProviderDriverKind, models: ReadonlyArray, @@ -531,7 +587,7 @@ export const TraitsPicker = memo(function TraitsPicker({ }) { const composerFloatingLayerProps = useComposerMenuProps(); const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden || disabled); - const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = + const { descriptors, selectDescriptors, primarySelectDescriptor, ultrathinkPromptControlled } = getTraitsSectionVisibility({ provider, models, @@ -555,14 +611,28 @@ export const TraitsPicker = memo(function TraitsPicker({ return null; } + const modelSelection = + instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null; const { label: triggerLabel } = buildTraitsTriggerDisplay({ provider, descriptors, primarySelectDescriptorId: primarySelectDescriptor?.id ?? null, ultrathinkPromptControlled, - modelSelection: instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null, + modelSelection, reportedModelSelection, }); + const reasoningDescriptor = + selectDescriptors.find((descriptor) => REASONING_SELECT_DESCRIPTOR_IDS.has(descriptor.id)) ?? + descriptors.find( + (descriptor) => + descriptor.type === "boolean" && REASONING_SELECT_DESCRIPTOR_IDS.has(descriptor.id), + ) ?? + null; + // Ultrathink only changes the prompt; the gauge measures native effort. + const effort = + reasoningDescriptor?.type === "boolean" + ? (reasoningDescriptor.currentValue ?? null) + : getDescriptorStringValue(reasoningDescriptor, modelSelection, reportedModelSelection); const isCodexStyle = provider === "codex"; return ( @@ -601,7 +671,7 @@ export const TraitsPicker = memo(function TraitsPicker({ )} > - + {triggerLabel} @@ -610,7 +680,7 @@ export const TraitsPicker = memo(function TraitsPicker({ ) : ( <> - + {triggerLabel} diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 54b68286ee315..529d2dda745f1 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -11,7 +11,7 @@ import { getComposerReasoningStep, withImplicitFastModeDefault, } from "./composerProviderState"; -import { readPromptUltrathink } from "./TraitsPicker"; +import { getEffortGaugeAngle, readPromptUltrathink } from "./TraitsPicker"; const PROVIDER: ProviderDriverKind = ProviderDriverKind.make("codex"); const MODEL = "test-model"; @@ -60,6 +60,71 @@ function selections( return entries.map(([id, value]) => ({ id, value })); } +describe("effort gauge", () => { + it("orders effort by meaning when a provider lists levels backwards", () => { + const descriptor = selectDescriptor( + "reasoningEffort", + ["xhigh", "high", "medium", "low"].map((id) => ({ id, label: id })), + ); + + const angles = ["low", "medium", "high", "xhigh"].map((value) => + getEffortGaugeAngle(descriptor, value), + ); + + for (let index = 1; index < angles.length; index++) { + expect(angles[index]).toBeGreaterThan(angles[index - 1]!); + } + }); + + it("spreads a sparse model's supported levels across the full arc", () => { + const descriptor = selectDescriptor( + "variant", + ["max", "high"].map((id) => ({ id, label: id })), + ); + + expect(getEffortGaugeAngle(descriptor, "high")).toBe(-120); + expect(getEffortGaugeAngle(descriptor, "max")).toBe(120); + }); + + it("gives Ultracode the same position as Xhigh without adding another level", () => { + const descriptor = selectDescriptor( + "effort", + ["low", "medium", "high", "xhigh", "ultracode", "max"].map((id) => ({ id, label: id })), + ); + const nativeOnly = { + ...descriptor, + options: descriptor.options.filter((option) => option.id !== "ultracode"), + }; + + expect(getEffortGaugeAngle(descriptor, "ultracode")).toBe( + getEffortGaugeAngle(descriptor, "xhigh"), + ); + expect(getEffortGaugeAngle(descriptor, "high")).toBe(getEffortGaugeAngle(nativeOnly, "high")); + }); + + it("does not let a prompt-only Ultrathink choice extend the effort scale", () => { + const descriptor = selectDescriptor( + "effort", + ["low", "high", "ultrathink"].map((id) => ({ id, label: id })), + ["ultrathink"], + ); + + expect(getEffortGaugeAngle(descriptor, "high")).toBe(120); + expect(getEffortGaugeAngle(descriptor, "ultrathink")).toBe(45); + }); + + it("keeps unknown variants static and out of the effort scale", () => { + const descriptor = selectDescriptor( + "variant", + ["default", "low", "high", "custom"].map((id) => ({ id, label: id })), + ); + + expect(getEffortGaugeAngle(descriptor, "custom")).toBe(45); + expect(getEffortGaugeAngle(descriptor, "default")).toBe(45); + expect(getEffortGaugeAngle(descriptor, "high")).toBe(120); + }); +}); + describe("getComposerProviderState", () => { it("uses descriptor defaults for display without dispatching them as overrides", () => { const state = getComposerProviderState({