diff --git a/apps/web/src/components/chat/TraitsPicker.test.ts b/apps/web/src/components/chat/TraitsPicker.test.ts new file mode 100644 index 00000000000..93157490090 --- /dev/null +++ b/apps/web/src/components/chat/TraitsPicker.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { ProviderOptionDescriptor } from "@t3tools/contracts"; +import { buildTraitsTriggerDisplay } from "./TraitsPicker"; + +function selectDescriptor( + id: string, + options: ReadonlyArray<{ id: string; label: string }>, + currentValue: string, +): Extract { + return { id, label: id, type: "select", options: [...options], currentValue }; +} + +function fastModeDescriptor( + currentValue: boolean, +): Extract { + return { id: "fastMode", label: "Fast Mode", type: "boolean", currentValue }; +} + +const EFFORT = selectDescriptor( + "effort", + [ + { id: "high", label: "High" }, + { id: "max", label: "Max" }, + ], + "high", +); +const CONTEXT_WINDOW = selectDescriptor( + "contextWindow", + [ + { id: "200k", label: "200k" }, + { id: "1m", label: "1M" }, + ], + "1m", +); + +function display(descriptors: ReadonlyArray, fastModeEnabled: boolean) { + return buildTraitsTriggerDisplay({ + descriptors, + primarySelectDescriptorId: "effort", + ultrathinkPromptControlled: false, + fastModeEnabled, + }); +} + +describe("buildTraitsTriggerDisplay", () => { + it("omits fast mode from the label entirely when it is off", () => { + expect(display([EFFORT, fastModeDescriptor(false), CONTEXT_WINDOW], false)).toEqual({ + label: "High · 1M", + showFastModeIcon: false, + }); + }); + + it("shows the bolt instead of a text label when fast mode is on", () => { + expect(display([EFFORT, fastModeDescriptor(true), CONTEXT_WINDOW], true)).toEqual({ + label: "High · 1M", + showFastModeIcon: true, + }); + }); + + it("keeps non-fastMode booleans as text labels", () => { + const thinking: Extract = { + id: "thinking", + label: "Thinking", + type: "boolean", + currentValue: true, + }; + expect(display([EFFORT, thinking], false)).toEqual({ + label: "High · Thinking On", + showFastModeIcon: false, + }); + }); + + it("falls back to a text label when fast mode is the only trait", () => { + expect(display([fastModeDescriptor(true)], true)).toEqual({ + label: "Fast", + showFastModeIcon: false, + }); + expect(display([fastModeDescriptor(false)], false)).toEqual({ + label: "Normal", + showFastModeIcon: false, + }); + }); + + it("stays blank when descriptors resolve to no label and there is no fast mode", () => { + // A select with neither a currentValue nor an isDefault option yields no + // label. Without a fastMode descriptor present that must stay blank rather + // than falling through to a bogus "Normal". + const unresolved: Extract = { + id: "effort", + label: "effort", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + }; + expect(display([unresolved], false)).toEqual({ label: "", showFastModeIcon: false }); + }); + + it("still renders the prompt-controlled ultrathink label alongside the bolt", () => { + expect( + buildTraitsTriggerDisplay({ + descriptors: [EFFORT, fastModeDescriptor(true)], + primarySelectDescriptorId: "effort", + ultrathinkPromptControlled: true, + fastModeEnabled: true, + }), + ).toEqual({ label: "Ultrathink", showFastModeIcon: true }); + }); +}); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 60f7f828ad4..d48fe484eb6 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -16,7 +16,7 @@ import { } from "@t3tools/shared/model"; import { memo, useCallback, useState } from "react"; import type { VariantProps } from "class-variance-authority"; -import { ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon, ZapIcon } from "lucide-react"; import { Button, buttonVariants } from "../ui/button"; import { Menu, @@ -379,6 +379,46 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ); }); +/** + * Build the traits trigger's text label plus whether the fast-mode bolt should + * render. Fast mode is a lightning bolt when on and nothing at all when off — + * "Normal" is the near-universal case and isn't worth the horizontal space. The + * one exception is when fast mode is the only trait, where a bare bolt (or bare + * chevron) would leave the trigger unreadable. + */ +export function buildTraitsTriggerDisplay(input: { + descriptors: ReadonlyArray; + primarySelectDescriptorId: string | null; + ultrathinkPromptControlled: boolean; + fastModeEnabled: boolean; +}): { label: string; showFastModeIcon: boolean } { + let hasFastMode = false; + const labels: Array = []; + for (const descriptor of input.descriptors) { + if (descriptor.id === "fastMode" && descriptor.type === "boolean") { + hasFastMode = true; + continue; + } + const label = + input.ultrathinkPromptControlled && descriptor.id === input.primarySelectDescriptorId + ? "Ultrathink" + : descriptor.type === "boolean" + ? `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}` + : getProviderOptionCurrentLabel(descriptor); + if (typeof label === "string" && label.length > 0) { + labels.push(label); + } + } + + // Only fall back to text when fast mode is genuinely the sole trait. Keying + // 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 && hasFastMode) { + return { label: input.fastModeEnabled ? "Fast" : "Normal", showFastModeIcon: false }; + } + return { label: labels.join(" · "), showFastModeIcon: input.fastModeEnabled }; +} + export const TraitsPicker = memo(function TraitsPicker({ provider, instanceId, @@ -393,7 +433,7 @@ export const TraitsPicker = memo(function TraitsPicker({ ...persistence }: TraitsMenuContentProps & TraitsPersistence) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = + const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled, fastModeEnabled } = getTraitsSectionVisibility({ provider, models, @@ -415,23 +455,18 @@ export const TraitsPicker = memo(function TraitsPicker({ return null; } - const triggerLabels: Array = []; - for (const descriptor of descriptors) { - const label = - ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id - ? "Ultrathink" - : descriptor.type === "boolean" - ? descriptor.id === "fastMode" - ? descriptor.currentValue === true - ? "Fast" - : "Normal" - : `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}` - : getProviderOptionCurrentLabel(descriptor); - if (typeof label === "string" && label.length > 0) { - triggerLabels.push(label); - } - } - const triggerLabel = triggerLabels.join(" · "); + const { label: triggerLabel, showFastModeIcon } = buildTraitsTriggerDisplay({ + descriptors, + primarySelectDescriptorId: primarySelectDescriptor?.id ?? null, + ultrathinkPromptControlled, + fastModeEnabled, + }); + const fastModeIcon = showFastModeIcon ? ( + <> +