authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-24 19:28:37-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-24 19:28:37-07:00
log5719e8ac4020dda0e375ef61d044b61f55a0df8a
tree4146a02f42bcd343a16ca085e112c65d0eb970e9
parent38cfc25e5422e468303f2010f639cf3de9ad89ba
signature Signed by PGP key B5690EEEBB952194

feat(web): show fast mode as a bolt instead of a "Normal" label (#4488)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

2 files changed, 168 insertions(+), 21 deletions(-)

apps/web/src/components/chat/TraitsPicker.test.ts created+110
......@@ -0,0 +1,110 @@
1import { describe, expect, it } from "vite-plus/test";
2import type { ProviderOptionDescriptor } from "@t3tools/contracts";
3import { buildTraitsTriggerDisplay } from "./TraitsPicker";
4
5function selectDescriptor(
6 id: string,
7 options: ReadonlyArray<{ id: string; label: string }>,
8 currentValue: string,
9): Extract<ProviderOptionDescriptor, { type: "select" }> {
10 return { id, label: id, type: "select", options: [...options], currentValue };
11}
12
13function fastModeDescriptor(
14 currentValue: boolean,
15): Extract<ProviderOptionDescriptor, { type: "boolean" }> {
16 return { id: "fastMode", label: "Fast Mode", type: "boolean", currentValue };
17}
18
19const EFFORT = selectDescriptor(
20 "effort",
21 [
22 { id: "high", label: "High" },
23 { id: "max", label: "Max" },
24 ],
25 "high",
26);
27const CONTEXT_WINDOW = selectDescriptor(
28 "contextWindow",
29 [
30 { id: "200k", label: "200k" },
31 { id: "1m", label: "1M" },
32 ],
33 "1m",
34);
35
36function display(descriptors: ReadonlyArray<ProviderOptionDescriptor>, fastModeEnabled: boolean) {
37 return buildTraitsTriggerDisplay({
38 descriptors,
39 primarySelectDescriptorId: "effort",
40 ultrathinkPromptControlled: false,
41 fastModeEnabled,
42 });
43}
44
45describe("buildTraitsTriggerDisplay", () => {
46 it("omits fast mode from the label entirely when it is off", () => {
47 expect(display([EFFORT, fastModeDescriptor(false), CONTEXT_WINDOW], false)).toEqual({
48 label: "High · 1M",
49 showFastModeIcon: false,
50 });
51 });
52
53 it("shows the bolt instead of a text label when fast mode is on", () => {
54 expect(display([EFFORT, fastModeDescriptor(true), CONTEXT_WINDOW], true)).toEqual({
55 label: "High · 1M",
56 showFastModeIcon: true,
57 });
58 });
59
60 it("keeps non-fastMode booleans as text labels", () => {
61 const thinking: Extract<ProviderOptionDescriptor, { type: "boolean" }> = {
62 id: "thinking",
63 label: "Thinking",
64 type: "boolean",
65 currentValue: true,
66 };
67 expect(display([EFFORT, thinking], false)).toEqual({
68 label: "High · Thinking On",
69 showFastModeIcon: false,
70 });
71 });
72
73 it("falls back to a text label when fast mode is the only trait", () => {
74 expect(display([fastModeDescriptor(true)], true)).toEqual({
75 label: "Fast",
76 showFastModeIcon: false,
77 });
78 expect(display([fastModeDescriptor(false)], false)).toEqual({
79 label: "Normal",
80 showFastModeIcon: false,
81 });
82 });
83
84 it("stays blank when descriptors resolve to no label and there is no fast mode", () => {
85 // A select with neither a currentValue nor an isDefault option yields no
86 // label. Without a fastMode descriptor present that must stay blank rather
87 // than falling through to a bogus "Normal".
88 const unresolved: Extract<ProviderOptionDescriptor, { type: "select" }> = {
89 id: "effort",
90 label: "effort",
91 type: "select",
92 options: [
93 { id: "low", label: "Low" },
94 { id: "high", label: "High" },
95 ],
96 };
97 expect(display([unresolved], false)).toEqual({ label: "", showFastModeIcon: false });
98 });
99
100 it("still renders the prompt-controlled ultrathink label alongside the bolt", () => {
101 expect(
102 buildTraitsTriggerDisplay({
103 descriptors: [EFFORT, fastModeDescriptor(true)],
104 primarySelectDescriptorId: "effort",
105 ultrathinkPromptControlled: true,
106 fastModeEnabled: true,
107 }),
108 ).toEqual({ label: "Ultrathink", showFastModeIcon: true });
109 });
110});
apps/web/src/components/chat/TraitsPicker.tsx+58-21
......@@ -16,7 +16,7 @@ import {
1616} from "@t3tools/shared/model";
1717import { memo, useCallback, useState } from "react";
1818import type { VariantProps } from "class-variance-authority";
19import { ChevronDownIcon } from "lucide-react";
19import { ChevronDownIcon, ZapIcon } from "lucide-react";
2020import { Button, buttonVariants } from "../ui/button";
2121import {
2222 Menu,
......@@ -379,6 +379,46 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
379379 );
380380});
381381
382/**
383 * Build the traits trigger's text label plus whether the fast-mode bolt should
384 * render. Fast mode is a lightning bolt when on and nothing at all when off —
385 * "Normal" is the near-universal case and isn't worth the horizontal space. The
386 * one exception is when fast mode is the only trait, where a bare bolt (or bare
387 * chevron) would leave the trigger unreadable.
388 */
389export function buildTraitsTriggerDisplay(input: {
390 descriptors: ReadonlyArray<ProviderOptionDescriptor>;
391 primarySelectDescriptorId: string | null;
392 ultrathinkPromptControlled: boolean;
393 fastModeEnabled: boolean;
394}): { label: string; showFastModeIcon: boolean } {
395 let hasFastMode = false;
396 const labels: Array<string> = [];
397 for (const descriptor of input.descriptors) {
398 if (descriptor.id === "fastMode" && descriptor.type === "boolean") {
399 hasFastMode = true;
400 continue;
401 }
402 const label =
403 input.ultrathinkPromptControlled && descriptor.id === input.primarySelectDescriptorId
404 ? "Ultrathink"
405 : descriptor.type === "boolean"
406 ? `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}`
407 : getProviderOptionCurrentLabel(descriptor);
408 if (typeof label === "string" && label.length > 0) {
409 labels.push(label);
410 }
411 }
412
413 // Only fall back to text when fast mode is genuinely the sole trait. Keying
414 // off an empty label list alone would also catch descriptors that resolved to
415 // no label at all, printing a bogus "Normal" for a model without fast mode.
416 if (labels.length === 0 && hasFastMode) {
417 return { label: input.fastModeEnabled ? "Fast" : "Normal", showFastModeIcon: false };
418 }
419 return { label: labels.join(" · "), showFastModeIcon: input.fastModeEnabled };
420}
421
382422export const TraitsPicker = memo(function TraitsPicker({
383423 provider,
384424 instanceId,
......@@ -393,7 +433,7 @@ export const TraitsPicker = memo(function TraitsPicker({
393433 ...persistence
394434}: TraitsMenuContentProps & TraitsPersistence) {
395435 const [isMenuOpen, setIsMenuOpen] = useState(false);
396 const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } =
436 const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled, fastModeEnabled } =
397437 getTraitsSectionVisibility({
398438 provider,
399439 models,
......@@ -415,23 +455,18 @@ export const TraitsPicker = memo(function TraitsPicker({
415455 return null;
416456 }
417457
418 const triggerLabels: Array<string> = [];
419 for (const descriptor of descriptors) {
420 const label =
421 ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id
422 ? "Ultrathink"
423 : descriptor.type === "boolean"
424 ? descriptor.id === "fastMode"
425 ? descriptor.currentValue === true
426 ? "Fast"
427 : "Normal"
428 : `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}`
429 : getProviderOptionCurrentLabel(descriptor);
430 if (typeof label === "string" && label.length > 0) {
431 triggerLabels.push(label);
432 }
433 }
434 const triggerLabel = triggerLabels.join(" · ");
458 const { label: triggerLabel, showFastModeIcon } = buildTraitsTriggerDisplay({
459 descriptors,
460 primarySelectDescriptorId: primarySelectDescriptor?.id ?? null,
461 ultrathinkPromptControlled,
462 fastModeEnabled,
463 });
464 const fastModeIcon = showFastModeIcon ? (
465 <>
466 <ZapIcon aria-hidden="true" className="size-3 shrink-0 text-foreground/80 opacity-100" />
467 <span className="sr-only">Fast mode on</span>
468 </>
469 ) : null;
435470
436471 const isCodexStyle = provider === "codex";
437472
......@@ -457,12 +492,14 @@ export const TraitsPicker = memo(function TraitsPicker({
457492 }
458493 >
459494 {isCodexStyle ? (
460 <span className="flex min-w-0 w-full items-center gap-2 overflow-hidden">
461 {triggerLabel}
495 <span className="flex min-w-0 w-full items-center gap-1.5 overflow-hidden">
496 {fastModeIcon}
497 <span className="min-w-0 truncate">{triggerLabel}</span>
462498 <ChevronDownIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
463499 </span>
464500 ) : (
465501 <>
502 {fastModeIcon}
466503 <span>{triggerLabel}</span>
467504 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
468505 </>