From 40a700d8dffcb42ddf2af90520c91e28540ce333 Mon Sep 17 00:00:00 2001 From: RCXDd Date: Sat, 31 Oct 2026 21:14:54 +0000 Subject: [PATCH 1/2] fix(web): mark the configured default effort as Default in the model picker The composer's effort column tagged the provider's default level as "Default", even when the default model in Settings sets another level. New threads start with the configured level, so the badge pointed at a level they never get, and users switched to it by hand on every thread. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/ChatComposer.tsx | 15 +++++- .../components/chat/ComposerControlsBar.tsx | 7 ++- .../chat/ComposerModelEffort.browser.test.tsx | 2 +- .../components/chat/ComposerModelEffort.tsx | 47 +++++++++++++++---- .../client-runtime/src/modelTraits.test.ts | 41 ++++++++++++++++ packages/client-runtime/src/modelTraits.ts | 26 ++++++++++ 6 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 packages/client-runtime/src/modelTraits.test.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 82b6b9617c1b0..c2a08fb6e096a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -233,7 +233,11 @@ import { toastManager } from "../ui/toast"; import { PlayIcon } from "lucide-react"; import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText"; import { hasProviderSetup } from "./ProviderStatusBanner"; -import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; +import { + type AppModelOption, + getAppModelOptionsForInstance, + resolveEffectiveDefaultModelSelection, +} from "../../modelSelection"; import type { UnifiedSettings } from "@t3tools/contracts/settings"; import { isVideoAttachment, @@ -953,6 +957,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModel, selectedModelOptionsForDispatch, ); + // Resolved like the composer's own model, so a stored alias still matches the model it names. + const defaultModelSelection = activeProjectDefaultModelSelection + ? resolveEffectiveDefaultModelSelection( + settings, + providerStatuses, + activeProjectDefaultModelSelection, + ) + : null; const selectedModelForPicker = selectedModel; const modelOptionsByInstance = new Map>(); for (const entry of providerInstanceEntries) { @@ -2334,6 +2346,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModelSelection={selectedModelSelection} onProviderModelSelect={onProviderModelSelect} activeThreadModelSelection={activeThreadModelSelection} + defaultModelSelection={defaultModelSelection} selectedModelForPickerWithCustomFallback={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} lockedContinuationGroupKey={lockedContinuationGroupKey} diff --git a/apps/web/src/components/chat/ComposerControlsBar.tsx b/apps/web/src/components/chat/ComposerControlsBar.tsx index 0002beeaf9fef..eb0a517765eb7 100644 --- a/apps/web/src/components/chat/ComposerControlsBar.tsx +++ b/apps/web/src/components/chat/ComposerControlsBar.tsx @@ -77,6 +77,8 @@ export function ComposerControlsBar(props: { selectedModelSelection: ModelSelection; onProviderModelSelect: ChatComposerProps["onProviderModelSelect"]; activeThreadModelSelection: ChatComposerProps["activeThreadModelSelection"]; + /** The project's default model selection; the effort column marks its level as Default. */ + defaultModelSelection: ModelSelection | null; selectedModelForPickerWithCustomFallback: string; lockedProvider: ChatComposerProps["lockedProvider"]; lockedContinuationGroupKey: string | null; @@ -125,6 +127,7 @@ export function ComposerControlsBar(props: { selectedModelSelection, onProviderModelSelect, activeThreadModelSelection, + defaultModelSelection, selectedModelForPickerWithCustomFallback, lockedProvider, lockedContinuationGroupKey, @@ -227,7 +230,9 @@ export function ComposerControlsBar(props: { triggerShortcuts={ pickerOwnsModeShortcut ? "composer.effort composer.mode" : "composer.effort" } - effortColumn={} + effortColumn={ + + } footer={
- +

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

