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.enabled ? null : }
+
+ ) : 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 (