Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ProviderInstanceId, ReadonlyArray<AppModelOption>>();
for (const entry of providerInstanceEntries) {
Expand Down Expand Up @@ -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}
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/chat/ComposerControlsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -125,6 +127,7 @@ export function ComposerControlsBar(props: {
selectedModelSelection,
onProviderModelSelect,
activeThreadModelSelection,
defaultModelSelection,
selectedModelForPickerWithCustomFallback,
lockedProvider,
lockedContinuationGroupKey,
Expand Down Expand Up @@ -227,7 +230,9 @@ export function ComposerControlsBar(props: {
triggerShortcuts={
pickerOwnsModeShortcut ? "composer.effort composer.mode" : "composer.effort"
}
effortColumn={<ComposerEffortColumn traits={traitsInput} />}
effortColumn={
<ComposerEffortColumn traits={traitsInput} defaultModelSelection={defaultModelSelection} />
}
footer={
<ComposerPickerFooter
traits={traitsInput}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<ReadonlyArray<ProviderOptionSelection>>([]);
const traits = traitsInput(options, (next) => setOptions(next ?? []));
return (
<>
<div data-model-picker-effort>
<ComposerEffortColumn traits={traits} />
<ComposerEffortColumn traits={traits} defaultModelSelection={defaultModelSelection} />
</div>
<p role="status">{describeComposerEffort(traits, "bars", "sm").suffix}</p>
</>
Expand All @@ -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");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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.
Expand Down
47 changes: 39 additions & 8 deletions apps/web/src/components/chat/ComposerModelEffort.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -238,7 +239,10 @@ function moveBetweenRows(event: KeyboardEvent<HTMLElement>) {
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,
Expand All @@ -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 ? <EffortRows control={effort} traits={traits} locked={locked} /> : null}
{effort ? (
<EffortRows
control={effort}
traits={traits}
locked={locked}
defaultChoiceId={
effort.kind === "select" ? defaultChoiceId(effort.descriptor) : undefined
}
/>
) : null}
{others.length > 0 ? (
<div
className={cn("flex flex-col gap-0.5", effort && "mt-1 border-t border-border/70 pt-1")}
Expand All @@ -285,6 +300,7 @@ function EffortLevels(props: { traits: TraitsSelectionInput }) {
descriptor={control.descriptor}
traits={traits}
tabStop={(!effort || locked) && index === 0}
defaultChoiceId={defaultChoiceId(control.descriptor)}
/>
),
)}
Expand All @@ -296,7 +312,13 @@ function EffortLevels(props: { traits: TraitsSelectionInput }) {

type TraitsSelection = ReturnType<typeof useTraitsSelection>;

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") {
Expand Down Expand Up @@ -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)}
/>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -444,7 +468,7 @@ function OptionMenuRow(props: {
</span>
) : null}
</span>
{choice.isDefault ? (
{choice.id === props.defaultChoiceId ? (
<span className="text-muted-foreground/70 text-xs">Default</span>
) : null}
<MenuRadioItemIndicator />
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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 ? <EffortLevels traits={props.traits} /> : <NoEffortRow />}
{props.traits ? (
<EffortLevels traits={props.traits} defaultModelSelection={props.defaultModelSelection} />
) : (
<NoEffortRow />
)}
</div>
</>
);
Expand Down
41 changes: 41 additions & 0 deletions packages/client-runtime/src/modelTraits.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
26 changes: 26 additions & 0 deletions packages/client-runtime/src/modelTraits.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type {
ModelSelection,
ProviderDriverKind,
ProviderInstanceId,
ProviderOptionDescriptor,
} from "@t3tools/contracts";
import {
buildProviderOptionSelectionsFromDescriptors,
getModelSelectionStringOptionValue,
getProviderOptionCurrentLabel,
getProviderOptionCurrentValue,
isClaudeUltrathinkPrompt,
Expand Down Expand Up @@ -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<ProviderOptionDescriptor, { type: "select" }>,
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<ProviderOptionDescriptor>,
prompt: string,
Expand Down
Loading