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({