diff --git a/apps/web/src/components/chat/ComposerModelEffort.tsx b/apps/web/src/components/chat/ComposerModelEffort.tsx index 3a863936101b2..858561c059058 100644 --- a/apps/web/src/components/chat/ComposerModelEffort.tsx +++ b/apps/web/src/components/chat/ComposerModelEffort.tsx @@ -2,9 +2,10 @@ import { buildTraitsTriggerDisplay, getDescriptorStringValue, isReasoningControl, + resolveDefaultChoiceId, selectableProviderOptionChoices, } from "@t3tools/client-runtime/model-traits"; -import type { ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; +import type { ModelSelection, ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; import type { EffortDisplay } from "@t3tools/contracts/settings"; import { getProviderOptionCurrentLabel } from "@t3tools/shared/model"; import { CheckIcon, ChevronRightIcon, ChevronUpIcon } from "lucide-react"; @@ -238,7 +239,10 @@ function moveBetweenRows(event: KeyboardEvent) { next.scrollIntoView({ block: "nearest" }); } -function EffortLevels(props: { traits: TraitsSelectionInput }) { +function EffortLevels(props: { + traits: TraitsSelectionInput; + defaultModelSelection: ModelSelection | null; +}) { const traits = useTraitsSelection(props.traits); const { effort, others } = columnControls( props.traits.provider, @@ -264,9 +268,20 @@ function EffortLevels(props: { traits: TraitsSelectionInput }) { // 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; + const defaultChoiceId = (descriptor: SelectDescriptor) => + resolveDefaultChoiceId(descriptor, props.traits, props.defaultModelSelection); return ( <> - {effort ? : null} + {effort ? ( + + ) : null} {others.length > 0 ? (
), )} @@ -296,7 +312,13 @@ function EffortLevels(props: { traits: TraitsSelectionInput }) { type TraitsSelection = ReturnType; -function EffortRows(props: { control: ColumnControl; traits: TraitsSelection; locked: boolean }) { +function EffortRows(props: { + control: ColumnControl; + traits: TraitsSelection; + locked: boolean; + /** The level marked Default; see resolveDefaultChoiceId. */ + defaultChoiceId: string | undefined; +}) { const { control, traits } = props; const { descriptor } = control; if (control.kind === "boolean") { @@ -333,7 +355,7 @@ function EffortRows(props: { control: ColumnControl; traits: TraitsSelection; lo selected={option.id === value} tabStop={shown ? option.id === value : index === 0} description={option.description} - isDefault={option.isDefault} + isDefault={option.id === props.defaultChoiceId} disabled={locked} onPick={() => traits.changeSelect(control.descriptor, option.id)} /> @@ -363,6 +385,8 @@ function OptionMenuRow(props: { traits: TraitsSelection; /** Holds the column's tab stop when the model has no levels. */ tabStop: boolean; + /** The choice marked Default; see resolveDefaultChoiceId. */ + defaultChoiceId: string | undefined; }) { const { descriptor, traits } = props; const [open, setOpen] = useState(false); @@ -444,7 +468,7 @@ function OptionMenuRow(props: { ) : null} - {choice.isDefault ? ( + {choice.id === props.defaultChoiceId ? ( Default ) : null} @@ -487,7 +511,10 @@ function NoEffortRow() { } /** The model picker's effort column: the selected model's levels and its other options. */ -export function ComposerEffortColumn(props: { traits: TraitsSelectionInput | null }) { +export function ComposerEffortColumn(props: { + traits: TraitsSelectionInput | null; + defaultModelSelection: ModelSelection | null; +}) { const picker = use(DockedPickerContext); const visibility = props.traits ? getTraitsSectionVisibility(props.traits) : null; const controls = @@ -517,7 +544,11 @@ export function ComposerEffortColumn(props: { traits: TraitsSelectionInput | nul // Capture, so Up and Down on an option row move rows instead of opening its menu. onKeyDownCapture={moveBetweenRows} > - {props.traits ? : } + {props.traits ? ( + + ) : ( + + )}
); diff --git a/packages/client-runtime/src/modelTraits.test.ts b/packages/client-runtime/src/modelTraits.test.ts new file mode 100644 index 0000000000000..1e69d3b1db553 --- /dev/null +++ b/packages/client-runtime/src/modelTraits.test.ts @@ -0,0 +1,41 @@ +import { ProviderInstanceId, type SelectProviderOptionDescriptor } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveDefaultChoiceId } from "./modelTraits.ts"; + +const claude = ProviderInstanceId.make("claudeAgent"); +const effort: SelectProviderOptionDescriptor = { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], +}; +const opus = { instanceId: claude, model: "claude-opus-5-5" }; + +describe("resolveDefaultChoiceId", () => { + it("marks the configured default's level, falling back to the provider's default", () => { + const configured = (instanceId: ProviderInstanceId, model: string, value: string) => + createModelSelection(instanceId, model, [{ id: "effort", value }]); + const work = ProviderInstanceId.make("claudeAgent_work"); + + expect(resolveDefaultChoiceId(effort, opus, configured(claude, opus.model, "high"))).toBe( + "high", + ); + expect( + resolveDefaultChoiceId(effort, opus, configured(claude, "claude-fable-5-1", "high")), + ).toBe("medium"); + expect(resolveDefaultChoiceId(effort, opus, configured(work, opus.model, "high"))).toBe( + "medium", + ); + expect(resolveDefaultChoiceId(effort, opus, configured(claude, opus.model, "ultrathink"))).toBe( + "medium", + ); + }); +}); diff --git a/packages/client-runtime/src/modelTraits.ts b/packages/client-runtime/src/modelTraits.ts index b05f9226defb3..8216b36b791d4 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -1,10 +1,12 @@ import type { ModelSelection, ProviderDriverKind, + ProviderInstanceId, ProviderOptionDescriptor, } from "@t3tools/contracts"; import { buildProviderOptionSelectionsFromDescriptors, + getModelSelectionStringOptionValue, getProviderOptionCurrentLabel, getProviderOptionCurrentValue, isClaudeUltrathinkPrompt, @@ -71,6 +73,30 @@ export function selectableProviderOptionChoices( ); } +/** + * The choice to mark as Default: the configured default model's value when that default is this + * exact instance and model and still offers the value, otherwise the provider's default. + */ +export function resolveDefaultChoiceId( + descriptor: Extract, + model: { + readonly instanceId?: ProviderInstanceId | undefined; + readonly model: string | null | undefined; + }, + defaultModelSelection: ModelSelection | null, +): string | undefined { + const choices = selectableProviderOptionChoices(descriptor); + const configured = + defaultModelSelection && + defaultModelSelection.instanceId === model.instanceId && + defaultModelSelection.model === model.model + ? getModelSelectionStringOptionValue(defaultModelSelection, descriptor.id) + : undefined; + return ( + choices.find((option) => option.id === configured) ?? choices.find((option) => option.isDefault) + )?.id; +} + export function getPromptControlledTraits( descriptors: ReadonlyArray, prompt: string, From 8325cd8f5f0b275914c557b7ae82d7720c7c1d54 Mon Sep 17 00:00:00 2001 From: RCXDd Date: Mon, 2 Nov 2026 11:33:16 +0000 Subject: [PATCH 2/2] test(web): assert the configured default effort carries the Default badge Co-Authored-By: Claude Opus 5.5 --- .../chat/ComposerModelEffort.browser.test.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx b/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx index 85da395fc410c..edcaf60ca7db4 100644 --- a/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx @@ -4,7 +4,7 @@ import { type ProviderOptionSelection, type ServerProviderModel, } from "@t3tools/contracts"; -import { createModelCapabilities } from "@t3tools/shared/model"; +import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; import { useState } from "react"; import { expect, it } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; @@ -75,13 +75,18 @@ function traitsInput( }; } +// The configured default names High, which the agent does not mark as its own default. +const defaultModelSelection = createModelSelection(ProviderInstanceId.make("devin"), "fusion", [ + { id: "thought_level", value: "high" }, +]); + function Column() { const [options, setOptions] = useState>([]); const traits = traitsInput(options, (next) => setOptions(next ?? [])); return ( <>
- +

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

@@ -96,6 +101,8 @@ it("draws an agent's reasoning as bars and keeps its mode out of the levels", as const levels = screen.getByRole("radiogroup", { name: "Thinking" }); await expect.element(levels.getByRole("radio")).toHaveLength(3); + // Only the configured High carries the Default badge, not the selected Medium. + await expect.element(levels).toHaveTextContent("LowMediumHighDefault"); 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.