diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9426a0b9b9b3e..16fe123c0afac 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -244,7 +244,11 @@ import { assistantCitationNavigation } from "../../lib/assistantCitationNavigati import { Button } from "../ui/button"; import { useManagedPolicy } from "../../state/managedPolicy"; 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, type SessionPhase, type Thread } from "../../types"; import { type ComposerPromptHistoryPosition } from "./composerPromptHistory"; @@ -961,6 +965,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) { @@ -2465,6 +2477,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 302330f29156b..80a6037b10b56 100644 --- a/apps/web/src/components/chat/ComposerControlsBar.tsx +++ b/apps/web/src/components/chat/ComposerControlsBar.tsx @@ -75,6 +75,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; @@ -119,6 +121,7 @@ export function ComposerControlsBar(props: { selectedModelSelection, onProviderModelSelect, activeThreadModelSelection, + defaultModelSelection, selectedModelForPickerWithCustomFallback, lockedProvider, lockedContinuationGroupKey, @@ -221,7 +224,9 @@ export function ComposerControlsBar(props: { triggerShortcuts={ pickerOwnsModeShortcut ? "composer.effort composer.mode" : "composer.effort" } - effortColumn={} + effortColumn={ + + } footer={ >([]); const traits = traitsInput(options, (next) => setOptions(next ?? [])); return ( <>
- +

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

@@ -95,6 +100,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. diff --git a/apps/web/src/components/chat/ComposerModelEffort.tsx b/apps/web/src/components/chat/ComposerModelEffort.tsx index f56997ff2a6a1..3afa02cf5df8b 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, WorkflowIcon } from "lucide-react"; @@ -245,7 +246,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(traits.selectDescriptors, traits.booleanDescriptors); if (traits.modelIsUnavailable) { @@ -267,9 +271,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 ? (
), )} @@ -299,7 +315,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") { @@ -336,7 +358,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)} /> @@ -366,6 +388,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); @@ -447,7 +471,7 @@ function OptionMenuRow(props: { ) : null} - {choice.isDefault ? ( + {choice.id === props.defaultChoiceId ? ( Default ) : null} @@ -490,7 +514,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 = @@ -516,7 +543,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 index 24da035532a04..2791736cbbb03 100644 --- a/packages/client-runtime/src/modelTraits.test.ts +++ b/packages/client-runtime/src/modelTraits.test.ts @@ -1,7 +1,16 @@ -import type { ProviderOptionDescriptor } from "@t3tools/contracts"; +import { + ProviderInstanceId, + type ProviderOptionDescriptor, + type SelectProviderOptionDescriptor, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; import { describe, expect, it } from "vite-plus/test"; -import { buildTraitsTriggerDisplay, getDescriptorStringValue } from "./modelTraits.ts"; +import { + buildTraitsTriggerDisplay, + getDescriptorStringValue, + resolveDefaultChoiceId, +} from "./modelTraits.ts"; describe("buildTraitsTriggerDisplay", () => { it.each([ @@ -33,3 +42,39 @@ describe("buildTraitsTriggerDisplay", () => { expect(getDescriptorStringValue(descriptor)).toBe(input.value); }); }); + +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 6e84acf10fc0d..081633cd942c8 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -1,6 +1,11 @@ -import type { ModelSelection, ProviderOptionDescriptor } from "@t3tools/contracts"; +import type { + ModelSelection, + ProviderInstanceId, + ProviderOptionDescriptor, +} from "@t3tools/contracts"; import { buildProviderOptionSelectionsFromDescriptors, + getModelSelectionStringOptionValue, getProviderOptionCurrentLabel, getProviderOptionCurrentValue, isClaudeUltrathinkPrompt, @@ -67,6 +72,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,