Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 75 additions & 5 deletions apps/web/src/components/chat/TraitsPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<Icon
iconNode={[["path", { d: "M3.34 19a10 10 0 1 1 17.32 0", key: "dial" }]]}
aria-hidden="true"
data-composer-control-icon
className={cn("hidden shrink-0 redesign:block", size === "xs" ? "size-3" : "size-4")}
>
<path d="m12 14 4-4" transform={`rotate(${angle - 45} 12 14)`} />
</Icon>
);
}

function getSelectedTraits(
provider: ProviderDriverKind,
models: ReadonlyArray<ServerProviderModel>,
Expand Down Expand Up @@ -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,
Expand All @@ -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 =
Comment thread
PixPMusic marked this conversation as resolved.
Resolved
reasoningDescriptor?.type === "boolean"
? (reasoningDescriptor.currentValue ?? null)
: getDescriptorStringValue(reasoningDescriptor, modelSelection, reportedModelSelection);
const isCodexStyle = provider === "codex";

return (
Expand Down Expand Up @@ -601,7 +671,7 @@ export const TraitsPicker = memo(function TraitsPicker({
)}
>
<ComposerControlIcon icon={BrainIcon} size={size} className="redesign:hidden" />
<ComposerControlIcon icon={GaugeIcon} size={size} className="hidden redesign:block" />
<EffortGaugeIcon descriptor={reasoningDescriptor} value={effort} size={size} />
<span data-composer-control-label className="min-w-0 truncate">
{triggerLabel}
</span>
Expand All @@ -610,7 +680,7 @@ export const TraitsPicker = memo(function TraitsPicker({
) : (
<>
<ComposerControlIcon icon={BrainIcon} size={size} className="redesign:hidden" />
<ComposerControlIcon icon={GaugeIcon} size={size} className="hidden redesign:block" />
<EffortGaugeIcon descriptor={reasoningDescriptor} value={effort} size={size} />
<span data-composer-control-label>{triggerLabel}</span>
<ComposerControlChevron size={size} />
</>
Expand Down
67 changes: 66 additions & 1 deletion apps/web/src/components/chat/composerProviderState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down
Loading