From 95c66a097ec3a76441bf718c2a4c8bcbcb7c03e4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 16:08:40 -0700 Subject: [PATCH] fix(web): model picker stays put with long names and agent options ACP agents such as Devin and Cline put every option (session mode, provider, speed, auto-approve) into the effort column and the trigger, and long model names stretched the trigger. Devin's Thinking was not treated as effort, the popup resized between models, and Grok listed its levels from strongest to weakest. - ACP thought_level options carry role "reasoning", so clients treat them as the effort control. - The effort column lists only the reasoning levels. Other options are one row each that opens its choices beside the column; two-state options are a switch. - The trigger shows the model, effort, Fast and context window only, with a capped model name. - The docked popup has one fixed height that shrinks only for short windows, and the ChatGPT plan row sits inside it. - Grok levels are sorted from lowest to highest. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/AcpRegistryProbe.test.ts | 1 + apps/server/src/provider/GrokProvider.test.ts | 4 +- .../chat/ComposerModelEffort.browser.test.tsx | 125 +++++++ .../components/chat/ComposerModelEffort.tsx | 350 ++++++++++++++---- .../src/components/chat/EffortGlyph.test.ts | 4 +- apps/web/src/components/chat/EffortGlyph.tsx | 4 +- apps/web/src/components/chat/ModelListRow.tsx | 9 +- .../components/chat/ModelPickerContent.tsx | 20 +- .../components/chat/ProviderModelPicker.tsx | 47 ++- apps/web/src/components/chat/TraitsPicker.tsx | 6 +- .../restingComposerControlsMeasurement.ts | 12 +- .../settings/ProviderModelsSection.tsx | 6 +- apps/web/src/components/ui/menu.tsx | 3 + packages/client-runtime/src/modelTraits.ts | 29 +- packages/contracts/src/model.ts | 2 + .../src/server/sessionConfig.test.ts | 1 + .../provider-acp/src/server/sessionConfig.ts | 1 + packages/provider-grok/src/server/status.ts | 7 + 18 files changed, 506 insertions(+), 125 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx diff --git a/apps/server/src/provider/AcpRegistryProbe.test.ts b/apps/server/src/provider/AcpRegistryProbe.test.ts index bc7c14d3b3c8a..e6835159f9e7f 100644 --- a/apps/server/src/provider/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/AcpRegistryProbe.test.ts @@ -229,6 +229,7 @@ describe("ACP Registry probe", () => { { id: "medium", label: "Medium" }, { id: "high", label: "High" }, ], + role: "reasoning", }, ]); }); diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index 4ecc0ad28971b..8139df621f98c 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -129,9 +129,9 @@ describe("buildGrokModelCapabilities", () => { type: "select", currentValue: "medium", options: [ - { id: "xhigh", label: "Extra High Effort", isDefault: true }, - { id: "high", label: "High Effort" }, { id: "medium", label: "Medium Effort" }, + { id: "high", label: "High Effort" }, + { id: "xhigh", label: "Extra High Effort", isDefault: true }, ], }, ]); diff --git a/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx b/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx new file mode 100644 index 0000000000000..3d704b5209305 --- /dev/null +++ b/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx @@ -0,0 +1,125 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + type ProviderOptionSelection, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { useState } from "react"; +import { expect, it } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { render } from "../../../test/render"; +import { ComposerEffortColumn, describeComposerEffort } from "./ComposerModelEffort"; +import type { TraitsSelectionInput } from "./TraitsPicker"; + +// Shaped like Devin over ACP: its session mode comes first, and its reasoning id is its own. +const models: ReadonlyArray = [ + { + slug: "fusion", + name: "Fusion (Claude Opus 5.5 Medium + SWE-2 High)", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { + id: "mode", + label: "Session Mode", + type: "select", + currentValue: "bypass", + options: [ + { id: "code", label: "Code" }, + { id: "plan", label: "Plan" }, + { id: "bypass", label: "Bypass Permissions" }, + ], + }, + { + id: "thought_level", + label: "Thinking", + type: "select", + role: "reasoning", + currentValue: "medium", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + }, + { + id: "speed", + label: "Speed", + type: "select", + currentValue: "standard", + options: [ + { id: "standard", label: "Standard" }, + { id: "fast", label: "Fast" }, + ], + }, + { id: "auto_approve", label: "Auto-approve tools", type: "boolean", currentValue: false }, + ], + }), + }, +]; + +function traitsInput( + modelOptions: ReadonlyArray, + onModelOptionsChange: (next: ReadonlyArray | undefined) => void, +): TraitsSelectionInput { + return { + provider: ProviderDriverKind.make("acpRegistry"), + instanceId: ProviderInstanceId.make("devin"), + models, + model: "fusion", + modelOptions, + planModeEnabled: false, + onModelOptionsChange, + }; +} + +function Column() { + const [options, setOptions] = useState>([]); + const traits = traitsInput(options, (next) => setOptions(next ?? [])); + return ( + <> +
+ +
+

