authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-05 12:06:59-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 12:06:59-03:00
loge22c8804346d4bd45a733803eda826e04e3bbd74
treeb0e03254002d46c31b8ac6b6b06d3824013e7a3f
parent7b797f2b90918e7bae0f710dcf24018b8b0843dc
signature Signed by PGP key B5690EEEBB952194

fix(web): show fast mode beside reasoning as text (#16069)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

3 files changed, 140 insertions(+), 119 deletions(-)

apps/web/src/components/Icons.tsx-7
......@@ -2,13 +2,6 @@ import React, { type SVGProps, useId } from "react";
22import { cn } from "~/lib/utils";
33export type Icon = React.FC<SVGProps<SVGSVGElement>>;
44
5export const UltrafastIcon: Icon = (props) => (
6 <svg viewBox="0 0 24 24" fill="currentColor" {...props}>
7 <path d="m17 2-10 12h7l-1 8 10-12h-7l1-8Z" opacity="0.4" />
8 <path d="m11 2-10 12h7l-1 8 10-12h-7l1-8Z" />
9 </svg>
10);
11
125export const FinderIcon: Icon = (props) => (
136 <svg viewBox="0 0 24 24" fill="none" {...props}>
147 <rect x="2" y="2" width="20" height="20" rx="4" fill="#36A9F5" />
apps/web/src/components/chat/TraitsPicker.test.ts+108-58
......@@ -69,32 +69,108 @@ describe("buildTraitsTriggerDisplay", () => {
6969 it("omits fast mode from the label entirely when it is off", () => {
7070 expect(display([EFFORT, fastModeDescriptor(false), CONTEXT_WINDOW])).toEqual({
7171 label: "High · 1M",
72 speedIcon: null,
7372 });
7473 });
7574
76 it("shows the bolt instead of a text label when fast mode is on", () => {
75 it("pairs fast mode with reasoning before the context window", () => {
7776 expect(display([EFFORT, fastModeDescriptor(true), CONTEXT_WINDOW])).toEqual({
78 label: "High · 1M",
79 speedIcon: "fast",
77 label: "High Fast · 1M",
78 });
79 expect(display([EFFORT, CONTEXT_WINDOW, fastModeDescriptor(true)])).toEqual({
80 label: "High Fast · 1M",
8081 });
8182 });
8283
83 it("treats Codex standard and fast service tiers as fast mode states", () => {
84 expect(display([EFFORT, serviceTierDescriptor("default")])).toEqual({
85 label: "High",
86 speedIcon: null,
87 });
88 expect(display([EFFORT, serviceTierDescriptor("priority")])).toEqual({
89 label: "High",
90 speedIcon: "fast",
84 it.each(["reasoningEffort", "reasoning", "effort", "variant", "thinking"])(
85 "pairs Fast with %s even when context is the primary select",
86 (id) => {
87 for (const speed of [fastModeDescriptor(true), serviceTierDescriptor("priority")]) {
88 expect(
89 buildTraitsTriggerDisplay({
90 provider: CODEX,
91 descriptors: [
92 selectDescriptor("profile", [{ id: "balanced", label: "Balanced" }], "balanced"),
93 CONTEXT_WINDOW,
94 { ...EFFORT, id },
95 speed,
96 ],
97 primarySelectDescriptorId: "profile",
98 ultrathinkPromptControlled: false,
99 }),
100 ).toEqual({ label: "Balanced · 1M · High Fast" });
101 }
102 expect(
103 buildTraitsTriggerDisplay({
104 provider: CODEX,
105 descriptors: [CONTEXT_WINDOW, { ...EFFORT, id }, serviceTierDescriptor("ultrafast")],
106 primarySelectDescriptorId: CONTEXT_WINDOW.id,
107 ultrathinkPromptControlled: false,
108 }),
109 ).toEqual({ label: "1M · High Ultrafast" });
110 },
111 );
112
113 it("pairs Cursor Fast with reasoning rather than the thinking toggle", () => {
114 expect(
115 buildTraitsTriggerDisplay({
116 provider: ProviderDriverKind.make("cursor"),
117 descriptors: [
118 { ...EFFORT, id: "reasoning" },
119 fastModeDescriptor(true),
120 { id: "thinking", label: "Thinking", type: "boolean", currentValue: true },
121 ],
122 primarySelectDescriptorId: "reasoning",
123 ultrathinkPromptControlled: false,
124 }),
125 ).toEqual({ label: "High Fast · Thinking On" });
126 });
127
128 it.each([true, false])("keeps boolean thinking %s independent of Fast", (currentValue) => {
129 const thinking = {
130 id: "thinking",
131 label: "Thinking",
132 type: "boolean" as const,
133 currentValue,
134 };
135 const label = `Thinking ${currentValue ? "On" : "Off"}`;
136 expect(display([thinking, fastModeDescriptor(true)])).toEqual({ label: `${label} · Fast` });
137 expect(display([thinking, { ...EFFORT, id: "reasoning" }, fastModeDescriptor(true)])).toEqual({
138 label: `${label} · High Fast`,
91139 });
140 expect(display([thinking, fastModeDescriptor(false)])).toEqual({ label });
141 });
142
143 it("keeps speed separate when there is no reasoning descriptor", () => {
144 expect(display([CONTEXT_WINDOW, fastModeDescriptor(true)])).toEqual({ label: "1M · Fast" });
92145 });
93146
94 it("uses a distinct double bolt for Codex Ultrafast", () => {
147 it.each(["Low", "Medium", "High", "Extra High", "Max", "Ultra"])(
148 "preserves %s reasoning with Fast across harnesses",
149 (label) => {
150 for (const provider of ["codex", "claudeAgent", "cursor"]) {
151 expect(
152 buildTraitsTriggerDisplay({
153 provider: ProviderDriverKind.make(provider),
154 descriptors: [
155 { ...EFFORT, options: [{ id: "effort", label }], currentValue: "effort" },
156 fastModeDescriptor(true),
157 ],
158 primarySelectDescriptorId: EFFORT.id,
159 ultrathinkPromptControlled: false,
160 }),
161 ).toEqual({ label: `${label} Fast` });
162 }
163 },
164 );
165
166 it("treats Codex standard and fast service tiers as fast mode states", () => {
167 expect(display([EFFORT, serviceTierDescriptor("default")])).toEqual({ label: "High" });
168 expect(display([EFFORT, serviceTierDescriptor("priority")])).toEqual({ label: "High Fast" });
169 });
170
171 it("keeps Codex Ultrafast distinct from Fast", () => {
95172 expect(display([EFFORT, serviceTierDescriptor("ultrafast")])).toEqual({
96 label: "High",
97 speedIcon: "ultrafast",
173 label: "High Ultrafast",
98174 });
99175 });
100176
......@@ -105,17 +181,11 @@ describe("buildTraitsTriggerDisplay", () => {
105181 EFFORT,
106182 { ...descriptor, options: descriptor.options.filter(({ id }) => id !== "priority") },
107183 ]),
108 ).toEqual({
109 label: "High",
110 speedIcon: "ultrafast",
111 });
184 ).toEqual({ label: "High Ultrafast" });
112185 });
113186
114187 it("keeps other Codex service tiers in the label", () => {
115 expect(display([EFFORT, serviceTierDescriptor("flex")])).toEqual({
116 label: "High · Flex",
117 speedIcon: null,
118 });
188 expect(display([EFFORT, serviceTierDescriptor("flex")])).toEqual({ label: "High · Flex" });
119189 });
120190
121191 it("keeps Standard as text for models without speed tiers", () => {
......@@ -124,32 +194,17 @@ describe("buildTraitsTriggerDisplay", () => {
124194 ...descriptor,
125195 options: descriptor.options.filter(({ id }) => id === "default" || id === "flex"),
126196 };
127 expect(display([EFFORT, nonSpeedDescriptor])).toEqual({
128 label: "High · Standard",
129 speedIcon: null,
130 });
131 expect(display([nonSpeedDescriptor])).toEqual({
132 label: "Standard",
133 speedIcon: null,
134 });
197 expect(display([EFFORT, nonSpeedDescriptor])).toEqual({ label: "High · Standard" });
198 expect(display([nonSpeedDescriptor])).toEqual({ label: "Standard" });
135199 });
136200
137201 it("keeps the Codex service tier readable when it is the only trait", () => {
138 expect(display([serviceTierDescriptor("default")])).toEqual({
139 label: "Standard",
140 speedIcon: null,
141 });
142 expect(display([serviceTierDescriptor("priority")])).toEqual({
143 label: "Fast",
144 speedIcon: null,
145 });
202 expect(display([serviceTierDescriptor("default")])).toEqual({ label: "Standard" });
203 expect(display([serviceTierDescriptor("priority")])).toEqual({ label: "Fast" });
146204 });
147205
148206 it("keeps Ultrafast readable when it is the only trait", () => {
149 expect(display([serviceTierDescriptor("ultrafast")])).toEqual({
150 label: "Ultrafast",
151 speedIcon: null,
152 });
207 expect(display([serviceTierDescriptor("ultrafast")])).toEqual({ label: "Ultrafast" });
153208 });
154209
155210 it("keeps non-fastMode booleans as text labels", () => {
......@@ -159,21 +214,16 @@ describe("buildTraitsTriggerDisplay", () => {
159214 type: "boolean",
160215 currentValue: true,
161216 };
162 expect(display([EFFORT, thinking])).toEqual({
163 label: "High · Thinking On",
164 speedIcon: null,
165 });
217 expect(display([EFFORT, thinking])).toEqual({ label: "High · Thinking On" });
166218 });
167219
168220 it("falls back to a text label when fast mode is the only trait", () => {
169 expect(display([fastModeDescriptor(true)])).toEqual({
170 label: "Fast",
171 speedIcon: null,
172 });
173 expect(display([fastModeDescriptor(false)])).toEqual({
174 label: "Normal",
175 speedIcon: null,
176 });
221 expect(display([fastModeDescriptor(true)])).toEqual({ label: "Fast" });
222 expect(display([fastModeDescriptor(false)])).toEqual({ label: "Normal" });
223 });
224
225 it("does not add Fast to a model without a speed option", () => {
226 expect(display([EFFORT, CONTEXT_WINDOW])).toEqual({ label: "High · 1M" });
177227 });
178228
179229 it("stays blank when descriptors resolve to no label and there is no fast mode", () => {
......@@ -189,10 +239,10 @@ describe("buildTraitsTriggerDisplay", () => {
189239 { id: "high", label: "High" },
190240 ],
191241 };
192 expect(display([unresolved])).toEqual({ label: "", speedIcon: null });
242 expect(display([unresolved])).toEqual({ label: "" });
193243 });
194244
195 it("still renders the prompt-controlled ultrathink label alongside the bolt", () => {
245 it("still renders prompt-controlled ultrathink with Fast", () => {
196246 expect(
197247 buildTraitsTriggerDisplay({
198248 provider: CODEX,
......@@ -200,7 +250,7 @@ describe("buildTraitsTriggerDisplay", () => {
200250 primarySelectDescriptorId: "reasoningEffort",
201251 ultrathinkPromptControlled: true,
202252 }),
203 ).toEqual({ label: "Ultrathink", speedIcon: "fast" });
253 ).toEqual({ label: "Ultrathink Fast" });
204254 });
205255});
206256
apps/web/src/components/chat/TraitsPicker.tsx+32-54
......@@ -17,8 +17,7 @@ import {
1717 normalizeModelSlug,
1818} from "@t3tools/shared/model";
1919import { memo, useCallback } from "react";
20import { BrainIcon, ZapIcon } from "lucide-react";
21import { UltrafastIcon } from "../Icons";
20import { BrainIcon } from "lucide-react";
2221import {
2322 Menu,
2423 MenuGroup,
......@@ -485,10 +484,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
485484 );
486485});
487486
488/**
489 * Fast mode uses one bolt; Codex Ultrafast uses two. Keep a text label when
490 * speed is the only trait so the trigger remains readable.
491 */
487/** Pair speed with reasoning while keeping other traits separated. */
492488export function buildTraitsTriggerDisplay(input: {
493489 provider: ProviderDriverKind;
494490 descriptors: ReadonlyArray<ProviderOptionDescriptor>;
......@@ -496,14 +492,15 @@ export function buildTraitsTriggerDisplay(input: {
496492 ultrathinkPromptControlled: boolean;
497493 modelSelection?: ModelSelection | null;
498494 reportedModelSelection?: ModelSelection | null | undefined;
499}): { label: string; speedIcon: "fast" | "ultrafast" | null } {
495}): { label: string } {
500496 let fastModeFallbackLabel: string | null = null;
501 let speedIcon: "fast" | "ultrafast" | null = null;
497 let speedLabel: string | null = null;
498 let reasoningLabelIndex = -1;
502499 const labels: Array<string> = [];
503500 for (const descriptor of input.descriptors) {
504501 if (descriptor.id === "fastMode" && descriptor.type === "boolean") {
505 speedIcon = descriptor.currentValue === true ? "fast" : null;
506 fastModeFallbackLabel = speedIcon ? "Fast" : "Normal";
502 speedLabel = descriptor.currentValue === true ? "Fast" : null;
503 fastModeFallbackLabel = speedLabel ?? "Normal";
507504 continue;
508505 }
509506 if (
......@@ -519,11 +516,11 @@ export function buildTraitsTriggerDisplay(input: {
519516 (fastTier && currentValue === fastTier.id) ||
520517 (ultrafastTier && currentValue === ultrafastTier.id)
521518 ) {
522 speedIcon =
519 speedLabel =
523520 ultrafastTier && currentValue === ultrafastTier.id
524 ? "ultrafast"
521 ? "Ultrafast"
525522 : fastTier && currentValue === fastTier.id
526 ? "fast"
523 ? "Fast"
527524 : null;
528525 fastModeFallbackLabel =
529526 descriptor.options.find(({ id }) => id === currentValue)?.label ?? "Normal";
......@@ -541,6 +538,14 @@ export function buildTraitsTriggerDisplay(input: {
541538 input.reportedModelSelection,
542539 );
543540 if (typeof label === "string" && label.length > 0) {
541 // Custom models retain descriptor order, so the primary select can be context.
542 if (
543 reasoningLabelIndex === -1 &&
544 descriptor.type === "select" &&
545 ["reasoningEffort", "reasoning", "effort", "variant", "thinking"].includes(descriptor.id)
546 ) {
547 reasoningLabelIndex = labels.length;
548 }
544549 labels.push(label);
545550 }
546551 }
......@@ -549,9 +554,16 @@ export function buildTraitsTriggerDisplay(input: {
549554 // off an empty label list alone would also catch descriptors that resolved to
550555 // no label at all, printing a bogus "Normal" for a model without fast mode.
551556 if (labels.length === 0 && fastModeFallbackLabel !== null) {
552 return { label: fastModeFallbackLabel, speedIcon: null };
557 return { label: fastModeFallbackLabel };
558 }
559 if (speedLabel) {
560 if (reasoningLabelIndex >= 0) {
561 labels[reasoningLabelIndex] = `${labels[reasoningLabelIndex]} ${speedLabel}`;
562 } else {
563 labels.push(speedLabel);
564 }
553565 }
554 return { label: labels.join(" · "), speedIcon };
566 return { label: labels.join(" · ") };
555567}
556568
557569export const TraitsPicker = memo(function TraitsPicker({
......@@ -601,7 +613,7 @@ export const TraitsPicker = memo(function TraitsPicker({
601613 return null;
602614 }
603615
604 const { label: triggerLabel, speedIcon } = buildTraitsTriggerDisplay({
616 const { label: triggerLabel } = buildTraitsTriggerDisplay({
605617 provider,
606618 descriptors,
607619 primarySelectDescriptorId: primarySelectDescriptor?.id ?? null,
......@@ -609,26 +621,6 @@ export const TraitsPicker = memo(function TraitsPicker({
609621 modelSelection: instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null,
610622 reportedModelSelection,
611623 });
612 const speedLabel = speedIcon === "ultrafast" ? "Ultrafast mode on" : "Fast mode on";
613 const accessibleLabel = speedIcon ? `${triggerLabel}, ${speedLabel}` : triggerLabel;
614 const fastModeIcon = speedIcon ? (
615 <>
616 <ComposerControlIcon
617 icon={speedIcon === "ultrafast" ? UltrafastIcon : ZapIcon}
618 size={size}
619 className={cn(
620 "fill-current opacity-80",
621 size === "xs"
622 ? "text-current"
623 : provider === "claudeAgent"
624 ? "text-[#d97757]"
625 : "text-foreground",
626 )}
627 />
628 <span className="sr-only">{speedLabel}</span>
629 </>
630 ) : null;
631
632624 const isCodexStyle = provider === "codex";
633625
634626 return (
......@@ -644,7 +636,7 @@ export const TraitsPicker = memo(function TraitsPicker({
644636 <MenuTrigger
645637 render={
646638 <ComposerControl
647 aria-label={accessibleLabel}
639 aria-label={triggerLabel}
648640 data-composer-shortcut={isComposerOwned ? "composer.effort" : undefined}
649641 size={size}
650642 className={cn(
......@@ -667,14 +659,7 @@ export const TraitsPicker = memo(function TraitsPicker({
667659 size === "xs" ? "gap-1" : "gap-1.5",
668660 )}
669661 >
670 {fastModeIcon ?? (
671 <span
672 data-composer-control-compact-icon
673 className="pointer-events-none invisible absolute"
674 >
675 <ComposerControlIcon icon={BrainIcon} size={size} />
676 </span>
677 )}
662 <ComposerControlIcon icon={BrainIcon} size={size} />
678663 <span data-composer-control-label className="min-w-0 truncate">
679664 {triggerLabel}
680665 </span>
......@@ -682,20 +667,13 @@ export const TraitsPicker = memo(function TraitsPicker({
682667 </span>
683668 ) : (
684669 <>
685 {fastModeIcon ?? (
686 <span
687 data-composer-control-compact-icon
688 className="pointer-events-none invisible absolute"
689 >
690 <ComposerControlIcon icon={BrainIcon} size={size} />
691 </span>
692 )}
670 <ComposerControlIcon icon={BrainIcon} size={size} />
693671 <span data-composer-control-label>{triggerLabel}</span>
694672 <ComposerControlChevron size={size} />
695673 </>
696674 )}
697675 </TooltipTrigger>
698 <TooltipPopup side="top">{accessibleLabel}</TooltipPopup>
676 <TooltipPopup side="top">{triggerLabel}</TooltipPopup>
699677 </Tooltip>
700678 <MenuPopup align="start" {...(isComposerOwned ? composerFloatingLayerProps : {})}>
701679 <TraitsMenuContent