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";...@@ -2,13 +2,6 @@ import React, { type SVGProps, useId } from "react";
2import { cn } from "~/lib/utils";2import { cn } from "~/lib/utils";
3export type Icon = React.FC<SVGProps<SVGSVGElement>>;3export 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
12export const FinderIcon: Icon = (props) => (5export const FinderIcon: Icon = (props) => (
13 <svg viewBox="0 0 24 24" fill="none" {...props}>6 <svg viewBox="0 0 24 24" fill="none" {...props}>
14 <rect x="2" y="2" width="20" height="20" rx="4" fill="#36A9F5" />7 <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", () => {...@@ -69,32 +69,108 @@ describe("buildTraitsTriggerDisplay", () => {
69 it("omits fast mode from the label entirely when it is off", () => {69 it("omits fast mode from the label entirely when it is off", () => {
70 expect(display([EFFORT, fastModeDescriptor(false), CONTEXT_WINDOW])).toEqual({70 expect(display([EFFORT, fastModeDescriptor(false), CONTEXT_WINDOW])).toEqual({
71 label: "High · 1M",71 label: "High · 1M",
72 speedIcon: null,
73 });72 });
74 });73 });
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", () => {
77 expect(display([EFFORT, fastModeDescriptor(true), CONTEXT_WINDOW])).toEqual({76 expect(display([EFFORT, fastModeDescriptor(true), CONTEXT_WINDOW])).toEqual({
78 label: "High · 1M",77 label: "High Fast · 1M",
79 speedIcon: "fast",78 });
79 expect(display([EFFORT, CONTEXT_WINDOW, fastModeDescriptor(true)])).toEqual({
80 label: "High Fast · 1M",
80 });81 });
81 });82 });
8283
83 it("treats Codex standard and fast service tiers as fast mode states", () => {84 it.each(["reasoningEffort", "reasoning", "effort", "variant", "thinking"])(
84 expect(display([EFFORT, serviceTierDescriptor("default")])).toEqual({85 "pairs Fast with %s even when context is the primary select",
85 label: "High",86 (id) => {
86 speedIcon: null,87 for (const speed of [fastModeDescriptor(true), serviceTierDescriptor("priority")]) {
87 });88 expect(
88 expect(display([EFFORT, serviceTierDescriptor("priority")])).toEqual({89 buildTraitsTriggerDisplay({
89 label: "High",90 provider: CODEX,
90 speedIcon: "fast",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`,
91 });139 });
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" });
92 });145 });
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", () => {
95 expect(display([EFFORT, serviceTierDescriptor("ultrafast")])).toEqual({172 expect(display([EFFORT, serviceTierDescriptor("ultrafast")])).toEqual({
96 label: "High",173 label: "High Ultrafast",
97 speedIcon: "ultrafast",
98 });174 });
99 });175 });
100176
...@@ -105,17 +181,11 @@ describe("buildTraitsTriggerDisplay", () => {...@@ -105,17 +181,11 @@ describe("buildTraitsTriggerDisplay", () => {
105 EFFORT,181 EFFORT,
106 { ...descriptor, options: descriptor.options.filter(({ id }) => id !== "priority") },182 { ...descriptor, options: descriptor.options.filter(({ id }) => id !== "priority") },
107 ]),183 ]),
108 ).toEqual({184 ).toEqual({ label: "High Ultrafast" });
109 label: "High",
110 speedIcon: "ultrafast",
111 });
112 });185 });
113186
114 it("keeps other Codex service tiers in the label", () => {187 it("keeps other Codex service tiers in the label", () => {
115 expect(display([EFFORT, serviceTierDescriptor("flex")])).toEqual({188 expect(display([EFFORT, serviceTierDescriptor("flex")])).toEqual({ label: "High · Flex" });
116 label: "High · Flex",
117 speedIcon: null,
118 });
119 });189 });
120190
121 it("keeps Standard as text for models without speed tiers", () => {191 it("keeps Standard as text for models without speed tiers", () => {
...@@ -124,32 +194,17 @@ describe("buildTraitsTriggerDisplay", () => {...@@ -124,32 +194,17 @@ describe("buildTraitsTriggerDisplay", () => {
124 ...descriptor,194 ...descriptor,
125 options: descriptor.options.filter(({ id }) => id === "default" || id === "flex"),195 options: descriptor.options.filter(({ id }) => id === "default" || id === "flex"),
126 };196 };
127 expect(display([EFFORT, nonSpeedDescriptor])).toEqual({197 expect(display([EFFORT, nonSpeedDescriptor])).toEqual({ label: "High · Standard" });
128 label: "High · Standard",198 expect(display([nonSpeedDescriptor])).toEqual({ label: "Standard" });
129 speedIcon: null,
130 });
131 expect(display([nonSpeedDescriptor])).toEqual({
132 label: "Standard",
133 speedIcon: null,
134 });
135 });199 });
136200
137 it("keeps the Codex service tier readable when it is the only trait", () => {201 it("keeps the Codex service tier readable when it is the only trait", () => {
138 expect(display([serviceTierDescriptor("default")])).toEqual({202 expect(display([serviceTierDescriptor("default")])).toEqual({ label: "Standard" });
139 label: "Standard",203 expect(display([serviceTierDescriptor("priority")])).toEqual({ label: "Fast" });
140 speedIcon: null,
141 });
142 expect(display([serviceTierDescriptor("priority")])).toEqual({
143 label: "Fast",
144 speedIcon: null,
145 });
146 });204 });
147205
148 it("keeps Ultrafast readable when it is the only trait", () => {206 it("keeps Ultrafast readable when it is the only trait", () => {
149 expect(display([serviceTierDescriptor("ultrafast")])).toEqual({207 expect(display([serviceTierDescriptor("ultrafast")])).toEqual({ label: "Ultrafast" });
150 label: "Ultrafast",
151 speedIcon: null,
152 });
153 });208 });
154209
155 it("keeps non-fastMode booleans as text labels", () => {210 it("keeps non-fastMode booleans as text labels", () => {
...@@ -159,21 +214,16 @@ describe("buildTraitsTriggerDisplay", () => {...@@ -159,21 +214,16 @@ describe("buildTraitsTriggerDisplay", () => {
159 type: "boolean",214 type: "boolean",
160 currentValue: true,215 currentValue: true,
161 };216 };
162 expect(display([EFFORT, thinking])).toEqual({217 expect(display([EFFORT, thinking])).toEqual({ label: "High · Thinking On" });
163 label: "High · Thinking On",
164 speedIcon: null,
165 });
166 });218 });
167219
168 it("falls back to a text label when fast mode is the only trait", () => {220 it("falls back to a text label when fast mode is the only trait", () => {
169 expect(display([fastModeDescriptor(true)])).toEqual({221 expect(display([fastModeDescriptor(true)])).toEqual({ label: "Fast" });
170 label: "Fast",222 expect(display([fastModeDescriptor(false)])).toEqual({ label: "Normal" });
171 speedIcon: null,223 });
172 });224
173 expect(display([fastModeDescriptor(false)])).toEqual({225 it("does not add Fast to a model without a speed option", () => {
174 label: "Normal",226 expect(display([EFFORT, CONTEXT_WINDOW])).toEqual({ label: "High · 1M" });
175 speedIcon: null,
176 });
177 });227 });
178228
179 it("stays blank when descriptors resolve to no label and there is no fast mode", () => {229 it("stays blank when descriptors resolve to no label and there is no fast mode", () => {
...@@ -189,10 +239,10 @@ describe("buildTraitsTriggerDisplay", () => {...@@ -189,10 +239,10 @@ describe("buildTraitsTriggerDisplay", () => {
189 { id: "high", label: "High" },239 { id: "high", label: "High" },
190 ],240 ],
191 };241 };
192 expect(display([unresolved])).toEqual({ label: "", speedIcon: null });242 expect(display([unresolved])).toEqual({ label: "" });
193 });243 });
194244
195 it("still renders the prompt-controlled ultrathink label alongside the bolt", () => {245 it("still renders prompt-controlled ultrathink with Fast", () => {
196 expect(246 expect(
197 buildTraitsTriggerDisplay({247 buildTraitsTriggerDisplay({
198 provider: CODEX,248 provider: CODEX,
...@@ -200,7 +250,7 @@ describe("buildTraitsTriggerDisplay", () => {...@@ -200,7 +250,7 @@ describe("buildTraitsTriggerDisplay", () => {
200 primarySelectDescriptorId: "reasoningEffort",250 primarySelectDescriptorId: "reasoningEffort",
201 ultrathinkPromptControlled: true,251 ultrathinkPromptControlled: true,
202 }),252 }),
203 ).toEqual({ label: "Ultrathink", speedIcon: "fast" });253 ).toEqual({ label: "Ultrathink Fast" });
204 });254 });
205});255});
206256
apps/web/src/components/chat/TraitsPicker.tsx+32-54
...@@ -17,8 +17,7 @@ import {...@@ -17,8 +17,7 @@ import {
17 normalizeModelSlug,17 normalizeModelSlug,
18} from "@t3tools/shared/model";18} from "@t3tools/shared/model";
19import { memo, useCallback } from "react";19import { memo, useCallback } from "react";
20import { BrainIcon, ZapIcon } from "lucide-react";20import { BrainIcon } from "lucide-react";
21import { UltrafastIcon } from "../Icons";
22import {21import {
23 Menu,22 Menu,
24 MenuGroup,23 MenuGroup,
...@@ -485,10 +484,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -485,10 +484,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
485 );484 );
486});485});
487486
488/**487/** Pair speed with reasoning while keeping other traits separated. */
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 */
492export function buildTraitsTriggerDisplay(input: {488export function buildTraitsTriggerDisplay(input: {
493 provider: ProviderDriverKind;489 provider: ProviderDriverKind;
494 descriptors: ReadonlyArray<ProviderOptionDescriptor>;490 descriptors: ReadonlyArray<ProviderOptionDescriptor>;
...@@ -496,14 +492,15 @@ export function buildTraitsTriggerDisplay(input: {...@@ -496,14 +492,15 @@ export function buildTraitsTriggerDisplay(input: {
496 ultrathinkPromptControlled: boolean;492 ultrathinkPromptControlled: boolean;
497 modelSelection?: ModelSelection | null;493 modelSelection?: ModelSelection | null;
498 reportedModelSelection?: ModelSelection | null | undefined;494 reportedModelSelection?: ModelSelection | null | undefined;
499}): { label: string; speedIcon: "fast" | "ultrafast" | null } {495}): { label: string } {
500 let fastModeFallbackLabel: string | null = null;496 let fastModeFallbackLabel: string | null = null;
501 let speedIcon: "fast" | "ultrafast" | null = null;497 let speedLabel: string | null = null;
498 let reasoningLabelIndex = -1;
502 const labels: Array<string> = [];499 const labels: Array<string> = [];
503 for (const descriptor of input.descriptors) {500 for (const descriptor of input.descriptors) {
504 if (descriptor.id === "fastMode" && descriptor.type === "boolean") {501 if (descriptor.id === "fastMode" && descriptor.type === "boolean") {
505 speedIcon = descriptor.currentValue === true ? "fast" : null;502 speedLabel = descriptor.currentValue === true ? "Fast" : null;
506 fastModeFallbackLabel = speedIcon ? "Fast" : "Normal";503 fastModeFallbackLabel = speedLabel ?? "Normal";
507 continue;504 continue;
508 }505 }
509 if (506 if (
...@@ -519,11 +516,11 @@ export function buildTraitsTriggerDisplay(input: {...@@ -519,11 +516,11 @@ export function buildTraitsTriggerDisplay(input: {
519 (fastTier && currentValue === fastTier.id) ||516 (fastTier && currentValue === fastTier.id) ||
520 (ultrafastTier && currentValue === ultrafastTier.id)517 (ultrafastTier && currentValue === ultrafastTier.id)
521 ) {518 ) {
522 speedIcon =519 speedLabel =
523 ultrafastTier && currentValue === ultrafastTier.id520 ultrafastTier && currentValue === ultrafastTier.id
524 ? "ultrafast"521 ? "Ultrafast"
525 : fastTier && currentValue === fastTier.id522 : fastTier && currentValue === fastTier.id
526 ? "fast"523 ? "Fast"
527 : null;524 : null;
528 fastModeFallbackLabel =525 fastModeFallbackLabel =
529 descriptor.options.find(({ id }) => id === currentValue)?.label ?? "Normal";526 descriptor.options.find(({ id }) => id === currentValue)?.label ?? "Normal";
...@@ -541,6 +538,14 @@ export function buildTraitsTriggerDisplay(input: {...@@ -541,6 +538,14 @@ export function buildTraitsTriggerDisplay(input: {
541 input.reportedModelSelection,538 input.reportedModelSelection,
542 );539 );
543 if (typeof label === "string" && label.length > 0) {540 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 }
544 labels.push(label);549 labels.push(label);
545 }550 }
546 }551 }
...@@ -549,9 +554,16 @@ export function buildTraitsTriggerDisplay(input: {...@@ -549,9 +554,16 @@ export function buildTraitsTriggerDisplay(input: {
549 // off an empty label list alone would also catch descriptors that resolved to554 // off an empty label list alone would also catch descriptors that resolved to
550 // no label at all, printing a bogus "Normal" for a model without fast mode.555 // no label at all, printing a bogus "Normal" for a model without fast mode.
551 if (labels.length === 0 && fastModeFallbackLabel !== null) {556 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 }
553 }565 }
554 return { label: labels.join(" · "), speedIcon };566 return { label: labels.join(" · ") };
555}567}
556568
557export const TraitsPicker = memo(function TraitsPicker({569export const TraitsPicker = memo(function TraitsPicker({
...@@ -601,7 +613,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -601,7 +613,7 @@ export const TraitsPicker = memo(function TraitsPicker({
601 return null;613 return null;
602 }614 }
603615
604 const { label: triggerLabel, speedIcon } = buildTraitsTriggerDisplay({616 const { label: triggerLabel } = buildTraitsTriggerDisplay({
605 provider,617 provider,
606 descriptors,618 descriptors,
607 primarySelectDescriptorId: primarySelectDescriptor?.id ?? null,619 primarySelectDescriptorId: primarySelectDescriptor?.id ?? null,
...@@ -609,26 +621,6 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -609,26 +621,6 @@ export const TraitsPicker = memo(function TraitsPicker({
609 modelSelection: instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null,621 modelSelection: instanceId && model ? { instanceId, model, options: modelOptions ?? [] } : null,
610 reportedModelSelection,622 reportedModelSelection,
611 });623 });
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
632 const isCodexStyle = provider === "codex";624 const isCodexStyle = provider === "codex";
633625
634 return (626 return (
...@@ -644,7 +636,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -644,7 +636,7 @@ export const TraitsPicker = memo(function TraitsPicker({
644 <MenuTrigger636 <MenuTrigger
645 render={637 render={
646 <ComposerControl638 <ComposerControl
647 aria-label={accessibleLabel}639 aria-label={triggerLabel}
648 data-composer-shortcut={isComposerOwned ? "composer.effort" : undefined}640 data-composer-shortcut={isComposerOwned ? "composer.effort" : undefined}
649 size={size}641 size={size}
650 className={cn(642 className={cn(
...@@ -667,14 +659,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -667,14 +659,7 @@ export const TraitsPicker = memo(function TraitsPicker({
667 size === "xs" ? "gap-1" : "gap-1.5",659 size === "xs" ? "gap-1" : "gap-1.5",
668 )}660 )}
669 >661 >
670 {fastModeIcon ?? (662 <ComposerControlIcon icon={BrainIcon} size={size} />
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 )}
678 <span data-composer-control-label className="min-w-0 truncate">663 <span data-composer-control-label className="min-w-0 truncate">
679 {triggerLabel}664 {triggerLabel}
680 </span>665 </span>
...@@ -682,20 +667,13 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -682,20 +667,13 @@ export const TraitsPicker = memo(function TraitsPicker({
682 </span>667 </span>
683 ) : (668 ) : (
684 <>669 <>
685 {fastModeIcon ?? (670 <ComposerControlIcon icon={BrainIcon} size={size} />
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 )}
693 <span data-composer-control-label>{triggerLabel}</span>671 <span data-composer-control-label>{triggerLabel}</span>
694 <ComposerControlChevron size={size} />672 <ComposerControlChevron size={size} />
695 </>673 </>
696 )}674 )}
697 </TooltipTrigger>675 </TooltipTrigger>
698 <TooltipPopup side="top">{accessibleLabel}</TooltipPopup>676 <TooltipPopup side="top">{triggerLabel}</TooltipPopup>
699 </Tooltip>677 </Tooltip>
700 <MenuPopup align="start" {...(isComposerOwned ? composerFloatingLayerProps : {})}>678 <MenuPopup align="start" {...(isComposerOwned ? composerFloatingLayerProps : {})}>
701 <TraitsMenuContent679 <TraitsMenuContent