{describeComposerEffort(traits, "bars", "sm").suffix}

+ + ); +} + +it("draws an agent's reasoning as bars and keeps its mode out of the levels", async () => { + await using screen = await render(, { router: false }); // no routes involved + const status = screen.getByRole("status"); + expect(status.element().querySelector("[data-composer-effort-glyph]")).not.toBeNull(); + await expect.element(status).toHaveTextContent(""); + + const levels = screen.getByRole("radiogroup", { name: "Thinking" }); + await expect.element(levels.getByRole("radio")).toHaveLength(3); + await expect.element(screen.getByRole("radio", { name: "Plan" })).not.toBeInTheDocument(); + + // Up and Down walk from the levels through the option rows without opening their menus. + await userEvent.tab(); + await expect.element(screen.getByRole("radio", { name: "Medium" })).toHaveFocus(); + await userEvent.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}"); + const speed = screen.getByRole("button", { name: "Speed: Standard" }); + await expect.element(speed).toHaveFocus(); + await expect.element(page.getByRole("menuitemradio")).not.toBeInTheDocument(); + await userEvent.keyboard("{ArrowRight}"); + await expect.element(page.getByRole("menuitemradio", { name: "Standard" })).toHaveFocus(); + await userEvent.keyboard("{ArrowLeft}"); + await expect.element(page.getByRole("menuitemradio")).not.toBeInTheDocument(); + await expect.element(speed).toHaveFocus(); + + await userEvent.click(screen.getByText("Auto-approve tools")); + await expect + .element(screen.getByRole("switch", { name: "Auto-approve tools" })) + .toHaveAttribute("aria-checked", "true"); + + await userEvent.click(screen.getByRole("button", { name: "Session Mode: Bypass Permissions" })); + await userEvent.click(page.getByRole("menuitemradio", { name: "Plan" })); + await expect + .element(screen.getByRole("button", { name: "Session Mode: Plan" })) + .toBeInTheDocument(); + await expect.element(page.getByRole("menuitemradio")).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/chat/ComposerModelEffort.tsx b/apps/web/src/components/chat/ComposerModelEffort.tsx index 5ce77c60b1078..3a863936101b2 100644 --- a/apps/web/src/components/chat/ComposerModelEffort.tsx +++ b/apps/web/src/components/chat/ComposerModelEffort.tsx @@ -1,16 +1,25 @@ import { buildTraitsTriggerDisplay, getDescriptorStringValue, - REASONING_SELECT_DESCRIPTOR_IDS, + isReasoningControl, selectableProviderOptionChoices, } from "@t3tools/client-runtime/model-traits"; import type { ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; import type { EffortDisplay } from "@t3tools/contracts/settings"; -import { CheckIcon, ChevronUpIcon } from "lucide-react"; -import { type KeyboardEvent, type ReactNode, use, useRef } from "react"; +import { getProviderOptionCurrentLabel } from "@t3tools/shared/model"; +import { CheckIcon, ChevronRightIcon, ChevronUpIcon } from "lucide-react"; +import { type KeyboardEvent, type ReactNode, use, useRef, useState } from "react"; import { cn } from "~/lib/utils"; -import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; +import { + Menu, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuRadioItemIndicator, + MenuTrigger, +} from "../ui/menu"; +import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { ComposerControlSize } from "./ComposerControl"; import { ComposerPermissionsSection, type RuntimeModeOption } from "./ComposerFooterControls"; @@ -51,8 +60,8 @@ type ColumnControl = | { kind: "boolean"; descriptor: BooleanDescriptor }; /** - * The column lists the model's reasoning control first, under the header that names it, then - * its other options. + * The column lists the model's reasoning levels under the header that names it. Other options, + * such as an agent's session mode, sit below as one row each. */ function columnControls( provider: TraitsSelectionInput["provider"], @@ -64,8 +73,7 @@ function columnControls( ...columnSelects.map((descriptor) => ({ kind: "select" as const, descriptor })), ...columnToggles.map((descriptor) => ({ kind: "boolean" as const, descriptor })), ]; - const effort = - controls.find((control) => REASONING_SELECT_DESCRIPTOR_IDS.has(control.descriptor.id)) ?? null; + const effort = controls.find((control) => isReasoningControl(control.descriptor)) ?? null; return { effort, others: controls.filter((control) => control !== effort) }; } @@ -94,20 +102,40 @@ export function describeComposerEffort( modelSelection, reportedModelSelection: traits.reportedModelSelection, }); + // The same reasoning control the column lists, wherever the agent put it among its options. + const reasoning = + columnControls(traits.provider, visibility.selectDescriptors, visibility.booleanDescriptors) + .effort?.descriptor ?? null; + const ultrathink = visibility.ultrathinkPromptControlled && reasoning === primary; const glyph = - primary && REASONING_SELECT_DESCRIPTOR_IDS.has(primary.id) + reasoning?.type === "select" ? resolveEffortGlyph( - primary, - visibility.ultrathinkPromptControlled + reasoning, + ultrathink ? "ultrathink" - : getDescriptorStringValue(primary, modelSelection, traits.reportedModelSelection), + : getDescriptorStringValue(reasoning, modelSelection, traits.reportedModelSelection), ) : null; - // Without a reasoning select the whole label is the word; it already names speed and the rest. // Workflow levels such as Ultracode always show their name; the setting names every level. const word = glyph ? (glyph.special ?? (effortDisplay === "label" ? glyph.label : null)) - : display.label; + : !reasoning + ? null + : reasoning.type === "boolean" + ? `${reasoning.label} ${reasoning.currentValue === true ? "On" : "Off"}` + : ultrathink + ? "Ultrathink" + : getProviderOptionCurrentLabel(reasoning, modelSelection, traits.reportedModelSelection); + const fast = display.speedLabel === "Fast" || display.speedLabel === "Ultrafast"; + // Agent options such as modes live in the picker; the trigger adds only the context window. + const contextWindow = + visibility.contextWindowDescriptor && + getProviderOptionCurrentLabel( + visibility.contextWindowDescriptor, + modelSelection, + traits.reportedModelSelection, + ); + if (!word && !glyph && !fast && !contextWindow) return { suffix: null, detail: display.label }; return { detail: display.label, suffix: ( @@ -118,22 +146,22 @@ export function describeComposerEffort( size === "sm" && "-ms-0.5", )} > - {word !== null ? ( - {word} + {word ? ( + + {word} + ) : glyph ? ( ) : null} - {glyph && (display.speedLabel === "Fast" || display.speedLabel === "Ultrafast") ? ( - - ) : null} - {glyph && display.otherLabels.length > 0 ? ( + {fast ? : null} + {contextWindow ? ( - {display.otherLabels.join(" · ")} + {contextWindow} ) : null} @@ -150,10 +178,10 @@ function ColumnRow(props: { /** Shown on hover; the column is too narrow to print it under the label. */ description?: string | undefined; selected: boolean; + /** The column's one tab stop: the selected level, else the first when none shows as selected. */ + tabStop: boolean; isDefault?: boolean | undefined; disabled?: boolean; - /** A level of the reasoning control, where focus lands after a model pick. */ - level: boolean; onPick: () => void; }) { const rowProps = { @@ -162,9 +190,10 @@ function ColumnRow(props: { "aria-checked": props.selected, disabled: props.disabled, "data-effort-row": true, - "data-effort-level": props.level || undefined, - // The reasoning control holds the column's one tab stop; Up and Down move between rows. - tabIndex: props.selected && props.level ? 0 : -1, + // Focus lands on a level after a model pick. + "data-effort-level": true, + // Up and Down move between rows. + tabIndex: props.tabStop ? 0 : -1, className: COLUMN_ROW, onClick: props.onPick, } as const; @@ -194,6 +223,8 @@ function ColumnRow(props: { function moveBetweenRows(event: KeyboardEvent) { if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + // An option's side menu is a React child here, but it moves through its own items. + if (!event.currentTarget.contains(event.target as Node)) return; const rows = Array.from( event.currentTarget.querySelectorAll("[data-effort-row]:not(:disabled)"), ); @@ -230,66 +261,222 @@ function EffortLevels(props: { traits: TraitsSelectionInput }) { ); } if (!effort && others.length === 0) return ; - const renderControl = (control: ColumnControl) => { - const isEffort = control === effort; - const { descriptor } = control; - if (control.kind === "boolean") { - return ( + // Ultrathink in the prompt locks the levels; the first option then holds the tab stop. + const locked = + effort?.descriptor.id === traits.primarySelectDescriptor?.id && traits.ultrathinkInBodyText; + return ( + <> + {effort ? : null} + {others.length > 0 ? (
- {isEffort ? null :
{descriptor.label}
} - {([true, false] as const).map((value) => ( - traits.changeBoolean(descriptor.id, value)} - /> - ))} + {others.map((control, index) => + control.kind === "boolean" ? ( + + ) : ( + + ), + )}
- ); - } - const locked = - descriptor.id === traits.primarySelectDescriptor?.id && traits.ultrathinkInBodyText; - const value = traits.selectValue(control.descriptor); + ) : null} + + ); +} + +type TraitsSelection = ReturnType; + +function EffortRows(props: { control: ColumnControl; traits: TraitsSelection; locked: boolean }) { + const { control, traits } = props; + const { descriptor } = control; + if (control.kind === "boolean") { return ( -
- {isEffort ? null :
{descriptor.label}
} - {locked ? ( -
- {ULTRATHINK_IN_BODY_NOTE} -
- ) : null} - {selectableProviderOptionChoices(control.descriptor).map((option) => ( +
+ {([true, false] as const).map((value) => ( traits.changeSelect(control.descriptor, option.id)} + key={String(value)} + label={value ? "On" : "Off"} + selected={(control.descriptor.currentValue === true) === value} + tabStop={(control.descriptor.currentValue === true) === value} + onPick={() => traits.changeBoolean(descriptor.id, value)} /> ))}
); - }; + } + const { locked } = props; + const value = traits.selectValue(control.descriptor); + const choices = selectableProviderOptionChoices(control.descriptor); + // An "Ultrathink:" prefix selects a level the column does not offer. + const shown = choices.some((option) => option.id === value); return ( - <> - {effort ? renderControl(effort) : null} - {others.map(renderControl)} - +
+ {locked ? ( +
+ {ULTRATHINK_IN_BODY_NOTE} +
+ ) : null} + {choices.map((option, index) => ( + traits.changeSelect(control.descriptor, option.id)} + /> + ))} +
+ ); +} + +const OPTION_ROW = cn(COLUMN_ROW, "h-auto py-1"); + +/** An option's name over its current value. */ +function OptionRowText(props: { label: string; value: string }) { + return ( + + {props.label} + {props.value} + + ); +} + +/** + * One select besides reasoning, such as an agent's session mode. It names the current choice and + * opens the rest beside the column, so any number of options leaves the levels in place. + */ +function OptionMenuRow(props: { + descriptor: SelectDescriptor; + traits: TraitsSelection; + /** Holds the column's tab stop when the model has no levels. */ + tabStop: boolean; +}) { + const { descriptor, traits } = props; + const [open, setOpen] = useState(false); + const popupRef = useRef(null); + const choices = selectableProviderOptionChoices(descriptor); + const value = traits.selectValue(descriptor); + const current = choices.find((choice) => choice.id === value)?.label ?? ""; + return ( + { + if (!isOpen) return; + const checked = popupRef.current?.querySelector( + '[role="menuitemradio"][aria-checked="true"]', + ); + checked?.focus({ preventScroll: true }); + checked?.scrollIntoView({ block: "nearest" }); + }} + > + {/* No hover opening: an opened menu takes focus, which would steal typing from search. */} + { + if (event.key !== "ArrowRight") return; + event.preventDefault(); + event.stopPropagation(); + setOpen(true); + }} + /> + } + > + + + { + if (event.key !== "ArrowLeft") return; + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + }} + > + { + if (next !== value) traits.changeSelect(descriptor, next); + }} + > + {choices.map((choice) => ( + + + + {choice.label} + {choice.description ? ( + + {choice.description} + + ) : null} + + {choice.isDefault ? ( + Default + ) : null} + + + + ))} + + + + ); +} + +/** A two-state option toggles in place; a menu would add a click for nothing. */ +function OptionSwitchRow(props: { + descriptor: BooleanDescriptor; + traits: TraitsSelection; + tabStop: boolean; +}) { + const { descriptor, traits } = props; + const checked = descriptor.currentValue === true; + return ( + ); } @@ -303,20 +490,22 @@ function NoEffortRow() { export function ComposerEffortColumn(props: { traits: TraitsSelectionInput | null }) { const picker = use(DockedPickerContext); const visibility = props.traits ? getTraitsSectionVisibility(props.traits) : null; - const effort = + const controls = props.traits && visibility ? columnControls( props.traits.provider, visibility.selectDescriptors, visibility.booleanDescriptors, - ).effort + ) : null; return ( <> {/* Matches the search row's box, so one rule runs across the top of the popup. */}
- {effort?.descriptor.label ?? "Effort"} + + {controls?.effort?.descriptor.label ?? (controls?.others.length ? "Options" : "Effort")} + {picker.modelLabel} @@ -325,7 +514,8 @@ export function ComposerEffortColumn(props: { traits: TraitsSelectionInput | nul {/* pt-2.5 puts the first level on the same line as the first model. */}
{props.traits ? : }
diff --git a/apps/web/src/components/chat/EffortGlyph.test.ts b/apps/web/src/components/chat/EffortGlyph.test.ts index 712e4b796ba61..0b73e89e4f833 100644 --- a/apps/web/src/components/chat/EffortGlyph.test.ts +++ b/apps/web/src/components/chat/EffortGlyph.test.ts @@ -45,9 +45,11 @@ describe("resolveEffortGlyph", () => { }); }); - it("falls back to the word when the value is unknown or the scale has one level", () => { + it("falls back to the word when the value is unknown or the scale is too short or long", () => { expect(resolveEffortGlyph(claude, "unknown")).toBeNull(); expect(resolveEffortGlyph(descriptor(["high"]), "high")).toBeNull(); + const levels = Array.from({ length: 8 }, (_, index) => `level${index}`); + expect(resolveEffortGlyph(descriptor(levels), "level0")).toBeNull(); }); }); diff --git a/apps/web/src/components/chat/EffortGlyph.tsx b/apps/web/src/components/chat/EffortGlyph.tsx index 39a46d2564901..7614e41c496a6 100644 --- a/apps/web/src/components/chat/EffortGlyph.tsx +++ b/apps/web/src/components/chat/EffortGlyph.tsx @@ -14,6 +14,8 @@ type SelectDescriptor = Extract; // Max is named too, so the full scale stops short of it. const NAMED_LEVELS = new Set(["ultracode", "max"]); const OFF_LEVELS = new Set(["off", "none"]); +// More bars than this no longer fit the glyph's height in whole pixels. +const MAX_BARS = 7; /** * Maps a reasoning select to bars: one per ordinary level, filled up to the current one, with an @@ -22,7 +24,7 @@ const OFF_LEVELS = new Set(["off", "none"]); export function resolveEffortGlyph(descriptor: SelectDescriptor, current: string | null) { const special = new Set([...(descriptor.promptInjectedValues ?? []), ...NAMED_LEVELS]); const levels = descriptor.options.filter((option) => !special.has(option.id)); - if (levels.length < 2 || current === null) return null; + if (levels.length < 2 || levels.length > MAX_BARS || current === null) return null; const startsAtOff = OFF_LEVELS.has(levels[0]!.id); const count = startsAtOff ? levels.length - 1 : levels.length; if (special.has(current)) { diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 9928530b97f2a..e70c32b064a3e 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -67,7 +67,14 @@ export const ModelListRow = memo(function ModelListRow(props: { badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-5xs" />
-
+
{ + const name = event.currentTarget; + name.title = name.scrollWidth > name.clientWidth ? getDisplayModelName(model) : ""; + }} + > {getDisplayModelName(model)} {/* The logo is decorative, so name the provider for screen readers. */} , {props.providerDisplayName} diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 742a7cf6b09fd..f040df764cda9 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -366,6 +366,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { docked?: boolean; /** Right at the end of the search text moves into the popup's effort column. */ onFocusEffort?: () => void; + /** Closes the list column, inside the popup's fixed height. */ + listFooter?: ReactNode; }) { const { searchInputRef, @@ -838,16 +840,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ref={pickerContentRef} className={cn( "relative flex w-screen max-w-90 flex-row overflow-hidden", - props.docked - ? "max-sm:h-56 max-sm:min-h-0! max-sm:w-full max-sm:max-w-none" - : "max-h-86.5", + props.docked ? "max-sm:h-56 max-sm:w-full max-sm:max-w-none" : "max-h-86.5", )} + // Docked, the popup sets the height; a minimum here would overflow short windows. style={ - isSearching - ? { height: searchHeight } - : { - minHeight: `min(calc(var(--spacing) * ${tallestTabUnits + LIST_CHROME_UNITS} + 1px), calc(var(--spacing) * 86.5))`, - } + props.docked + ? undefined + : isSearching + ? { height: searchHeight } + : { + minHeight: `min(calc(var(--spacing) * ${tallestTabUnits + LIST_CHROME_UNITS} + 1px), calc(var(--spacing) * 86.5))`, + } } data-model-picker-content="true" > @@ -1072,6 +1075,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onRequestClose={onRequestClose} onOpenProviderSetup={onOpenProviderSetup} /> + {props.listFooter}
diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 1680e8d426b0b..848b1ee4532db 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -37,7 +37,9 @@ function lockBackgroundScroll(popup: HTMLElement | null) { const shouldAllowOverlayScroll = (target: EventTarget | null) => { return ( target instanceof Element && - target.closest("[data-model-picker-content], [data-model-picker-effort]") + target.closest( + "[data-model-picker-content], [data-model-picker-effort], [data-model-picker-option-menu]", + ) ); }; const preventBackgroundWheel = (event: WheelEvent) => { @@ -154,16 +156,22 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /** * Focuses the selected reasoning level, else the first level. With `rows: "any"` it falls back - * to the column's other options. False when nothing took focus. + * to the column's first enabled option. False when nothing took focus. */ const focusEffort = (rows: "levels" | "any") => { const column = popupRef.current?.querySelector("[data-model-picker-effort]"); - const first = (selector: string) => - column?.querySelector(`${selector}[aria-checked="true"]:not(:disabled)`) ?? - column?.querySelector(`${selector}:not(:disabled)`); + const level = + column?.querySelector( + '[data-effort-level][aria-checked="true"]:not(:disabled)', + ) ?? column?.querySelector("[data-effort-level]:not(:disabled)"); + // No aria-checked here: an On switch is checked but is not where focus belongs. const row = - first("[data-effort-level]") ?? (rows === "any" ? first("[data-effort-row]") : null); + level ?? + (rows === "any" + ? column?.querySelector("[data-effort-row]:not(:disabled)") + : null); row?.focus({ preventScroll: true }); + row?.scrollIntoView({ block: "nearest" }); return row != null; }; const openTarget = (target: "effort" | "permissions") => { @@ -230,7 +238,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const triggerText = props.triggerLabel ?? multipleLabel ?? triggerTitle; const triggerTextProps = { - className: "min-w-0 flex-1 overflow-hidden truncate", + className: cn( + "min-w-0 flex-1 overflow-hidden truncate", + // Long names such as "Fusion (Claude Opus 5.5 Medium + SWE-2 High)" would push the composer. + props.isComposerOwned && "max-w-56", + ), "data-chat-provider-model-picker-label": "true", } as const; @@ -262,7 +274,16 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { : (props.getModelDisabledReason?.(instanceId, model) ?? null) } onInstanceModelChange={handleInstanceModelChange} - {...(docked ? { docked, onFocusEffort: () => void focusEffort("any") } : {})} + {...(docked + ? { + docked, + onFocusEffort: () => void focusEffort("any"), + // Inside the fixed height, so switching to or from a ChatGPT plan never resizes it. + ...(props.selectedModels === undefined + ? { listFooter: } + : {}), + } + : {})} /> ); @@ -401,12 +422,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { > {props.effortColumn ? ( -
-
+
+ {/* One fixed height, so a model with more options never resizes the popup. Short + windows shrink it and keep the footer. */} +
{picker}
{ if (event.key !== "ArrowLeft" || event.altKey || event.ctrlKey || event.metaKey) return; @@ -424,7 +447,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ) : ( picker )} - {props.selectedModels === undefined ? ( + {props.selectedModels === undefined && !docked ? ( ) : null} diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 4639c0a3ec043..6c723c8bcb4e4 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -4,7 +4,7 @@ import { getTraitSelectChange, replaceDescriptorCurrentValue, selectableProviderOptionChoices, - REASONING_SELECT_DESCRIPTOR_IDS, + isReasoningSelect, } from "@t3tools/client-runtime/model-traits"; import { type ModelSelection, @@ -254,9 +254,7 @@ export function getReasoningTraitStep(input: { input.planModeEnabled, ); if (selected.modelIsUnavailable || selected.ultrathinkInBodyText) return null; - const descriptor = selected.selectDescriptors.find((entry) => - REASONING_SELECT_DESCRIPTOR_IDS.has(entry.id), - ); + const descriptor = selected.selectDescriptors.find(isReasoningSelect); const choices = descriptor ? selectableProviderOptionChoices(descriptor) : []; if (!descriptor || choices.length < 2) return null; const isPrimary = descriptor.id === selected.primarySelectDescriptor?.id; diff --git a/apps/web/src/components/chat/restingComposerControlsMeasurement.ts b/apps/web/src/components/chat/restingComposerControlsMeasurement.ts index 50f6a34148dfb..474df488a6c6b 100644 --- a/apps/web/src/components/chat/restingComposerControlsMeasurement.ts +++ b/apps/web/src/components/chat/restingComposerControlsMeasurement.ts @@ -25,8 +25,18 @@ function providerModelPickerNaturalWidth(picker: HTMLElement): number { const style = getComputedStyle(picker); const label = picker.querySelector('[data-chat-provider-model-picker-label="true"]'); const labelIsCollapsed = label?.clientWidth === 0 && getComputedStyle(label).flexGrow === "0"; + // A label with its own max width, such as a long model name, never grows past it. + const labelMaxWidth = label ? Number.parseFloat(getComputedStyle(label).maxWidth) : Number.NaN; const hiddenLabelWidth = - label && !labelIsCollapsed ? Math.max(0, label.scrollWidth - label.clientWidth) : 0; + label && !labelIsCollapsed + ? Math.max( + 0, + Math.min( + label.scrollWidth, + Number.isFinite(labelMaxWidth) ? labelMaxWidth : Number.POSITIVE_INFINITY, + ) - label.clientWidth, + ) + : 0; const maxWidth = Number.parseFloat(style.maxWidth); const naturalWidth = Math.min( renderedWidth + hiddenLabelWidth, diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 1bb6c9dde0d4a..3e39d6ccfc12b 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -5,7 +5,7 @@ import { type ProviderInstanceId, type ServerProviderModel, } from "@t3tools/contracts"; -import { REASONING_SELECT_DESCRIPTOR_IDS } from "@t3tools/client-runtime/model-traits"; +import { isReasoningControl } from "@t3tools/client-runtime/model-traits"; import { type CustomModelDefinition, normalizeCustomModelSlug } from "@t3tools/shared/model"; import { useStableCallback } from "../../hooks/useStableCallback"; @@ -31,9 +31,7 @@ const FILTER_THRESHOLD = 8; const PROVIDER_ALIAS_SLUGS = new Set(["default", "auto"]); function hasReasoningControl(model: ServerProviderModel): boolean { - return (model.capabilities?.optionDescriptors ?? []).some((descriptor) => - REASONING_SELECT_DESCRIPTOR_IDS.has(descriptor.id), - ); + return (model.capabilities?.optionDescriptors ?? []).some(isReasoningControl); } // Reasoning is the norm, so only its absence is labeled, and only when other models have it. diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 47ae92c241e15..1c199afe446f5 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -25,6 +25,7 @@ function MenuPopup({ align = "center", alignOffset, side = "bottom", + collisionAvoidance, anchor, keepMounted = false, width = "default", @@ -34,6 +35,7 @@ function MenuPopup({ sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"]; alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"]; side?: MenuPrimitive.Positioner.Props["side"]; + collisionAvoidance?: MenuPrimitive.Positioner.Props["collisionAvoidance"]; anchor?: MenuPrimitive.Positioner.Props["anchor"]; keepMounted?: boolean; width?: "default" | "content"; @@ -44,6 +46,7 @@ function MenuPopup({ align={align} alignOffset={alignOffset} anchor={anchor} + collisionAvoidance={collisionAvoidance} className="z-[130]" data-slot="menu-positioner" render={renderClosingPositioner} diff --git a/packages/client-runtime/src/modelTraits.ts b/packages/client-runtime/src/modelTraits.ts index 23ada4ea11c46..b05f9226defb3 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -11,11 +11,25 @@ import { } from "@t3tools/shared/model"; import { REASONING_EFFORT_OPTION_IDS } from "./state/threadExecution.ts"; -export const REASONING_SELECT_DESCRIPTOR_IDS: ReadonlySet = new Set([ +const REASONING_DESCRIPTOR_IDS: ReadonlySet = new Set([ ...REASONING_EFFORT_OPTION_IDS, "thinking", ]); +/** The model's reasoning control: a known effort id, or a select its agent marked as reasoning. */ +export function isReasoningControl(descriptor: ProviderOptionDescriptor): boolean { + return ( + (descriptor.type === "select" && descriptor.role === "reasoning") || + REASONING_DESCRIPTOR_IDS.has(descriptor.id) + ); +} + +export function isReasoningSelect( + descriptor: ProviderOptionDescriptor, +): descriptor is Extract { + return descriptor.type === "select" && isReasoningControl(descriptor); +} + export function replaceDescriptorCurrentValue( descriptors: ReadonlyArray, descriptorId: string, @@ -106,8 +120,6 @@ export function buildTraitsTriggerDisplay(input: { label: string; /** "Fast" or "Ultrafast" when a speed option is on. */ speedLabel: string | null; - /** Labels other than reasoning and speed, such as context window or thinking. */ - otherLabels: ReadonlyArray; } { let fastModeFallbackLabel: string | null = null; let speedLabel: string | null = null; @@ -154,20 +166,15 @@ export function buildTraitsTriggerDisplay(input: { input.reportedModelSelection, ); if (typeof label === "string" && label.length > 0) { - if ( - reasoningLabelIndex === -1 && - descriptor.type === "select" && - REASONING_SELECT_DESCRIPTOR_IDS.has(descriptor.id) - ) { + if (reasoningLabelIndex === -1 && isReasoningSelect(descriptor)) { reasoningLabelIndex = labels.length; } labels.push(label); } } - const otherLabels = labels.filter((_, index) => index !== reasoningLabelIndex); if (labels.length === 0 && fastModeFallbackLabel !== null) { - return { label: fastModeFallbackLabel, speedLabel, otherLabels }; + return { label: fastModeFallbackLabel, speedLabel }; } if (speedLabel) { if (reasoningLabelIndex >= 0) { @@ -176,5 +183,5 @@ export function buildTraitsTriggerDisplay(input: { labels.push(speedLabel); } } - return { label: labels.join(" · "), speedLabel, otherLabels }; + return { label: labels.join(" · "), speedLabel }; } diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 87990e7f542f7..1a04cc61c887a 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -27,6 +27,8 @@ export const SelectProviderOptionDescriptor = Schema.Struct({ options: Schema.Array(ProviderOptionChoice), currentValue: Schema.optional(TrimmedNonEmptyString), promptInjectedValues: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + /** Marks the reasoning control when its id is an agent's own, such as an ACP `thought_level`. */ + role: Schema.optional(Schema.Literal("reasoning")), }); export type SelectProviderOptionDescriptor = typeof SelectProviderOptionDescriptor.Type; diff --git a/packages/provider-acp/src/server/sessionConfig.test.ts b/packages/provider-acp/src/server/sessionConfig.test.ts index 308ba1db02ccf..ceb6ceb92783a 100644 --- a/packages/provider-acp/src/server/sessionConfig.test.ts +++ b/packages/provider-acp/src/server/sessionConfig.test.ts @@ -50,6 +50,7 @@ describe("acpProviderOptionDescriptors", () => { { id: "medium", label: "Medium", description: "Balanced" }, ], currentValue: "medium", + role: "reasoning", }, ]); }); diff --git a/packages/provider-acp/src/server/sessionConfig.ts b/packages/provider-acp/src/server/sessionConfig.ts index 46269bb5e0d13..b79fdaebe245d 100644 --- a/packages/provider-acp/src/server/sessionConfig.ts +++ b/packages/provider-acp/src/server/sessionConfig.ts @@ -102,6 +102,7 @@ export function acpProviderOptionDescriptors(input: { ...(currentValue && choices.some((choice) => choice.id === currentValue) ? { currentValue } : {}), + ...(option.category === "thought_level" ? { role: "reasoning" as const } : {}), }); if (descriptors.length === MAX_OPTION_DESCRIPTORS) return descriptors; } diff --git a/packages/provider-grok/src/server/status.ts b/packages/provider-grok/src/server/status.ts index c634c5b80c74e..67908d12385e7 100644 --- a/packages/provider-grok/src/server/status.ts +++ b/packages/provider-grok/src/server/status.ts @@ -126,6 +126,8 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +const GROK_EFFORT_RANK = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]; + function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): { readonly options: ReadonlyArray<{ value: string; @@ -183,6 +185,11 @@ function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): { const selectedDefault = advertisedDefaults.find((option) => option.value === currentValue)?.value ?? advertisedDefaults[0]?.value; + // Grok advertises its strongest level first; pickers and effort bars read lowest to highest. + const rank = (option: { value: string }) => GROK_EFFORT_RANK.indexOf(option.value); + if (options.every((option) => rank(option) >= 0)) { + options.sort((left, right) => rank(left) - rank(right)); + } return { options: options.map(({ value, label, description }) => ({ value,