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,