Skip to content
Open
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
49 changes: 33 additions & 16 deletions apps/web/src/components/chat/ComposerModelEffort.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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: (
Expand All @@ -120,20 +133,24 @@ export function describeComposerEffort(
>
{word !== null ? (
<span data-composer-control-label>{word}</span>
) : glyph ? (
) : glyph?.kind === "thinking" ? (
<BrainIcon aria-hidden="true" className={size === "xs" ? "size-4" : "size-4.5"}>
{glyph.enabled ? null : <path d="m3 3 18 18" />}
</BrainIcon>
) : glyph?.kind === "bars" ? (
<EffortGlyph count={glyph.count} filled={glyph.filled} size={size} />
) : null}
{glyph && (display.speedLabel === "Fast" || display.speedLabel === "Ultrafast") ? (
<FastBolt ultra={display.speedLabel === "Ultrafast"} />
) : null}
{glyph && display.otherLabels.length > 0 ? (
{glyph && otherLabels.length > 0 ? (
<span
// A label, so icon-only footers drop it; compacted rows drop it before the model.
data-composer-control-label
data-composer-secondary-trait
className="max-w-24 truncate text-xs"
>
{display.otherLabels.join(" · ")}
{otherLabels.join(" · ")}
</span>
) : null}
</span>
Expand Down Expand Up @@ -246,7 +263,7 @@ function EffortLevels(props: { traits: TraitsSelectionInput }) {
<ColumnRow
key={String(value)}
label={value ? "On" : "Off"}
selected={(control.descriptor.currentValue === true) === value}
selected={traits.booleanValue(control.descriptor) === value}
level={isEffort}
onPick={() => traits.changeBoolean(descriptor.id, value)}
/>
Expand Down Expand Up @@ -395,7 +412,7 @@ function SpeedControl(props: { traits: TraitsSelectionInput }) {
? ([false, true] as const).map((value) => (
<SpeedSegment
key={String(value)}
checked={(fastToggle.currentValue === true) === value}
checked={traits.booleanValue(fastToggle) === value}
label={value ? "Fast" : "Standard"}
onPick={() => traits.changeBoolean(fastToggle.id, value)}
/>
Expand Down
61 changes: 50 additions & 11 deletions apps/web/src/components/chat/EffortGlyph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
59 changes: 37 additions & 22 deletions apps/web/src/components/chat/EffortGlyph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderOptionDescriptor, { type: "select" }>;

// 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,
};
}

Expand All @@ -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 (
<svg
Expand All @@ -69,16 +84,16 @@ export function EffortGlyph(props: { count: number; filled: number; size?: Compo
data-composer-control-icon
>
{effortBarHeights(props.count, maxBar).map((barHeight, index) => {
const x = index * step;
const x = inset + index * step;
return (
<rect
key={x}
x={x}
y={maxBar - barHeight}
width={2}
width={barWidth}
height={barHeight}
fill="currentColor"
opacity={index < props.filled ? 1 : 0.4}
opacity={index < props.filled ? 1 : 0.25}
/>
);
})}
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/chat/TraitsPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import {
buildProviderOptionSelectionsFromDescriptors,
getProviderOptionCurrentLabel,
getProviderOptionCurrentValue,
getProviderOptionDescriptors,
isClaudeUltrathinkPrompt,
normalizeModelSlug,
Expand Down Expand Up @@ -347,6 +348,8 @@ export function useTraitsSelection({
selected.ultrathinkPromptControlled && descriptor.id === selected.primarySelectDescriptor?.id
? "ultrathink"
: (getDescriptorStringValue(descriptor, modelSelection, reportedModelSelection) ?? ""),
booleanValue: (descriptor: Extract<ProviderOptionDescriptor, { type: "boolean" }>) =>
getProviderOptionCurrentValue(descriptor, modelSelection, reportedModelSelection) === true,
currentLabel: (descriptor: ProviderOptionDescriptor) =>
getProviderOptionCurrentLabel(descriptor, modelSelection, reportedModelSelection),
changeSelect: (
Expand Down Expand Up @@ -463,7 +466,7 @@ const TraitsMenuContent = memo(function TraitsMenuContentImpl(props: TraitsSelec
{descriptor.label}
</div>
<MenuRadioGroup
value={descriptor.currentValue === true ? "on" : "off"}
value={traits.booleanValue(descriptor) ? "on" : "off"}
onValueChange={(value) => traits.changeBoolean(descriptor.id, value === "on")}
>
{(["on", "off"] as const).map((value) => (
Expand Down
41 changes: 41 additions & 0 deletions packages/client-runtime/src/modelTraits.test.ts
Original file line number Diff line number Diff line change
@@ -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] });
});
2 changes: 1 addition & 1 deletion packages/client-runtime/src/modelTraits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading