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
18 changes: 12 additions & 6 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ import {
applyClaudePromptEffortPrefix,
createModelSelection,
formatModelSlugName,
resolveModelPick,
resolvePromptInjectedEffort,
resolveSelectableModel,
} from "@t3tools/shared/model";
Expand Down Expand Up @@ -10434,14 +10435,18 @@ export default function ChatView(props: ChatViewProps) {
}
// Restore this model's own remembered options; without any, start it
// from its default rather than carrying the previous model's over.
const rememberedOptions =
const nextModelSelection = resolveModelPick(
composerRef.current?.getSendContext().selectedModelSelection,
instanceId,
resolvedModel,
useComposerDraftStore.getState().stickyOptionsByModelByProvider[instanceId]?.[
resolvedModel
];
const nextModelSelection: ModelSelection =
rememberedOptions !== undefined && rememberedOptions.length > 0
? { instanceId, model: resolvedModel, options: [...rememberedOptions] }
: { instanceId, model: resolvedModel };
],
);
if (!nextModelSelection) {
if (options?.focusComposer !== false) scheduleComposerFocus();
return;
}
const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({
providers: providerStatuses,
hasStartedSession: activeRuntime !== null,
Expand Down Expand Up @@ -10472,6 +10477,7 @@ export default function ChatView(props: ChatViewProps) {
[
activeThread,
activeRuntime,
composerRef,
lockedProvider,
supportsProviderSwitchingViaHandoff,
scheduleComposerFocus,
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/settings/ProjectDefaultsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type ProviderInstanceId,
type WorktreeSubmodules,
} from "@t3tools/contracts";
import { createModelSelection } from "@t3tools/shared/model";
import { createModelSelection, resolveModelPick } from "@t3tools/shared/model";
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { useNavigate } from "@tanstack/react-router";

Expand Down Expand Up @@ -173,8 +173,10 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
search: { environmentId: representative.environmentId, instanceId },
});
}}
// Re-picking the current model still writes it, which pins an
// automatic default and unifies a mixed scope without losing traits.
onInstanceModelChange={(instanceId, model) =>
setModel(createModelSelection(instanceId, model))
setModel(resolveModelPick(selection, instanceId, model) ?? selection)
}
/>
{!mixedModel ? (
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import {
SidebarProjectSortOrder,
} from "@t3tools/contracts/settings";
import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
import { createModelSelection } from "@t3tools/shared/model";
import { createModelSelection, resolveModelPick } from "@t3tools/shared/model";
import * as Duration from "effect/Duration";
import * as Equal from "effect/Equal";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -3281,7 +3281,9 @@ export function GeneralSettingsPanel() {
textGenerationModelSelection: resolveAppModelSelectionState(
{
...settings,
textGenerationModelSelection: createModelSelection(instanceId, model),
textGenerationModelSelection:
resolveModelPick(textGenerationModelSelection, instanceId, model) ??
textGenerationModelSelection,
},
textGenerationProviders,
),
Expand Down
28 changes: 28 additions & 0 deletions packages/shared/src/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getModelSelectionStringOptionValue,
getProviderOptionDescriptors,
readCustomModelEntries,
resolveModelPick,
toCustomModelSetting,
getProviderOptionBooleanSelectionValue,
getProviderOptionStringSelectionValue,
Expand Down Expand Up @@ -224,6 +225,33 @@ describe("descriptor helpers", () => {
});
});

describe("resolveModelPick", () => {
const claude = ProviderInstanceId.make("claudeAgent");
const current = createModelSelection(claude, "claude-opus-5-5", [
{ id: "effort", value: "medium" },
]);
const remembered = [{ id: "effort", value: "high" }];

it("changes nothing when the current model is picked again", () => {
expect(resolveModelPick(current, claude, "claude-opus-5-5", remembered)).toBeNull();
});

it("starts any other model from the given options", () => {
expect(resolveModelPick(current, claude, "claude-sonnet-5-5", remembered)).toEqual({
instanceId: "claudeAgent",
model: "claude-sonnet-5-5",
options: [{ id: "effort", value: "high" }],
});
expect(
resolveModelPick(current, ProviderInstanceId.make("claude_work"), "claude-opus-5-5"),
).toEqual({ instanceId: "claude_work", model: "claude-opus-5-5" });
expect(resolveModelPick(null, claude, "claude-opus-5-5")).toEqual({
instanceId: "claudeAgent",
model: "claude-opus-5-5",
});
});
});

describe("applyClaudePromptEffortPrefix", () => {
it("keeps slash commands intact when ultrathink is selected", () => {
expect(applyClaudePromptEffortPrefix("/compact", "ultrathink")).toBe("/compact");
Expand Down
16 changes: 16 additions & 0 deletions packages/shared/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,22 @@ export function createModelSelection(
return selections.length > 0 ? { ...base, options: selections } : base;
}

/**
* The selection a model picker pick produces, starting the picked model from
* `options` (for example its remembered options). Returns null when the pick
* names the current instance and model, so confirming the model already in use
* keeps the options it runs with.
*/
export function resolveModelPick(
current: ModelSelection | null | undefined,
instanceId: ProviderInstanceId,
model: string,
options?: ReadonlyArray<ProviderOptionSelection> | null,
): ModelSelection | null {
if (current?.instanceId === instanceId && current.model === model) return null;
return createModelSelection(instanceId, model, options);
}

/**
* Returns the effort value if it is a prompt-injected value according to
* any select descriptor in the given capabilities, or null otherwise.
Expand Down
Loading