| author | |
| committer | |
| log | 5719e8ac4020dda0e375ef61d044b61f55a0df8a |
| tree | 4146a02f42bcd343a16ca085e112c65d0eb970e9 |
| parent | 38cfc25e5422e468303f2010f639cf3de9ad89ba |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import type { ProviderOptionDescriptor } from "@t3tools/contracts"; | |
| 3 | import { buildTraitsTriggerDisplay } from "./TraitsPicker"; | |
| 4 | ||
| 5 | function 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 | ||
| 13 | function fastModeDescriptor( | |
| 14 | currentValue: boolean, | |
| 15 | ): Extract<ProviderOptionDescriptor, { type: "boolean" }> { | |
| 16 | return { id: "fastMode", label: "Fast Mode", type: "boolean", currentValue }; | |
| 17 | } | |
| 18 | ||
| 19 | const EFFORT = selectDescriptor( | |
| 20 | "effort", | |
| 21 | [ | |
| 22 | { id: "high", label: "High" }, | |
| 23 | { id: "max", label: "Max" }, | |
| 24 | ], | |
| 25 | "high", | |
| 26 | ); | |
| 27 | const CONTEXT_WINDOW = selectDescriptor( | |
| 28 | "contextWindow", | |
| 29 | [ | |
| 30 | { id: "200k", label: "200k" }, | |
| 31 | { id: "1m", label: "1M" }, | |
| 32 | ], | |
| 33 | "1m", | |
| 34 | ); | |
| 35 | ||
| 36 | function display(descriptors: ReadonlyArray<ProviderOptionDescriptor>, fastModeEnabled: boolean) { | |
| 37 | return buildTraitsTriggerDisplay({ | |
| 38 | descriptors, | |
| 39 | primarySelectDescriptorId: "effort", | |
| 40 | ultrathinkPromptControlled: false, | |
| 41 | fastModeEnabled, | |
| 42 | }); | |
| 43 | } | |
| 44 | ||
| 45 | describe("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 { |
| 16 | 16 | } from "@t3tools/shared/model"; |
| 17 | 17 | import { memo, useCallback, useState } from "react"; |
| 18 | 18 | import type { VariantProps } from "class-variance-authority"; |
| 19 | import { ChevronDownIcon } from "lucide-react"; | |
| 19 | import { ChevronDownIcon, ZapIcon } from "lucide-react"; | |
| 20 | 20 | import { Button, buttonVariants } from "../ui/button"; |
| 21 | 21 | import { |
| 22 | 22 | Menu, |
| ... | ... | @@ -379,6 +379,46 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 379 | 379 | ); |
| 380 | 380 | }); |
| 381 | 381 | |
| 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 | */ | |
| 389 | export 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 | ||
| 382 | 422 | export const TraitsPicker = memo(function TraitsPicker({ |
| 383 | 423 | provider, |
| 384 | 424 | instanceId, |
| ... | ... | @@ -393,7 +433,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 393 | 433 | ...persistence |
| 394 | 434 | }: TraitsMenuContentProps & TraitsPersistence) { |
| 395 | 435 | const [isMenuOpen, setIsMenuOpen] = useState(false); |
| 396 | const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = | |
| 436 | const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled, fastModeEnabled } = | |
| 397 | 437 | getTraitsSectionVisibility({ |
| 398 | 438 | provider, |
| 399 | 439 | models, |
| ... | ... | @@ -415,23 +455,18 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 415 | 455 | return null; |
| 416 | 456 | } |
| 417 | 457 | |
| 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; | |
| 435 | 470 | |
| 436 | 471 | const isCodexStyle = provider === "codex"; |
| 437 | 472 | |
| ... | ... | @@ -457,12 +492,14 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 457 | 492 | } |
| 458 | 493 | > |
| 459 | 494 | {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> | |
| 462 | 498 | <ChevronDownIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" /> |
| 463 | 499 | </span> |
| 464 | 500 | ) : ( |
| 465 | 501 | <> |
| 502 | {fastModeIcon} | |
| 466 | 503 | <span>{triggerLabel}</span> |
| 467 | 504 | <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" /> |
| 468 | 505 | </> |