diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 4c62f5f385ef14521f89b7105725ec609e4708fb..f47bd48678abb7ac4206816e57c393e4a1c794b1 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -2,13 +2,6 @@ import React, { type SVGProps, useId } from "react"; import { cn } from "~/lib/utils"; export type Icon = React.FC>; -export const UltrafastIcon: Icon = (props) => ( - - - - -); - export const FinderIcon: Icon = (props) => ( diff --git a/apps/web/src/components/chat/TraitsPicker.test.ts b/apps/web/src/components/chat/TraitsPicker.test.ts index 885a1bd9d83d8c6bc73044c3971c55d2301e0919..ab688bf5a043f70400e616a8150294d53f8a526f 100644 --- a/apps/web/src/components/chat/TraitsPicker.test.ts +++ b/apps/web/src/components/chat/TraitsPicker.test.ts @@ -69,32 +69,108 @@ describe("buildTraitsTriggerDisplay", () => { it("omits fast mode from the label entirely when it is off", () => { expect(display([EFFORT, fastModeDescriptor(false), CONTEXT_WINDOW])).toEqual({ label: "High · 1M", - speedIcon: null, }); }); - it("shows the bolt instead of a text label when fast mode is on", () => { + it("pairs fast mode with reasoning before the context window", () => { expect(display([EFFORT, fastModeDescriptor(true), CONTEXT_WINDOW])).toEqual({ - label: "High · 1M", - speedIcon: "fast", + label: "High Fast · 1M", }); + expect(display([EFFORT, CONTEXT_WINDOW, fastModeDescriptor(true)])).toEqual({ + label: "High Fast · 1M", + }); + }); + + it.each(["reasoningEffort", "reasoning", "effort", "variant", "thinking"])( + "pairs Fast with %s even when context is the primary select", + (id) => { + for (const speed of [fastModeDescriptor(true), serviceTierDescriptor("priority")]) { + expect( + buildTraitsTriggerDisplay({ + provider: CODEX, + descriptors: [ + selectDescriptor("profile", [{ id: "balanced", label: "Balanced" }], "balanced"), + CONTEXT_WINDOW, + { ...EFFORT, id }, + speed, + ], + primarySelectDescriptorId: "profile", + ultrathinkPromptControlled: false, + }), + ).toEqual({ label: "Balanced · 1M · High Fast" }); + } + expect( + buildTraitsTriggerDisplay({ + provider: CODEX, + descriptors: [CONTEXT_WINDOW, { ...EFFORT, id }, serviceTierDescriptor("ultrafast")], + primarySelectDescriptorId: CONTEXT_WINDOW.id, + ultrathinkPromptControlled: false, + }), + ).toEqual({ label: "1M · High Ultrafast" }); + }, + ); + + it("pairs Cursor Fast with reasoning rather than the thinking toggle", () => { + expect( + buildTraitsTriggerDisplay({ + provider: ProviderDriverKind.make("cursor"), + descriptors: [ + { ...EFFORT, id: "reasoning" }, + fastModeDescriptor(true), + { id: "thinking", label: "Thinking", type: "boolean", currentValue: true }, + ], + primarySelectDescriptorId: "reasoning", + ultrathinkPromptControlled: false, + }), + ).toEqual({ label: "High Fast · Thinking On" }); + }); + + it.each([true, false])("keeps boolean thinking %s independent of Fast", (currentValue) => { + const thinking = { + id: "thinking", + label: "Thinking", + type: "boolean" as const, + currentValue, + }; + const label = `Thinking ${currentValue ? "On" : "Off"}`; + expect(display([thinking, fastModeDescriptor(true)])).toEqual({ label: `${label} · Fast` }); + expect(display([thinking, { ...EFFORT, id: "reasoning" }, fastModeDescriptor(true)])).toEqual({ + label: `${label} · High Fast`, + }); + expect(display([thinking, fastModeDescriptor(false)])).toEqual({ label }); + }); + + it("keeps speed separate when there is no reasoning descriptor", () => { + expect(display([CONTEXT_WINDOW, fastModeDescriptor(true)])).toEqual({ label: "1M · Fast" }); }); + it.each(["Low", "Medium", "High", "Extra High", "Max", "Ultra"])( + "preserves %s reasoning with Fast across harnesses", + (label) => { + for (const provider of ["codex", "claudeAgent", "cursor"]) { + expect( + buildTraitsTriggerDisplay({ + provider: ProviderDriverKind.make(provider), + descriptors: [ + { ...EFFORT, options: [{ id: "effort", label }], currentValue: "effort" }, + fastModeDescriptor(true), + ], + primarySelectDescriptorId: EFFORT.id, + ultrathinkPromptControlled: false, + }), + ).toEqual({ label: `${label} Fast` }); + } + }, + ); + it("treats Codex standard and fast service tiers as fast mode states", () => { - expect(display([EFFORT, serviceTierDescriptor("default")])).toEqual({ - label: "High", - speedIcon: null, - }); - expect(display([EFFORT, serviceTierDescriptor("priority")])).toEqual({ - label: "High", - speedIcon: "fast", - }); + expect(display([EFFORT, serviceTierDescriptor("default")])).toEqual({ label: "High" }); + expect(display([EFFORT, serviceTierDescriptor("priority")])).toEqual({ label: "High Fast" }); }); - it("uses a distinct double bolt for Codex Ultrafast", () => { + it("keeps Codex Ultrafast distinct from Fast", () => { expect(display([EFFORT, serviceTierDescriptor("ultrafast")])).toEqual({ - label: "High", - speedIcon: "ultrafast", + label: "High Ultrafast", }); }); @@ -105,17 +181,11 @@ describe("buildTraitsTriggerDisplay", () => { EFFORT, { ...descriptor, options: descriptor.options.filter(({ id }) => id !== "priority") }, ]), - ).toEqual({ - label: "High", - speedIcon: "ultrafast", - }); + ).toEqual({ label: "High Ultrafast" }); }); it("keeps other Codex service tiers in the label", () => { - expect(display([EFFORT, serviceTierDescriptor("flex")])).toEqual({ - label: "High · Flex", - speedIcon: null, - }); + expect(display([EFFORT, serviceTierDescriptor("flex")])).toEqual({ label: "High · Flex" }); }); it("keeps Standard as text for models without speed tiers", () => { @@ -124,32 +194,17 @@ describe("buildTraitsTriggerDisplay", () => { ...descriptor, options: descriptor.options.filter(({ id }) => id === "default" || id === "flex"), }; - expect(display([EFFORT, nonSpeedDescriptor])).toEqual({ - label: "High · Standard", - speedIcon: null, - }); - expect(display([nonSpeedDescriptor])).toEqual({ - label: "Standard", - speedIcon: null, - }); + expect(display([EFFORT, nonSpeedDescriptor])).toEqual({ label: "High · Standard" }); + expect(display([nonSpeedDescriptor])).toEqual({ label: "Standard" }); }); it("keeps the Codex service tier readable when it is the only trait", () => { - expect(display([serviceTierDescriptor("default")])).toEqual({ - label: "Standard", - speedIcon: null, - }); - expect(display([serviceTierDescriptor("priority")])).toEqual({ - label: "Fast", - speedIcon: null, - }); + expect(display([serviceTierDescriptor("default")])).toEqual({ label: "Standard" }); + expect(display([serviceTierDescriptor("priority")])).toEqual({ label: "Fast" }); }); it("keeps Ultrafast readable when it is the only trait", () => { - expect(display([serviceTierDescriptor("ultrafast")])).toEqual({ - label: "Ultrafast", - speedIcon: null, - }); + expect(display([serviceTierDescriptor("ultrafast")])).toEqual({ label: "Ultrafast" }); }); it("keeps non-fastMode booleans as text labels", () => { @@ -159,21 +214,16 @@ describe("buildTraitsTriggerDisplay", () => { type: "boolean", currentValue: true, }; - expect(display([EFFORT, thinking])).toEqual({ - label: "High · Thinking On", - speedIcon: null, - }); + expect(display([EFFORT, thinking])).toEqual({ label: "High · Thinking On" }); }); it("falls back to a text label when fast mode is the only trait", () => { - expect(display([fastModeDescriptor(true)])).toEqual({ - label: "Fast", - speedIcon: null, - }); - expect(display([fastModeDescriptor(false)])).toEqual({ - label: "Normal", - speedIcon: null, - }); + expect(display([fastModeDescriptor(true)])).toEqual({ label: "Fast" }); + expect(display([fastModeDescriptor(false)])).toEqual({ label: "Normal" }); + }); + + it("does not add Fast to a model without a speed option", () => { + expect(display([EFFORT, CONTEXT_WINDOW])).toEqual({ label: "High · 1M" }); }); it("stays blank when descriptors resolve to no label and there is no fast mode", () => { @@ -189,10 +239,10 @@ describe("buildTraitsTriggerDisplay", () => { { id: "high", label: "High" }, ], }; - expect(display([unresolved])).toEqual({ label: "", speedIcon: null }); + expect(display([unresolved])).toEqual({ label: "" }); }); - it("still renders the prompt-controlled ultrathink label alongside the bolt", () => { + it("still renders prompt-controlled ultrathink with Fast", () => { expect( buildTraitsTriggerDisplay({ provider: CODEX, @@ -200,7 +250,7 @@ describe("buildTraitsTriggerDisplay", () => { primarySelectDescriptorId: "reasoningEffort", ultrathinkPromptControlled: true, }), - ).toEqual({ label: "Ultrathink", speedIcon: "fast" }); + ).toEqual({ label: "Ultrathink Fast" }); }); }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 2debe0ba0c707a27fda13cdf0b29069259deccf5..ff42d096b37ad15aa5ab042ef4c7cee5b97ca6eb 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -17,8 +17,7 @@ import { normalizeModelSlug, } from "@t3tools/shared/model"; import { memo, useCallback } from "react"; -import { BrainIcon, ZapIcon } from "lucide-react"; -import { UltrafastIcon } from "../Icons"; +import { BrainIcon } from "lucide-react"; import { Menu, MenuGroup, @@ -485,10 +484,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ); }); -/** - * Fast mode uses one bolt; Codex Ultrafast uses two. Keep a text label when - * speed is the only trait so the trigger remains readable. - */ +/** Pair speed with reasoning while keeping other traits separated. */ export function buildTraitsTriggerDisplay(input: { provider: ProviderDriverKind; descriptors: ReadonlyArray; @@ -496,14 +492,15 @@ export function buildTraitsTriggerDisplay(input: { ultrathinkPromptControlled: boolean; modelSelection?: ModelSelection | null; reportedModelSelection?: ModelSelection | null | undefined; -}): { label: string; speedIcon: "fast" | "ultrafast" | null } { +}): { label: string } { let fastModeFallbackLabel: string | null = null; - let speedIcon: "fast" | "ultrafast" | null = null; + let speedLabel: string | null = null; + let reasoningLabelIndex = -1; const labels: Array = []; for (const descriptor of input.descriptors) { if (descriptor.id === "fastMode" && descriptor.type === "boolean") { - speedIcon = descriptor.currentValue === true ? "fast" : null; - fastModeFallbackLabel = speedIcon ? "Fast" : "Normal"; + speedLabel = descriptor.currentValue === true ? "Fast" : null; + fastModeFallbackLabel = speedLabel ?? "Normal"; continue; } if ( @@ -519,11 +516,11 @@ export function buildTraitsTriggerDisplay(input: { (fastTier && currentValue === fastTier.id) || (ultrafastTier && currentValue === ultrafastTier.id) ) { - speedIcon = + speedLabel = ultrafastTier && currentValue === ultrafastTier.id - ? "ultrafast" + ? "Ultrafast" : fastTier && currentValue === fastTier.id - ? "fast" + ? "Fast" : null; fastModeFallbackLabel = descriptor.options.find(({ id }) => id === currentValue)?.label ?? "Normal"; @@ -541,6 +538,14 @@ export function buildTraitsTriggerDisplay(input: { input.reportedModelSelection, ); if (typeof label === "string" && label.length > 0) { + // Custom models retain descriptor order, so the primary select can be context. + if ( + reasoningLabelIndex === -1 && + descriptor.type === "select" && + ["reasoningEffort", "reasoning", "effort", "variant", "thinking"].includes(descriptor.id) + ) { + reasoningLabelIndex = labels.length; + } labels.push(label); } } @@ -549,9 +554,16 @@ export function buildTraitsTriggerDisplay(input: { // off an empty label list alone would also catch descriptors that resolved to // no label at all, printing a bogus "Normal" for a model without fast mode. if (labels.length === 0 && fastModeFallbackLabel !== null) { - return { label: fastModeFallbackLabel, speedIcon: null }; + return { label: fastModeFallbackLabel }; } - return { label: labels.join(" · "), speedIcon }; + if (speedLabel) { + if (reasoningLabelIndex >= 0) { + labels[reasoningLabelIndex] = `${labels[reasoningLabelIndex]} ${speedLabel}`; + } else { + labels.push(speedLabel); + } + } + return { label: labels.join(" · ") }; } export const TraitsPicker = memo(function TraitsPicker({ @@ -601,7 +613,7 @@ export const TraitsPicker = memo(function TraitsPicker({ return null; } - const { label: triggerLabel, speedIcon } = buildTraitsTriggerDisplay({ + const { label: triggerLabel } = buildTraitsTriggerDisplay({ provider, descriptors, primarySelectDescriptorId: primarySelectDescriptor?.id ?? null, @@ -609,26 +621,6 @@ export const TraitsPicker = memo(function TraitsPicker({ modelSelection: instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null, reportedModelSelection, }); - const speedLabel = speedIcon === "ultrafast" ? "Ultrafast mode on" : "Fast mode on"; - const accessibleLabel = speedIcon ? `${triggerLabel}, ${speedLabel}` : triggerLabel; - const fastModeIcon = speedIcon ? ( - <> - - {speedLabel} - - ) : null; - const isCodexStyle = provider === "codex"; return ( @@ -644,7 +636,7 @@ export const TraitsPicker = memo(function TraitsPicker({ - {fastModeIcon ?? ( - - - - )} + {triggerLabel} @@ -682,20 +667,13 @@ export const TraitsPicker = memo(function TraitsPicker({ ) : ( <> - {fastModeIcon ?? ( - - - - )} + {triggerLabel} )} - {accessibleLabel} + {triggerLabel}