diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
index 14f0fcc95a22..e8ce566ffae8 100644
--- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx
+++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
@@ -18,6 +18,13 @@ import {
ThreadId,
} from "@t3tools/contracts";
import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
+import {
+ buildProviderOptionSelectionsFromDescriptors,
+ createModelSelection,
+ getProviderOptionDescriptors,
+ resolveConfiguredProviderOptionDefaults,
+ resolveConfiguredRuntimeMode,
+} from "@t3tools/shared/model";
import {
isDefaultThreadEnvModeSettled,
resolveDefaultThreadEnvMode,
@@ -32,7 +39,8 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import {
buildModelOptions,
groupByProvider,
- resolveDefaultableModelSelection,
+ resolveNewTaskModelSelection,
+ resolveNewTaskRuntimeMode,
resolveSelectableModelSelection,
} from "../../lib/modelOptions";
import { scopedProjectKey } from "../../lib/scopedEntities";
@@ -400,7 +408,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
draftStartFromOrigin ??
selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ??
true;
- const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
const interactionMode = planModeEnabled
? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE)
: DEFAULT_PROVIDER_INTERACTION_MODE;
@@ -408,31 +415,25 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
// Stored selections only count while their provider is usable on the
// server; otherwise the server's default model wins instead of silently
// targeting a disabled provider. The draft selection is an explicit pick
- // and passes through as-is; the project default (last used, possibly from
- // desktop) is implicit and additionally never resolves to a legacy model.
- const draftModelSelection = resolveSelectableModelSelection(
- selectedEnvironmentServerConfig,
- selectedProjectDraft.modelSelection ?? null,
- );
- const projectDefaultModelSelection = resolveDefaultableModelSelection(
- selectedEnvironmentServerConfig,
- selectedProject?.defaultModelSelection ?? null,
+ // and passes through as-is.
+ const draftModelSelection = selectedProjectDraft.modelSelection ?? null;
+ const selectedModel = useMemo(
+ () =>
+ resolveNewTaskModelSelection({
+ config: selectedEnvironmentServerConfig,
+ draftSelection: draftModelSelection,
+ }),
+ [selectedEnvironmentServerConfig, draftModelSelection],
);
const modelOptions = useMemo(
- () =>
- buildModelOptions(
- selectedEnvironmentServerConfig,
- draftModelSelection ?? projectDefaultModelSelection,
- ),
- [selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection],
+ () => buildModelOptions(selectedEnvironmentServerConfig, selectedModel),
+ [selectedEnvironmentServerConfig, selectedModel],
);
-
- const selectedModel =
- draftModelSelection ??
- projectDefaultModelSelection ??
- modelOptions.find((option) => option.isDefault)?.selection ??
- modelOptions[0]?.selection ??
- null;
+ const runtimeMode = resolveNewTaskRuntimeMode({
+ config: selectedEnvironmentServerConfig,
+ selectedModel,
+ draftRuntimeMode: selectedProjectDraft.runtimeMode,
+ });
const selectedModelKey = selectedModel
? `${selectedModel.instanceId}:${selectedModel.model}`
: null;
@@ -462,11 +463,55 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
if (!option) {
return;
}
+ const provider = selectedEnvironmentServerConfig?.providers.find(
+ (candidate) => candidate.instanceId === option.selection.instanceId,
+ );
+ const model = provider?.models.find((candidate) => candidate.slug === option.selection.model);
+ const existingOptions =
+ options ??
+ (selectedProjectDraft.modelSelection?.instanceId === option.selection.instanceId
+ ? selectedProjectDraft.modelSelection.options
+ : undefined);
+ const descriptors = model?.capabilities?.optionDescriptors ?? [];
+ const seededOptions =
+ existingOptions !== undefined
+ ? buildProviderOptionSelectionsFromDescriptors(
+ getProviderOptionDescriptors({
+ caps: { optionDescriptors: descriptors },
+ selections: existingOptions,
+ }),
+ )
+ : resolveConfiguredProviderOptionDefaults({
+ settings: selectedEnvironmentServerConfig?.settings ?? {
+ providerNewThreadDefaults: {},
+ },
+ instanceId: option.selection.instanceId,
+ descriptors,
+ });
+ const configuredRuntimeMode =
+ selectedProjectDraft.modelSelection?.instanceId !== option.selection.instanceId &&
+ selectedEnvironmentServerConfig
+ ? resolveConfiguredRuntimeMode(
+ selectedEnvironmentServerConfig.settings,
+ option.selection.instanceId,
+ )
+ : null;
updateComposerDraftSettings(selectedProjectDraftKey, {
- modelSelection: options ? { ...option.selection, options } : option.selection,
+ modelSelection: createModelSelection(
+ option.selection.instanceId,
+ option.selection.model,
+ seededOptions,
+ ),
+ ...(configuredRuntimeMode !== null ? { runtimeMode: configuredRuntimeMode } : {}),
});
},
- [modelOptions, selectedProjectDraftKey],
+ [
+ modelOptions,
+ selectedEnvironmentServerConfig,
+ selectedProjectDraft.modelSelection,
+ selectedProjectDraft.runtimeMode,
+ selectedProjectDraftKey,
+ ],
);
const setSelectedModelOptions = useCallback(
(options: ReadonlyArray | undefined) => {
diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts
index 8a9dabbe034f..da1861e504f7 100644
--- a/apps/mobile/src/lib/modelOptions.test.ts
+++ b/apps/mobile/src/lib/modelOptions.test.ts
@@ -1,15 +1,102 @@
import { describe, expect, it } from "vite-plus/test";
-import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";
+import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";
import {
buildModelOptions,
groupByProvider,
resolveDefaultableModelSelection,
+ resolveNewTaskModelSelection,
+ resolveNewTaskRuntimeMode,
resolveSelectableModelSelection,
} from "./modelOptions";
describe("mobile model options", () => {
+ it("resolves configured new-thread defaults with configured options", () => {
+ const codexId = ProviderInstanceId.make("codex");
+ const claudeId = ProviderInstanceId.make("claudeAgent");
+ const config = {
+ settings: {
+ ...DEFAULT_SERVER_SETTINGS,
+ newThreadModel: { instanceId: claudeId, model: "claude" },
+ providerNewThreadDefaults: {
+ [claudeId]: {
+ modelOptions: [{ id: "effort", value: "high" }],
+ runtimeMode: "approval-required",
+ },
+ },
+ },
+ providers: [
+ {
+ instanceId: codexId,
+ driver: "codex",
+ enabled: true,
+ installed: true,
+ auth: { status: "authenticated" },
+ models: [
+ {
+ slug: "gpt",
+ name: "GPT",
+ isCustom: false,
+ isDefault: true,
+ capabilities: null,
+ },
+ ],
+ },
+ {
+ instanceId: claudeId,
+ driver: "claudeAgent",
+ enabled: true,
+ installed: true,
+ auth: { status: "authenticated" },
+ models: [
+ {
+ slug: "claude",
+ name: "Claude",
+ isCustom: false,
+ capabilities: {
+ optionDescriptors: [
+ {
+ id: "effort",
+ label: "Effort",
+ type: "select",
+ options: [
+ { id: "low", label: "Low", isDefault: true },
+ { id: "high", label: "High" },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ },
+ ],
+ } as unknown as ServerConfig;
+
+ const configured = resolveNewTaskModelSelection({
+ config,
+ draftSelection: null,
+ });
+ expect(configured).toEqual({
+ instanceId: claudeId,
+ model: "claude",
+ options: [{ id: "effort", value: "high" }],
+ });
+ expect(
+ resolveNewTaskRuntimeMode({ config, selectedModel: configured, draftRuntimeMode: undefined }),
+ ).toBe("approval-required");
+ expect(
+ resolveNewTaskModelSelection({
+ config,
+ draftSelection: {
+ instanceId: claudeId,
+ model: "claude",
+ options: [{ id: "effort", value: "low" }],
+ },
+ })?.options,
+ ).toEqual([{ id: "effort", value: "low" }]);
+ });
+
it("groups models by provider and flags legacy entries", () => {
const config = {
providers: [
diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts
index cb7a8c4198ec..f0648afde4a6 100644
--- a/apps/mobile/src/lib/modelOptions.ts
+++ b/apps/mobile/src/lib/modelOptions.ts
@@ -1,11 +1,16 @@
import type {
ModelCapabilities,
ModelSelection,
+ RuntimeMode,
ServerConfig as T3ServerConfig,
} from "@t3tools/contracts";
+import { DEFAULT_RUNTIME_MODE } from "@t3tools/contracts";
import {
buildProviderOptionSelectionsFromDescriptors,
+ createModelSelection,
getProviderOptionDescriptors,
+ resolveConfiguredProviderOptionDefaults,
+ resolveConfiguredRuntimeMode,
} from "@t3tools/shared/model";
export type ModelOption = {
@@ -167,6 +172,52 @@ export function buildModelOptions(
return [...options.values()];
}
+export function resolveNewTaskModelSelection(input: {
+ readonly config: T3ServerConfig | null | undefined;
+ readonly draftSelection: ModelSelection | null;
+}): ModelSelection | null {
+ const draftSelection = resolveSelectableModelSelection(input.config, input.draftSelection);
+ const configuredSelection = resolveDefaultableModelSelection(
+ input.config,
+ input.config?.settings.newThreadModel ?? null,
+ );
+ const modelOptions = buildModelOptions(input.config, draftSelection ?? configuredSelection);
+ const base =
+ draftSelection ??
+ configuredSelection ??
+ modelOptions.find((option) => option.isDefault)?.selection ??
+ modelOptions[0]?.selection ??
+ null;
+ if (!base) return null;
+ const provider = input.config?.providers.find(
+ (candidate) => candidate.instanceId === base.instanceId,
+ );
+ const model = provider?.models.find((candidate) => candidate.slug === base.model);
+ if (draftSelection?.options && draftSelection.options.length > 0) {
+ return normalizeSelectionOptions(draftSelection, model?.capabilities ?? null);
+ }
+ const options = resolveConfiguredProviderOptionDefaults({
+ settings: input.config?.settings ?? { providerNewThreadDefaults: {} },
+ instanceId: base.instanceId,
+ descriptors: model?.capabilities?.optionDescriptors ?? [],
+ });
+ return createModelSelection(base.instanceId, base.model, options);
+}
+
+export function resolveNewTaskRuntimeMode(input: {
+ readonly config: T3ServerConfig | null | undefined;
+ readonly selectedModel: ModelSelection | null;
+ readonly draftRuntimeMode: RuntimeMode | undefined;
+}): RuntimeMode {
+ return (
+ input.draftRuntimeMode ??
+ (input.config && input.selectedModel
+ ? resolveConfiguredRuntimeMode(input.config.settings, input.selectedModel.instanceId)
+ : null) ??
+ DEFAULT_RUNTIME_MODE
+ );
+}
+
export function groupByProvider(options: ReadonlyArray): ReadonlyArray {
const groups = new Map();
for (const option of options) {
diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx
index 31290c6004ff..fc65894e58bb 100644
--- a/apps/web/src/AppRoot.tsx
+++ b/apps/web/src/AppRoot.tsx
@@ -6,6 +6,7 @@ import { QuitHoldOverlay } from "./components/QuitHoldOverlay";
import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
import type { AppRouter } from "./router";
import { ServerBootReload } from "./components/ServerBootReload";
+import { DraftDefaultsSync } from "./draftDefaultsSync";
/**
* Owns renderer-wide providers. The Electron browser host intentionally sits
@@ -16,6 +17,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) {
return (
+
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index ca7ce3a6b3f8..a7609bd1023f 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -42,6 +42,8 @@ import {
import {
applyClaudePromptEffortPrefix,
createModelSelection,
+ resolveConfiguredProviderOptionDefaults,
+ resolveConfiguredRuntimeMode,
resolvePromptInjectedEffort,
} from "@t3tools/shared/model";
import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList";
@@ -6104,6 +6106,10 @@ function ChatViewContent(props: ChatViewProps) {
// are rejected by returning early; the server remains authoritative too.
const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId);
const resolvedDriverKind = entry?.driver ?? null;
+ if (!entry) {
+ scheduleComposerFocus();
+ return;
+ }
if (
lockedProvider !== null &&
resolvedDriverKind !== null &&
@@ -6135,10 +6141,19 @@ function ChatViewContent(props: ChatViewProps) {
scheduleComposerFocus();
return;
}
- const nextModelSelection: ModelSelection = {
- instanceId,
- model: resolvedModel,
- };
+ const existingDraftSelection = useComposerDraftStore
+ .getState()
+ .getComposerDraft(composerDraftTarget)?.modelSelectionByProvider[instanceId];
+ const configuredOptions = existingDraftSelection
+ ? undefined
+ : resolveConfiguredProviderOptionDefaults({
+ settings,
+ instanceId,
+ descriptors:
+ getProviderModelCapabilities(entry.models, resolvedModel, entry.driver)
+ .optionDescriptors ?? [],
+ });
+ const nextModelSelection = createModelSelection(instanceId, resolvedModel, configuredOptions);
const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({
providers: providerStatuses,
hasStartedSession: activeThread.session !== null,
@@ -6159,14 +6174,28 @@ function ChatViewContent(props: ChatViewProps) {
scopeThreadRef(activeThread.environmentId, activeThread.id),
nextModelSelection,
);
- setStickyComposerModelSelection(nextModelSelection);
+ setStickyComposerModelSelection(createModelSelection(instanceId, resolvedModel));
+ const previousInstanceId = composerActiveProvider ?? activeThread.modelSelection.instanceId;
+ const configuredRuntimeMode =
+ resolveConfiguredRuntimeMode(settings, instanceId) ?? DEFAULT_RUNTIME_MODE;
+ if (activeThread.session === null && instanceId !== previousInstanceId) {
+ setComposerDraftRuntimeMode(composerDraftTarget, configuredRuntimeMode);
+ if (isLocalDraftThread) {
+ setDraftThreadContext(composerDraftTarget, { runtimeMode: configuredRuntimeMode });
+ }
+ }
scheduleComposerFocus();
},
[
activeThread,
+ composerActiveProvider,
+ composerDraftTarget,
+ isLocalDraftThread,
lockedProvider,
scheduleComposerFocus,
setComposerDraftModelSelection,
+ setComposerDraftRuntimeMode,
+ setDraftThreadContext,
setStickyComposerModelSelection,
providerStatuses,
settings,
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index 975c8886940f..f4c747e5f1eb 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -208,11 +208,6 @@ import {
Maximize2Icon,
Minimize2Icon,
PencilRulerIcon,
- type LucideIcon,
- LockIcon,
- LockOpenIcon,
- PenLineIcon,
- SparklesIcon,
XIcon,
} from "lucide-react";
import { proposedPlanTitle } from "../../proposedPlan";
@@ -237,34 +232,10 @@ import { searchProviderSkills } from "../../providerSkillSearch";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import type { ReviewCommentContext } from "../../reviewCommentContext";
import { deriveLatestPromptSuggestion } from "../../lib/promptSuggestion";
+import { RUNTIME_MODE_CONFIG, RUNTIME_MODE_OPTIONS } from "./runtimeModeConfig";
-const runtimeModeConfig: Record<
- RuntimeMode,
- { label: string; description: string; icon: LucideIcon }
-> = {
- "approval-required": {
- label: "Supervised",
- description: "Ask before commands and file changes.",
- icon: LockIcon,
- },
- "auto-accept-edits": {
- label: "Auto-accept edits",
- description: "Auto-approve edits, ask before other actions.",
- icon: PenLineIcon,
- },
- auto: {
- label: "Auto",
- description: "Supported providers approve routine actions; others still ask.",
- icon: SparklesIcon,
- },
- "full-access": {
- label: "Full access",
- description: "Allow commands and edits without prompts.",
- icon: LockOpenIcon,
- },
-};
-
-const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
+const runtimeModeConfig = RUNTIME_MODE_CONFIG;
+const runtimeModeOptions = RUNTIME_MODE_OPTIONS;
const extendReplacementRangeForTrailingSpace = (
text: string,
rangeEnd: number,
diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx
index 924fbddeab70..3f906a3bd7f5 100644
--- a/apps/web/src/components/chat/TraitsPicker.tsx
+++ b/apps/web/src/components/chat/TraitsPicker.tsx
@@ -243,7 +243,6 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
setProviderModelOptions(threadTarget, provider, nextOptions, {
...(instanceId ? { instanceId } : {}),
model,
- persistSticky: true,
});
},
[instanceId, model, persistence, provider, setProviderModelOptions],
@@ -330,14 +329,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
>
-
- {option.label}
- {option.isDefault ? (
- <>
- {" "}
-
- >
- ) : null}
+
+ {option.label}
+ {option.isDefault ? : null}
{option.description ? (
diff --git a/apps/web/src/components/chat/runtimeModeConfig.ts b/apps/web/src/components/chat/runtimeModeConfig.ts
new file mode 100644
index 000000000000..0e6e8432e4ff
--- /dev/null
+++ b/apps/web/src/components/chat/runtimeModeConfig.ts
@@ -0,0 +1,35 @@
+import type { RuntimeMode } from "@t3tools/contracts";
+import { LockIcon, LockOpenIcon, PenLineIcon, SparklesIcon, type LucideIcon } from "lucide-react";
+
+/**
+ * Presentation for each permission (runtime) mode, shared by the composer's
+ * mode control and settings surfaces that pick a mode, so labels, blurbs, and
+ * icons stay identical everywhere.
+ */
+export const RUNTIME_MODE_CONFIG: Record<
+ RuntimeMode,
+ { label: string; description: string; icon: LucideIcon }
+> = {
+ "approval-required": {
+ label: "Supervised",
+ description: "Ask before commands and file changes.",
+ icon: LockIcon,
+ },
+ "auto-accept-edits": {
+ label: "Auto-accept edits",
+ description: "Auto-approve edits, ask before other actions.",
+ icon: PenLineIcon,
+ },
+ auto: {
+ label: "Auto",
+ description: "Supported providers approve routine actions; others still ask.",
+ icon: SparklesIcon,
+ },
+ "full-access": {
+ label: "Full access",
+ description: "Allow commands and edits without prompts.",
+ icon: LockOpenIcon,
+ },
+};
+
+export const RUNTIME_MODE_OPTIONS = Object.keys(RUNTIME_MODE_CONFIG) as RuntimeMode[];
diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx
index 11cfaf889d0e..2945c5ceb569 100644
--- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx
+++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx
@@ -14,14 +14,11 @@ import {
} from "../../logicalProject";
import type {
ContextMenuItem,
- ModelSelection,
- ProviderDriverKind,
SidebarProjectGroupingMode,
T3ProjectFileScript,
ThreadEnvMode,
} from "@t3tools/contracts";
import { resolveEnvModeLabel } from "../BranchToolbar.logic";
-import { createModelSelection } from "@t3tools/shared/model";
import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
import { useCanGoBack, useNavigate } from "@tanstack/react-router";
import * as Cause from "effect/Cause";
@@ -53,13 +50,6 @@ import {
nextProjectScriptId,
} from "../../projectScripts";
import { decodeProjectScriptKeybindingRule } from "../../lib/projectScriptKeybindings";
-import {
- applyProviderInstanceSettings,
- deriveProviderInstanceEntries,
- resolveDefaultProviderModelSelection,
- sortProviderInstanceEntries,
-} from "../../providerInstances";
-import { getCustomModelOptionsByInstance } from "../../modelSelection";
import {
buildSidebarProjectSnapshots,
type SidebarProjectGroupMember,
@@ -68,11 +58,9 @@ import {
import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
import { useProjects } from "../../state/entities";
import { projectEnvironment } from "../../state/projects";
-import { primaryServerProvidersAtom, serverEnvironment } from "../../state/server";
+import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
-import { ProviderModelPicker } from "../chat/ProviderModelPicker";
import { useDiscoverableThreadShells } from "../chat/useDiscoverableThreadShells";
-import { TraitsPicker } from "../chat/TraitsPicker";
import { ProjectFavicon } from "../ProjectFavicon";
import {
EMPTY_PROJECT_SCRIPT_INPUT,
@@ -308,7 +296,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
const settings = usePrimarySettings();
const updateClientSettings = useUpdateClientSettings();
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
- const serverProviders = useAtomValue(primaryServerProvidersAtom);
const threads = useDiscoverableThreadShells();
const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });
const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
@@ -365,7 +352,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
async (
input: Partial<{
title: string;
- defaultModelSelection: ModelSelection | null;
defaultThreadEnvMode: ThreadEnvMode | null;
faviconPath: string | null;
}>,
@@ -410,29 +396,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
[group.displayName, group.memberProjects, updateAllMembers],
);
- // ----- default model -----
- const storedSelection = representative.defaultModelSelection;
- const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection);
- const instanceEntries = useMemo(
- () =>
- sortProviderInstanceEntries(
- applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings),
- ),
- [serverProviders, settings],
- );
- const modelOptionsByInstance = useMemo(
- () => getCustomModelOptionsByInstance(settings, serverProviders),
- [serverProviders, settings],
- );
- const activeEntry = instanceEntries.find(
- (entry) => entry.instanceId === resolvedSelection?.instanceId,
- );
- const setDefaultModel = useCallback(
- (selection: ModelSelection | null) =>
- void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"),
- [updateAllMembers],
- );
-
// ----- new-thread workspace mode -----
const storedEnvMode = representative.defaultThreadEnvMode ?? null;
const setDefaultThreadEnvMode = useCallback(
@@ -816,58 +779,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
- setDefaultModel(null)}
- />
- ) : null
- }
- control={
- resolvedSelection && activeEntry ? (
-
-
{
- setDefaultModel(createModelSelection(instanceId, model));
- }}
- />
- {}}
- modelOptions={resolvedSelection.options ?? []}
- allowPromptInjectedEffort={false}
- triggerVariant="outline"
- triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
- onModelOptionsChange={(nextOptions) => {
- setDefaultModel(
- createModelSelection(
- resolvedSelection.instanceId,
- resolvedSelection.model,
- nextOptions,
- ),
- );
- }}
- />
-
- ) : (
- No providers available
- )
- }
- />
) => void;
readonly onFavoriteModelsChange: (next: ReadonlyArray) => void;
readonly onModelOrderChange: (next: ReadonlyArray) => void;
+ readonly newThreadDefaults: ProviderNewThreadDefaultsValue | undefined;
+ readonly newThreadModel: ModelSelection | null;
+ readonly onNewThreadDefaultsChange: (next: ProviderNewThreadDefaultsValue) => void;
readonly onRunUpdate?: (() => void) | undefined;
readonly isUpdating?: boolean | undefined;
}
@@ -391,6 +399,9 @@ export function ProviderInstanceCard({
onHiddenModelsChange,
onFavoriteModelsChange,
onModelOrderChange,
+ newThreadDefaults,
+ newThreadModel,
+ onNewThreadDefaultsChange,
onRunUpdate,
isUpdating = false,
}: ProviderInstanceCardProps) {
@@ -797,6 +808,16 @@ export function ProviderInstanceCard({
)}
+
+
diff --git a/apps/web/src/components/settings/ProviderNewThreadDefaultsSection.tsx b/apps/web/src/components/settings/ProviderNewThreadDefaultsSection.tsx
new file mode 100644
index 000000000000..c1cfa40923ca
--- /dev/null
+++ b/apps/web/src/components/settings/ProviderNewThreadDefaultsSection.tsx
@@ -0,0 +1,172 @@
+"use client";
+
+import {
+ DEFAULT_RUNTIME_MODE,
+ type ModelSelection,
+ type ProviderDriverKind,
+ type ProviderInstanceId,
+ type ProviderOptionSelection,
+ type RuntimeMode,
+ type ServerProviderModel,
+} from "@t3tools/contracts";
+import { Badge } from "../ui/badge";
+import { RUNTIME_MODE_CONFIG, RUNTIME_MODE_OPTIONS } from "../chat/runtimeModeConfig";
+import { TraitsPicker } from "../chat/TraitsPicker";
+import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
+import { resolveNewThreadDefaultModelForInstance } from "./ProviderSettingsPanel.logic";
+import { SettingResetButton } from "./settingsLayout";
+
+export interface ProviderNewThreadDefaultsValue {
+ readonly modelOptions?: ReadonlyArray | undefined;
+ readonly runtimeMode?: RuntimeMode | undefined;
+}
+
+function AppDefaultBadge() {
+ return (
+
+ Default
+
+ );
+}
+
+function RuntimeModeValue({ mode }: { readonly mode: RuntimeMode }) {
+ const option = RUNTIME_MODE_CONFIG[mode];
+ const Icon = option.icon;
+ return (
+
+
+ {option.label}
+
+ );
+}
+
+interface ProviderNewThreadDefaultsSectionProps {
+ readonly instanceId: ProviderInstanceId;
+ readonly displayName: string;
+ readonly driverKind: ProviderDriverKind | null;
+ readonly models: ReadonlyArray;
+ readonly newThreadDefaults: ProviderNewThreadDefaultsValue | undefined;
+ readonly newThreadModel: ModelSelection | null;
+ readonly onNewThreadDefaultsChange: (next: ProviderNewThreadDefaultsValue) => void;
+}
+
+export function ProviderNewThreadDefaultsSection({
+ instanceId,
+ displayName,
+ driverKind,
+ models,
+ newThreadDefaults,
+ newThreadModel,
+ onNewThreadDefaultsChange,
+}: ProviderNewThreadDefaultsSectionProps) {
+ const representativeModelSlug = resolveNewThreadDefaultModelForInstance({
+ instanceId,
+ configuredModel: newThreadModel,
+ models,
+ });
+ const representativeModel = models.find((model) => model.slug === representativeModelSlug);
+ const descriptors = representativeModel?.capabilities?.optionDescriptors ?? [];
+ const hasConfigurableTraits = descriptors.length > 0 && driverKind !== null;
+ const hasPinnedDefaults =
+ (newThreadDefaults?.modelOptions?.length ?? 0) > 0 ||
+ newThreadDefaults?.runtimeMode !== undefined;
+ const runtimeMode = newThreadDefaults?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
+
+ return (
+
+
+
+
New thread defaults
+
+ Applied whenever a new thread selects this provider.
+
+
+ {hasPinnedDefaults ? (
+
onNewThreadDefaultsChange({})}
+ />
+ ) : null}
+
+
+
+
+
Traits
+ {hasConfigurableTraits && representativeModelSlug && driverKind ? (
+
+ {}}
+ modelOptions={newThreadDefaults?.modelOptions ?? []}
+ allowPromptInjectedEffort={false}
+ triggerVariant="outline"
+ triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
+ onModelOptionsChange={(nextOptions) => {
+ // The picker reports the complete option set it displays;
+ // store it verbatim so the row is what-you-see-is-what-you-get.
+ onNewThreadDefaultsChange({
+ ...(nextOptions && nextOptions.length > 0 ? { modelOptions: nextOptions } : {}),
+ ...(newThreadDefaults?.runtimeMode
+ ? { runtimeMode: newThreadDefaults.runtimeMode }
+ : {}),
+ });
+ }}
+ />
+
+ ) : (
+
+ No configurable traits for this provider
+
+ )}
+
+
+
+
Permission mode
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts
index bf558f5a4d66..ad7bf0695825 100644
--- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts
+++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts
@@ -5,10 +5,35 @@ import {
buildProviderEnvironmentOptions,
classifyProviderEnvironmentAccess,
resolvePrimaryOperateAccess,
+ resolveNewThreadDefaultModelForInstance,
resolveRemoteOperateAccess,
resolveSelectedProviderEnvironmentId,
} from "./ProviderSettingsPanel.logic";
+describe("new-thread provider row model", () => {
+ const models = [
+ { slug: "default", name: "Default", isCustom: false, isDefault: true, capabilities: null },
+ { slug: "chosen", name: "Chosen", isCustom: false, capabilities: null },
+ ] as const;
+
+ it("uses the specific configured model only for its instance", () => {
+ expect(
+ resolveNewThreadDefaultModelForInstance({
+ instanceId: "codex",
+ configuredModel: { instanceId: "codex", model: "chosen" },
+ models,
+ }),
+ ).toBe("chosen");
+ expect(
+ resolveNewThreadDefaultModelForInstance({
+ instanceId: "codex_work",
+ configuredModel: { instanceId: "codex", model: "chosen" },
+ models,
+ }),
+ ).toBe("default");
+ });
+});
+
const primaryId = EnvironmentId.make("primary");
const relayId = EnvironmentId.make("relay");
const sshId = EnvironmentId.make("ssh");
diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts
index 1c7dac391f6a..852590303f3d 100644
--- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts
+++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts
@@ -3,8 +3,28 @@ import {
AuthOrchestrationOperateScope,
type AuthSessionState,
type EnvironmentId,
+ type ServerProviderModel,
} from "@t3tools/contracts";
+export function resolveNewThreadDefaultModelForInstance(input: {
+ readonly instanceId: string;
+ readonly configuredModel: { readonly instanceId: string; readonly model: string } | null;
+ readonly models: ReadonlyArray;
+}): string | null {
+ if (
+ input.configuredModel?.instanceId === input.instanceId &&
+ input.models.some((model) => model.slug === input.configuredModel?.model)
+ ) {
+ return input.configuredModel.model;
+ }
+ return (
+ input.models.find((model) => model.isDefault && model.isLegacy !== true)?.slug ??
+ input.models.find((model) => model.isLegacy !== true)?.slug ??
+ input.models[0]?.slug ??
+ null
+ );
+}
+
export interface ProviderEnvironmentOptionLike {
readonly environmentId: EnvironmentId;
readonly label: string;
diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx
index 773463a3835c..00a8cbda1edf 100644
--- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx
+++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx
@@ -870,6 +870,20 @@ export function EnvironmentProviderSettings({
modelOrder,
})
}
+ newThreadDefaults={settings.providerNewThreadDefaults[row.instanceId]}
+ newThreadModel={settings.newThreadModel}
+ onNewThreadDefaultsChange={(next) => {
+ const rest = withoutProviderInstanceKey(
+ settings.providerNewThreadDefaults,
+ row.instanceId,
+ );
+ updateSettings({
+ providerNewThreadDefaults:
+ next.modelOptions === undefined && next.runtimeMode === undefined
+ ? rest
+ : { ...rest, [row.instanceId]: next },
+ });
+ }}
onRunUpdate={
showInlineUpdateButton && updateCandidate
? () => {
diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts
index 0086ceec1983..42c0a3b03b0c 100644
--- a/apps/web/src/components/settings/SettingsPanels.logic.ts
+++ b/apps/web/src/components/settings/SettingsPanels.logic.ts
@@ -5,6 +5,7 @@ import type {
ProviderInstanceConfig,
ProviderInstanceId,
ServerSettings,
+ ServerSettingsPatch,
SidebarProjectGroupingMode,
UnifiedSettings,
} from "@t3tools/contracts";
@@ -211,7 +212,7 @@ export function buildProviderInstanceUpdatePatch(input: {
readonly textGenerationModelSelection?:
| ServerSettings["textGenerationModelSelection"]
| undefined;
-}): Partial {
+}): Pick {
type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]];
const legacyProviderDefaults = DEFAULT_UNIFIED_SETTINGS.providers as Record<
string,
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index f3fb455eb9bf..901789b91299 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -74,6 +74,7 @@ import {
import {
applyProviderInstanceSettings,
deriveProviderInstanceEntries,
+ resolveDefaultProviderModelSelection,
sortProviderInstanceEntries,
} from "../../providerInstances";
import { ensureLocalApi, readLocalApi } from "../../localApi";
@@ -516,6 +517,9 @@ export function useSettingsRestore(onRestored?: () => void) {
DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin
? ["New worktrees start from origin"]
: []),
+ ...(settings.newThreadModel !== DEFAULT_UNIFIED_SETTINGS.newThreadModel
+ ? ["New thread model"]
+ : []),
...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory
? ["Add project base directory"]
: []),
@@ -539,6 +543,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.addProjectBaseDirectory,
settings.defaultThreadEnvMode,
settings.newWorktreesStartFromOrigin,
+ settings.newThreadModel,
settings.diffIgnoreWhitespace,
settings.environmentIdentificationMode,
settings.fontFamilyCode,
@@ -645,6 +650,7 @@ export function useSettingsRestore(onRestored?: () => void) {
providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval,
defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin,
+ newThreadModel: DEFAULT_UNIFIED_SETTINGS.newThreadModel,
addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
@@ -1794,10 +1800,10 @@ export function GeneralSettingsPanel() {
const textGenInstanceId = textGenerationModelSelection.instanceId;
const textGenModel = textGenerationModelSelection.model;
const textGenModelOptions = textGenerationModelSelection.options;
- const textGenerationModelInstanceEntries = sortProviderInstanceEntries(
+ const providerInstanceEntries = sortProviderInstanceEntries(
applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings),
);
- const textGenInstanceEntry = textGenerationModelInstanceEntries.find(
+ const textGenInstanceEntry = providerInstanceEntries.find(
(entry) => entry.instanceId === textGenInstanceId,
);
const textGenProvider: ProviderDriverKind =
@@ -1812,6 +1818,15 @@ export function GeneralSettingsPanel() {
settings.textGenerationModelSelection ?? null,
DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
);
+ // Only providers a new thread could actually start on: unavailable ones
+ // (not installed, unauthenticated) are skipped by the resolver anyway.
+ const newThreadModelInstanceEntries = providerInstanceEntries.filter(
+ (entry) => entry.enabled && entry.isAvailable,
+ );
+ const resolvedNewThreadModel = resolveDefaultProviderModelSelection(
+ serverProviders,
+ settings.newThreadModel,
+ );
const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
const activeBackgroundActivityProfile = resolvedBackgroundActivity.profile;
const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings);
@@ -2186,6 +2201,66 @@ export function GeneralSettingsPanel() {
/>
) : null}
+ updateSettings({ newThreadModel: null })}
+ />
+ ) : null
+ }
+ control={
+
+
+ {settings.newThreadModel !== null && resolvedNewThreadModel ? (
+
+ updateSettings({ newThreadModel: createModelSelection(instanceId, model) })
+ }
+ />
+ ) : null}
+
+ }
+ />
+
{
});
});
+ it("keeps new-thread settings beside their owning sections", () => {
+ const catalogItems: ReadonlyArray = SETTINGS_SEARCH_ITEMS;
+ const workspaceIndex = SETTINGS_SEARCH_ITEMS.findIndex((item) => item.id === "new-threads");
+ const originIndex = SETTINGS_SEARCH_ITEMS.findIndex((item) => item.id === "start-from-origin");
+ const modelIndex = SETTINGS_SEARCH_ITEMS.findIndex((item) => item.id === "new-thread-model");
+
+ expect(SETTINGS_SEARCH_ITEMS[workspaceIndex]).toMatchObject({
+ title: "New thread workspace",
+ to: "/settings/general",
+ });
+ expect([workspaceIndex, originIndex, modelIndex]).toEqual([
+ workspaceIndex,
+ workspaceIndex + 1,
+ workspaceIndex + 2,
+ ]);
+ expect(searchSettings("provider new thread defaults")[0]).toMatchObject({
+ id: "provider-new-thread-defaults",
+ to: "/settings/providers",
+ targetId: "providers",
+ });
+ expect(
+ catalogItems.some(
+ (item) => item.id === "new-thread-defaults" || item.id === "new-threads-start-with",
+ ),
+ ).toBe(false);
+ });
+
it("indexes typography typefaces and sizes as separate settings", () => {
expect(searchSettings("typeface").map((item) => item.id)).toEqual([
"interface-font",
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index af55a4e99eef..c5f4ebe53b5a 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -155,7 +155,7 @@ export const SETTINGS_SEARCH_ITEMS = [
},
{
id: "new-threads",
- title: "New threads",
+ title: "New thread workspace",
to: "/settings/general",
},
{
@@ -164,6 +164,11 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
targetId: "new-threads",
},
+ {
+ id: "new-thread-model",
+ title: "New thread model",
+ to: "/settings/general",
+ },
{
id: "add-project-starts-in",
title: "Add project starts in",
@@ -220,6 +225,12 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Providers",
to: "/settings/providers",
},
+ {
+ id: "provider-new-thread-defaults",
+ title: "Provider new thread defaults",
+ to: "/settings/providers",
+ targetId: "providers",
+ },
{
id: "source-control",
title: "Source control",
diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts
index 3e4106c583f1..86450af9e320 100644
--- a/apps/web/src/composerDraftStore.test.ts
+++ b/apps/web/src/composerDraftStore.test.ts
@@ -14,7 +14,9 @@ import {
ThreadId,
type ModelSelection,
type ProviderOptionSelection,
+ type ServerProvider,
} from "@t3tools/contracts";
+import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
import { createModelSelection } from "@t3tools/shared/model";
// The composer draft's `modelSelectionByProvider` and
@@ -28,6 +30,41 @@ const CODEX_DRIVER = ProviderDriverKind.make("codex");
const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent");
const CURSOR_DRIVER = ProviderDriverKind.make("cursor");
+const configuredProvider: ServerProvider = {
+ instanceId: CODEX_INSTANCE,
+ driver: CODEX_DRIVER,
+ enabled: true,
+ installed: true,
+ version: null,
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-01-01T00:00:00.000Z",
+ models: [
+ {
+ slug: "gpt-test",
+ name: "GPT Test",
+ isCustom: false,
+ isDefault: true,
+ capabilities: {
+ optionDescriptors: [
+ {
+ id: "reasoningEffort",
+ label: "Reasoning",
+ type: "select",
+ options: [
+ { id: "low", label: "Low", isDefault: true },
+ { id: "high", label: "High" },
+ { id: "xhigh", label: "Extra high" },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ slashCommands: [],
+ skills: [],
+};
+
type ProviderOptionSelectionBag = ReadonlyArray;
type ProviderOptionSelectionsByProvider = Partial>;
@@ -60,6 +97,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
import {
COMPOSER_DRAFT_STORAGE_KEY,
clearComposerDraftsEnvironment,
+ deriveEffectiveComposerModelState,
finalizePromotedDraftThreadByRef,
markPromotedDraftThread,
markPromotedDraftThreadByRef,
@@ -1138,6 +1176,51 @@ describe("composerDraftStore project draft thread mapping", () => {
expect(useComposerDraftStore.getState().getDraftThread(draftId)?.startFromOrigin).toBe(false);
});
+ it("ignores the removed seed flag in legacy persisted draft sessions", () => {
+ const persistApi = useComposerDraftStore.persist as unknown as {
+ getOptions: () => {
+ merge: (
+ persistedState: unknown,
+ currentState: ReturnType,
+ ) => ReturnType;
+ };
+ };
+ const mergedState = persistApi.getOptions().merge(
+ {
+ draftsByThreadKey: {},
+ draftThreadsByThreadKey: {
+ [draftId]: {
+ threadId,
+ environmentId: TEST_ENVIRONMENT_ID,
+ projectId,
+ logicalProjectKey: scopedProjectKey(projectRef),
+ createdAt: "2026-08-17T00:00:00.000Z",
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ envMode: "local",
+ startFromOrigin: false,
+ hidden: false,
+ seededFromDefaults: true,
+ promotedTo: null,
+ },
+ },
+ logicalProjectDraftThreadKeyByLogicalProjectKey: {
+ [scopedProjectKey(projectRef)]: draftId,
+ },
+ },
+ useComposerDraftStore.getInitialState(),
+ );
+
+ expect(mergedState.draftThreadsByThreadKey[draftId]).toMatchObject({
+ threadId,
+ environmentId: TEST_ENVIRONMENT_ID,
+ projectId,
+ });
+ expect(mergedState.draftThreadsByThreadKey[draftId]).not.toHaveProperty("seededFromDefaults");
+ });
+
it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => {
const store = useComposerDraftStore.getState();
store.setProjectDraftThreadId(projectRef, draftId, {
@@ -1298,9 +1381,6 @@ describe("composerDraftStore modelSelection", () => {
threadRef,
CLAUDE_AGENT_DRIVER,
toSelections({ thinking: false }),
- {
- persistSticky: true,
- },
);
expect(
@@ -1314,7 +1394,8 @@ describe("composerDraftStore modelSelection", () => {
useComposerDraftStore.getState().stickyModelSelectionByProvider[CLAUDE_AGENT_INSTANCE],
).toEqual(
modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", {
- thinking: false,
+ effort: "max",
+ fastMode: true,
}),
);
});
@@ -1396,7 +1477,6 @@ describe("composerDraftStore modelSelection", () => {
store.setProviderModelOptions(threadRef, CURSOR_DRIVER, toSelections({ reasoning: "high" }), {
model: "gpt-5.4",
- persistSticky: true,
});
expect(
@@ -1408,11 +1488,7 @@ describe("composerDraftStore modelSelection", () => {
);
expect(
useComposerDraftStore.getState().stickyModelSelectionByProvider[CURSOR_INSTANCE],
- ).toEqual(
- modelSelection(CURSOR_DRIVER, "gpt-5.4", {
- reasoning: "high",
- }),
- );
+ ).toBeUndefined();
});
it("updates only the draft when sticky persistence is omitted", () => {
@@ -1496,20 +1572,16 @@ describe("composerDraftStore modelSelection", () => {
expect(draft?.activeProvider).toBe("claudeAgent");
});
- it("creates the first sticky snapshot from provider option changes", () => {
+ it("does not create a sticky snapshot from provider option changes", () => {
const store = useComposerDraftStore.getState();
store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"));
- store.setProviderModelOptions(threadRef, CODEX_DRIVER, toSelections({ fastMode: true }), {
- persistSticky: true,
- });
+ store.setProviderModelOptions(threadRef, CODEX_DRIVER, toSelections({ fastMode: true }));
- expect(useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_INSTANCE]).toEqual(
- modelSelection(CODEX_DRIVER, "gpt-5.4", {
- fastMode: true,
- }),
- );
+ expect(
+ useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_INSTANCE],
+ ).toBeUndefined();
});
it("stores provider option changes on a selected custom instance", () => {
@@ -1522,7 +1594,6 @@ describe("composerDraftStore modelSelection", () => {
{
instanceId: CODEX_SECONDARY_INSTANCE,
model: "gpt-5-codex",
- persistSticky: true,
},
);
@@ -1535,18 +1606,13 @@ describe("composerDraftStore modelSelection", () => {
}),
);
expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.activeProvider).toBe(CODEX_SECONDARY_INSTANCE);
- expect(useComposerDraftStore.getState().stickyActiveProvider).toBe(CODEX_SECONDARY_INSTANCE);
+ expect(useComposerDraftStore.getState().stickyActiveProvider).toBeNull();
expect(useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_INSTANCE]).toBe(
undefined,
);
expect(
useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_SECONDARY_INSTANCE],
- ).toEqual(
- expect.objectContaining({
- instanceId: CODEX_SECONDARY_INSTANCE,
- options: [{ id: "reasoningEffort", value: "low" }],
- }),
- );
+ ).toBeUndefined();
});
it("updates only the draft when sticky persistence is disabled", () => {
@@ -1564,9 +1630,6 @@ describe("composerDraftStore modelSelection", () => {
threadRef,
CLAUDE_AGENT_DRIVER,
toSelections({ thinking: false }),
- {
- persistSticky: false,
- },
);
expect(
@@ -1659,7 +1722,9 @@ describe("composerDraftStore sticky composer settings", () => {
const threadId = ThreadId.make("thread-sticky-active-provider");
const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
- store.setStickyModelSelection(modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6"));
+ store.setStickyModelSelection(
+ modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6", { effort: "max" }),
+ );
store.applyStickyState(threadRef);
expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toMatchObject({
@@ -1703,6 +1768,67 @@ describe("composerDraftStore provider-scoped option updates", () => {
});
});
+describe("deriveEffectiveComposerModelState configured defaults", () => {
+ const configuredSettings = {
+ ...DEFAULT_UNIFIED_SETTINGS,
+ providerNewThreadDefaults: {
+ [CODEX_INSTANCE]: {
+ modelOptions: [{ id: "reasoningEffort", value: "high" }],
+ },
+ },
+ };
+ const derive = (input?: {
+ draftOptions?: ReadonlyArray;
+ threadOptions?: ReadonlyArray;
+ projectOptions?: ReadonlyArray;
+ }) =>
+ deriveEffectiveComposerModelState({
+ draft: input?.draftOptions
+ ? {
+ activeProvider: CODEX_INSTANCE,
+ modelSelectionByProvider: {
+ [CODEX_INSTANCE]: createModelSelection(
+ CODEX_INSTANCE,
+ "gpt-test",
+ input.draftOptions,
+ ),
+ },
+ }
+ : null,
+ providers: [configuredProvider],
+ selectedProvider: CODEX_DRIVER,
+ selectedInstanceId: CODEX_INSTANCE,
+ threadModelSelection: input?.threadOptions
+ ? createModelSelection(CODEX_INSTANCE, "gpt-test", input.threadOptions)
+ : null,
+ projectModelSelection: input?.projectOptions
+ ? createModelSelection(CODEX_INSTANCE, "gpt-test", input.projectOptions)
+ : null,
+ settings: configuredSettings,
+ });
+
+ it("uses configured values before adapter defaults", () => {
+ expect(derive().modelOptions?.[CODEX_INSTANCE]).toEqual([
+ { id: "reasoningEffort", value: "high" },
+ ]);
+ });
+
+ it("keeps draft, thread, and project options ahead of configured values", () => {
+ const draft = [{ id: "reasoningEffort", value: "xhigh" }] as const;
+ const thread = [{ id: "reasoningEffort", value: "low" }] as const;
+ const project = [{ id: "reasoningEffort", value: "xhigh" }] as const;
+
+ expect(
+ derive({ draftOptions: draft, threadOptions: thread, projectOptions: project })
+ .modelOptions?.[CODEX_INSTANCE],
+ ).toEqual(draft);
+ expect(
+ derive({ threadOptions: thread, projectOptions: project }).modelOptions?.[CODEX_INSTANCE],
+ ).toEqual(thread);
+ expect(derive({ projectOptions: project }).modelOptions?.[CODEX_INSTANCE]).toEqual(project);
+ });
+});
+
describe("composerDraftStore runtime and interaction settings", () => {
const threadId = ThreadId.make("thread-settings");
const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts
index 61bd7162f7ae..f72a270ba67d 100644
--- a/apps/web/src/composerDraftStore.ts
+++ b/apps/web/src/composerDraftStore.ts
@@ -29,7 +29,11 @@ import * as Schema from "effect/Schema";
import * as Equal from "effect/Equal";
import * as Effect from "effect/Effect";
import { DeepMutable } from "effect/Types";
-import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
+import {
+ createModelSelection,
+ normalizeModelSlug,
+ resolveConfiguredProviderOptionDefaults,
+} from "@t3tools/shared/model";
import { useMemo } from "react";
import { getLocalStorageItem } from "./hooks/useLocalStorage";
import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection";
@@ -479,7 +483,6 @@ interface ComposerDraftStoreState {
options?: {
instanceId?: ProviderInstanceId | null | undefined;
model?: string | null | undefined;
- persistSticky?: boolean;
},
) => void;
setRuntimeMode: (
@@ -1078,15 +1081,41 @@ export function deriveEffectiveComposerModelState(input: {
activeSelection.model,
))
: baseModel;
- const modelOptions =
- modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ??
- providerSelectionsFromModelSelection(input.threadModelSelection) ??
- providerSelectionsFromModelSelection(input.projectModelSelection) ??
- null;
+ const draftOptions = modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider);
+ const threadOptions = providerSelectionsFromModelSelection(input.threadModelSelection);
+ const projectOptions = providerSelectionsFromModelSelection(input.projectModelSelection);
+ const combinedModelOptions = {
+ ...projectOptions,
+ ...threadOptions,
+ ...draftOptions,
+ };
+ const modelOptions = Object.keys(combinedModelOptions).length > 0 ? combinedModelOptions : null;
+ const activeInstanceId = input.selectedInstanceId ?? activeSelectionInstanceId;
+ const hasStoredActiveOptions =
+ modelOptions?.[activeInstanceId] !== undefined ||
+ (activeInstanceId !== ProviderInstanceId.make(input.selectedProvider) &&
+ modelOptions?.[ProviderInstanceId.make(input.selectedProvider)] !== undefined);
+ const selectedProviderSnapshot = input.providers.find(
+ (provider) => provider.instanceId === activeInstanceId,
+ );
+ const selectedModelSnapshot = selectedProviderSnapshot?.models.find(
+ (model) => model.slug === selectedModel,
+ );
+ const configuredOptions = hasStoredActiveOptions
+ ? undefined
+ : resolveConfiguredProviderOptionDefaults({
+ settings: input.settings,
+ instanceId: activeInstanceId,
+ descriptors: selectedModelSnapshot?.capabilities?.optionDescriptors ?? [],
+ });
+ const effectiveModelOptions =
+ configuredOptions && configuredOptions.length > 0
+ ? { ...modelOptions, [activeInstanceId]: configuredOptions }
+ : modelOptions;
return {
selectedModel,
- modelOptions,
+ modelOptions: effectiveModelOptions,
};
}
@@ -2689,10 +2718,11 @@ const composerDraftStore = create()(
// the typed lookup.
const instanceKey = provider as ProviderInstanceId;
const current = nextMap[instanceKey];
- nextMap[instanceKey] = {
- ...selection,
- model: current?.model ?? selection.model,
- };
+ nextMap[instanceKey] = createModelSelection(
+ instanceKey,
+ current?.model ?? selection.model,
+ current?.options,
+ );
}
}
if (
@@ -2891,35 +2921,7 @@ const composerDraftStore = create()(
nextMap[instanceKey] = rest as ModelSelection;
}
- // Handle sticky persistence
- let nextStickyMap = state.stickyModelSelectionByProvider;
- let nextStickyActiveProvider = state.stickyActiveProvider;
- if (options?.persistSticky === true) {
- nextStickyMap = { ...state.stickyModelSelectionByProvider };
- const stickyBase =
- nextStickyMap[instanceKey] ??
- base.modelSelectionByProvider[instanceKey] ??
- createModelSelection(instanceKey, fallbackModel);
- if (providerOpts) {
- nextStickyMap[instanceKey] = createModelSelection(
- instanceKey,
- stickyBase.model,
- providerOpts,
- );
- } else if ((stickyBase.options?.length ?? 0) > 0) {
- const { options: _, ...rest } = stickyBase;
- nextStickyMap[instanceKey] = rest as ModelSelection;
- }
- nextStickyActiveProvider = options.instanceId
- ? instanceKey
- : (base.activeProvider ?? instanceKey);
- }
-
- if (
- Equal.equals(base.modelSelectionByProvider, nextMap) &&
- Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) &&
- state.stickyActiveProvider === nextStickyActiveProvider
- ) {
+ if (Equal.equals(base.modelSelectionByProvider, nextMap)) {
return state;
}
@@ -2935,15 +2937,7 @@ const composerDraftStore = create()(
nextDraftsByThreadKey[threadKey] = nextDraft;
}
- return {
- draftsByThreadKey: nextDraftsByThreadKey,
- ...(options?.persistSticky === true
- ? {
- stickyModelSelectionByProvider: nextStickyMap,
- stickyActiveProvider: nextStickyActiveProvider,
- }
- : {}),
- };
+ return { draftsByThreadKey: nextDraftsByThreadKey };
});
},
setRuntimeMode: (threadRef, runtimeMode) => {
diff --git a/apps/web/src/draftDefaultsSync.test.ts b/apps/web/src/draftDefaultsSync.test.ts
new file mode 100644
index 000000000000..6c2ff24c883c
--- /dev/null
+++ b/apps/web/src/draftDefaultsSync.test.ts
@@ -0,0 +1,215 @@
+import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
+import {
+ EnvironmentId,
+ ProjectId,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ ThreadId,
+ type ServerConfig,
+ type ServerProvider,
+} from "@t3tools/contracts";
+import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
+import { createModelSelection } from "@t3tools/shared/model";
+import { beforeEach, describe, expect, it } from "vite-plus/test";
+import { DraftId, useComposerDraftStore } from "./composerDraftStore";
+import {
+ captureDraftDefaultsInputSnapshot,
+ isUnstartedDraft,
+ syncDraftDefaultsForChangedInputs,
+} from "./draftDefaultsSync";
+
+const environmentId = EnvironmentId.make("environment-draft-defaults");
+const projectId = ProjectId.make("project-draft-defaults");
+const projectRef = scopeProjectRef(environmentId, projectId);
+const instanceId = ProviderInstanceId.make("codex");
+const provider: ServerProvider = {
+ instanceId,
+ driver: ProviderDriverKind.make("codex"),
+ enabled: true,
+ installed: true,
+ version: null,
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-08-17T00:00:00.000Z",
+ models: ["old-model", "new-model"].map((slug, index) => ({
+ slug,
+ name: slug,
+ isCustom: false,
+ isDefault: index === 0,
+ capabilities: {
+ optionDescriptors: [
+ {
+ id: "reasoningEffort",
+ label: "Reasoning",
+ type: "select" as const,
+ options: [
+ { id: "low", label: "Low", isDefault: true },
+ { id: "high", label: "High" },
+ ],
+ },
+ ],
+ },
+ })),
+ slashCommands: [],
+ skills: [],
+};
+
+function makeConfig(input: {
+ model: "old-model" | "new-model" | null;
+ effort: "low" | "high";
+ runtimeMode: "approval-required" | "full-access";
+}) {
+ return {
+ providers: [provider],
+ settings: {
+ ...DEFAULT_UNIFIED_SETTINGS,
+ newThreadModel: input.model ? createModelSelection(instanceId, input.model) : null,
+ providerNewThreadDefaults: {
+ [instanceId]: {
+ modelOptions: [{ id: "reasoningEffort", value: input.effort }],
+ runtimeMode: input.runtimeMode,
+ },
+ },
+ },
+ } as unknown as ServerConfig;
+}
+
+function resetStore() {
+ useComposerDraftStore.setState({
+ draftsByThreadKey: {},
+ draftThreadsByThreadKey: {},
+ logicalProjectDraftThreadKeyByLogicalProjectKey: {},
+ stickyModelSelectionByProvider: {},
+ stickyActiveProvider: null,
+ });
+}
+
+function createDraftWithModel(name: string) {
+ const draftId = DraftId.make(name);
+ const store = useComposerDraftStore.getState();
+ store.setLogicalProjectDraftThreadId(scopedProjectKey(projectRef), projectRef, draftId, {
+ threadId: ThreadId.make(`${name}-thread`),
+ });
+ store.setModelSelection(
+ draftId,
+ createModelSelection(instanceId, "old-model", [{ id: "reasoningEffort", value: "low" }]),
+ { replaceOptions: true },
+ );
+ return draftId;
+}
+
+function syncAfterConfigChange(
+ previousConfig: ServerConfig,
+ currentConfig: ServerConfig,
+ hasStartedSession?: () => boolean,
+) {
+ const previousConfigs = new Map([[environmentId, previousConfig]]);
+ const currentConfigs = new Map([[environmentId, currentConfig]]);
+ return syncDraftDefaultsForChangedInputs({
+ previous: captureDraftDefaultsInputSnapshot(previousConfigs),
+ current: captureDraftDefaultsInputSnapshot(currentConfigs),
+ serverConfigs: currentConfigs,
+ ...(hasStartedSession ? { hasStartedSession } : {}),
+ });
+}
+
+const previousConfig = makeConfig({
+ model: "old-model",
+ effort: "low",
+ runtimeMode: "full-access",
+});
+const currentConfig = makeConfig({
+ model: "new-model",
+ effort: "high",
+ runtimeMode: "approval-required",
+});
+
+describe("draft defaults sync", () => {
+ beforeEach(resetStore);
+
+ it("treats every unpromoted local draft as unstarted", () => {
+ const draftId = createDraftWithModel("unstarted-detection");
+ const store = useComposerDraftStore.getState();
+ expect(isUnstartedDraft(store.getDraftSession(draftId)!)).toBe(true);
+
+ store.markDraftThreadPromoting(draftId);
+ expect(isUnstartedDraft(useComposerDraftStore.getState().getDraftSession(draftId)!)).toBe(
+ false,
+ );
+ });
+
+ it("re-seeds model, options, and runtime mode after defaults change", () => {
+ const draftId = createDraftWithModel("settings-change");
+
+ expect(syncAfterConfigChange(previousConfig, currentConfig)).toBe(1);
+ expect(useComposerDraftStore.getState().getComposerDraft(draftId)).toMatchObject({
+ activeProvider: instanceId,
+ runtimeMode: "approval-required",
+ modelSelectionByProvider: {
+ [instanceId]: {
+ instanceId,
+ model: "new-model",
+ options: [{ id: "reasoningEffort", value: "high" }],
+ },
+ },
+ });
+ });
+
+ it("re-seeds a draft with a typed prompt without changing its content", () => {
+ const draftId = createDraftWithModel("prompt-draft");
+ useComposerDraftStore.getState().setPrompt(draftId, "keep this prompt");
+
+ expect(syncAfterConfigChange(previousConfig, currentConfig)).toBe(1);
+ expect(useComposerDraftStore.getState().getComposerDraft(draftId)).toMatchObject({
+ prompt: "keep this prompt",
+ modelSelectionByProvider: {
+ [instanceId]: { model: "new-model" },
+ },
+ });
+ });
+
+ it("does not re-seed a promoted draft", () => {
+ const draftId = createDraftWithModel("promoted-draft");
+ useComposerDraftStore.getState().markDraftThreadPromoting(draftId);
+
+ expect(syncAfterConfigChange(previousConfig, currentConfig)).toBe(0);
+ expect(
+ useComposerDraftStore.getState().getComposerDraft(draftId)?.modelSelectionByProvider[
+ instanceId
+ ]?.model,
+ ).toBe("old-model");
+ });
+
+ it("does not re-seed a draft with a started server session", () => {
+ const draftId = createDraftWithModel("started-draft");
+
+ expect(syncAfterConfigChange(previousConfig, currentConfig, () => true)).toBe(0);
+ expect(
+ useComposerDraftStore.getState().getComposerDraft(draftId)?.modelSelectionByProvider[
+ instanceId
+ ]?.model,
+ ).toBe("old-model");
+ });
+
+ it("does not re-seed when settings are unchanged", () => {
+ const draftId = createDraftWithModel("unchanged-settings");
+ const configs = new Map([[environmentId, previousConfig]]);
+ const snapshot = captureDraftDefaultsInputSnapshot(configs);
+ useComposerDraftStore
+ .getState()
+ .setStickyModelSelection(createModelSelection(instanceId, "new-model"));
+
+ expect(
+ syncDraftDefaultsForChangedInputs({
+ previous: snapshot,
+ current: snapshot,
+ serverConfigs: configs,
+ }),
+ ).toBe(0);
+ expect(
+ useComposerDraftStore.getState().getComposerDraft(draftId)?.modelSelectionByProvider[
+ instanceId
+ ]?.model,
+ ).toBe("old-model");
+ });
+});
diff --git a/apps/web/src/draftDefaultsSync.tsx b/apps/web/src/draftDefaultsSync.tsx
new file mode 100644
index 000000000000..72a21226c9f9
--- /dev/null
+++ b/apps/web/src/draftDefaultsSync.tsx
@@ -0,0 +1,134 @@
+import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
+import { type EnvironmentId, type ServerConfig } from "@t3tools/contracts";
+import { useEffect, useRef } from "react";
+import { DraftId, type DraftSessionState, useComposerDraftStore } from "./composerDraftStore";
+import { resolveNewThreadConfiguredState } from "./hooks/useHandleNewThread";
+import { readThreadShell, useServerConfigs } from "./state/entities";
+
+export interface DraftDefaultsInputSnapshot {
+ readonly settingsByEnvironment: ReadonlyMap;
+}
+
+export function captureDraftDefaultsInputSnapshot(
+ serverConfigs: ReadonlyMap,
+): DraftDefaultsInputSnapshot {
+ return {
+ settingsByEnvironment: new Map(
+ Array.from(serverConfigs, ([environmentId, config]) => [
+ environmentId,
+ JSON.stringify({
+ newThreadModel: config.settings.newThreadModel,
+ providerNewThreadDefaults: config.settings.providerNewThreadDefaults,
+ }),
+ ]),
+ ),
+ };
+}
+
+function storedInputChanged(
+ previous: ReadonlyMap,
+ current: ReadonlyMap,
+ key: TKey,
+): boolean {
+ const previousValue = previous.get(key);
+ const currentValue = current.get(key);
+ return (
+ previousValue !== undefined && currentValue !== undefined && previousValue !== currentValue
+ );
+}
+
+export function isUnstartedDraft(session: DraftSessionState): boolean {
+ return session.promotedTo == null;
+}
+
+export function syncDraftDefaultsForChangedInputs(input: {
+ previous: DraftDefaultsInputSnapshot;
+ current: DraftDefaultsInputSnapshot;
+ serverConfigs: ReadonlyMap;
+ hasStartedSession?: (
+ environmentId: EnvironmentId,
+ threadId: DraftSessionState["threadId"],
+ ) => boolean;
+}): number {
+ const store = useComposerDraftStore.getState();
+ let reseededCount = 0;
+
+ for (const draftKey of Object.keys(store.draftThreadsByThreadKey)) {
+ const currentStore = useComposerDraftStore.getState();
+ const session = currentStore.draftThreadsByThreadKey[draftKey];
+ if (!session) {
+ continue;
+ }
+ const inputsChanged = storedInputChanged(
+ input.previous.settingsByEnvironment,
+ input.current.settingsByEnvironment,
+ session.environmentId,
+ );
+ if (!inputsChanged || !isUnstartedDraft(session)) {
+ continue;
+ }
+ if (input.hasStartedSession?.(session.environmentId, session.threadId) === true) {
+ continue;
+ }
+
+ const serverConfig = input.serverConfigs.get(session.environmentId);
+ if (!serverConfig) {
+ continue;
+ }
+
+ const projectRef = scopeProjectRef(session.environmentId, session.projectId);
+ const latestStore = useComposerDraftStore.getState();
+ const stickySelection = latestStore.stickyActiveProvider
+ ? latestStore.stickyModelSelectionByProvider[latestStore.stickyActiveProvider]
+ : null;
+ const resolved = resolveNewThreadConfiguredState({
+ configuredModel: serverConfig.settings.newThreadModel,
+ carryModel: null,
+ stickyActiveProvider: stickySelection,
+ providers: serverConfig.providers,
+ settings: serverConfig.settings,
+ });
+ const draftId = DraftId.make(draftKey);
+ latestStore.setDraftThreadContext(draftId, { runtimeMode: resolved.runtimeMode });
+ if (
+ latestStore.logicalProjectDraftThreadKeyByLogicalProjectKey[session.logicalProjectKey] ===
+ draftKey
+ ) {
+ latestStore.setLogicalProjectDraftThreadId(session.logicalProjectKey, projectRef, draftId, {
+ threadId: session.threadId,
+ createdAt: session.createdAt,
+ runtimeMode: resolved.runtimeMode,
+ });
+ }
+ if (resolved.modelSelection) {
+ latestStore.setModelSelection(draftId, resolved.modelSelection, { replaceOptions: true });
+ }
+ latestStore.setRuntimeMode(draftId, resolved.runtimeMode);
+ reseededCount += 1;
+ }
+
+ return reseededCount;
+}
+
+export function DraftDefaultsSync() {
+ const serverConfigs = useServerConfigs();
+ const previousSnapshotRef = useRef(null);
+
+ useEffect(() => {
+ const current = captureDraftDefaultsInputSnapshot(serverConfigs);
+ const previous = previousSnapshotRef.current;
+ previousSnapshotRef.current = current;
+ if (!previous) {
+ return;
+ }
+ syncDraftDefaultsForChangedInputs({
+ previous,
+ current,
+ serverConfigs,
+ hasStartedSession: (environmentId, threadId) =>
+ readThreadShell(scopeThreadRef(environmentId, threadId)) !== null,
+ });
+ }, [serverConfigs]);
+
+ return null;
+}
diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts
new file mode 100644
index 000000000000..59241e390b3f
--- /dev/null
+++ b/apps/web/src/hooks/useHandleNewThread.test.ts
@@ -0,0 +1,119 @@
+import {
+ ProviderDriverKind,
+ ProviderInstanceId,
+ type ModelSelection,
+ type ServerProvider,
+} from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveNewThreadConfiguredState } from "./useHandleNewThread";
+
+const codexId = ProviderInstanceId.make("codex");
+const claudeId = ProviderInstanceId.make("claudeAgent");
+
+function provider(input: {
+ readonly instanceId: typeof codexId;
+ readonly driver: string;
+ readonly model: string;
+ readonly enabled?: boolean;
+}): ServerProvider {
+ return {
+ instanceId: input.instanceId,
+ driver: ProviderDriverKind.make(input.driver),
+ enabled: input.enabled ?? true,
+ installed: true,
+ version: null,
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-01-01T00:00:00.000Z",
+ models: [
+ {
+ slug: input.model,
+ name: input.model,
+ isCustom: false,
+ isDefault: true,
+ capabilities: {
+ optionDescriptors: [
+ {
+ id: "effort",
+ label: "Effort",
+ type: "select",
+ options: [
+ { id: "low", label: "Low", isDefault: true },
+ { id: "high", label: "High" },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ slashCommands: [],
+ skills: [],
+ };
+}
+
+const providers = [
+ provider({ instanceId: codexId, driver: "codex", model: "gpt" }),
+ provider({ instanceId: claudeId, driver: "claudeAgent", model: "claude" }),
+];
+const selection = (instanceId: typeof codexId, model: string): ModelSelection => ({
+ instanceId,
+ model,
+});
+const settings = {
+ providerNewThreadDefaults: {
+ [claudeId]: {
+ modelOptions: [{ id: "effort", value: "high" }],
+ runtimeMode: "approval-required" as const,
+ },
+ },
+};
+
+function resolve(input: {
+ configuredModel?: ModelSelection | null;
+ carryModel?: ModelSelection | null;
+ providersOverride?: ReadonlyArray;
+}) {
+ return resolveNewThreadConfiguredState({
+ configuredModel: input.configuredModel ?? null,
+ carryModel: input.carryModel ?? null,
+ stickyActiveProvider: selection(codexId, "gpt"),
+ providers: input.providersOverride ?? providers,
+ settings,
+ });
+}
+
+describe("new-thread model defaults", () => {
+ it("uses Always model before carry and sticky, with configured options and runtime mode", () => {
+ const result = resolve({
+ configuredModel: selection(claudeId, "claude"),
+ carryModel: selection(codexId, "gpt"),
+ });
+
+ expect(result).toEqual({
+ modelSelection: {
+ instanceId: claudeId,
+ model: "claude",
+ options: [{ id: "effort", value: "high" }],
+ },
+ runtimeMode: "approval-required",
+ });
+ });
+
+ it("keeps the last-used carry path when Always is null", () => {
+ expect(resolve({ carryModel: selection(claudeId, "claude") }).modelSelection?.instanceId).toBe(
+ claudeId,
+ );
+ });
+
+ it("falls through when the configured instance is disabled", () => {
+ const disabledProviders = [providers[0]!, { ...providers[1]!, enabled: false }];
+ expect(
+ resolve({
+ configuredModel: selection(claudeId, "claude"),
+ carryModel: selection(codexId, "gpt"),
+ providersOverride: disabledProviders,
+ }).modelSelection?.instanceId,
+ ).toBe(codexId);
+ });
+});
diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts
index 64176c0873a7..8484b95c6191 100644
--- a/apps/web/src/hooks/useHandleNewThread.ts
+++ b/apps/web/src/hooks/useHandleNewThread.ts
@@ -4,7 +4,14 @@ import {
scopeProjectRef,
scopeThreadRef,
} from "@t3tools/client-runtime/environment";
-import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts";
+import {
+ DEFAULT_RUNTIME_MODE,
+ type ModelSelection,
+ type ScopedProjectRef,
+ type ServerProvider,
+ type ServerSettings,
+ type ThreadId,
+} from "@t3tools/contracts";
import { useParams, useRouter } from "@tanstack/react-router";
import { useCallback, useMemo } from "react";
import {
@@ -23,7 +30,12 @@ import {
selectProjectGroupingSettings,
} from "../logicalProject";
import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
-import { readThreadShell, useProjects, useThread } from "../state/entities";
+import {
+ createModelSelection,
+ resolveConfiguredProviderOptionDefaults,
+ resolveConfiguredRuntimeMode,
+} from "@t3tools/shared/model";
+import { readThreadShell, useProjects, useServerConfigs, useThread } from "../state/entities";
import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";
import { primaryServerSettingsAtom } from "../state/server";
@@ -38,6 +50,78 @@ interface NewThreadWorkspaceOptions {
startFromOrigin?: boolean;
}
+function resolveSelectableNewThreadSelection(
+ selection: ModelSelection | null | undefined,
+ providers: ReadonlyArray,
+): ModelSelection | null {
+ if (!selection) return null;
+ const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId);
+ if (
+ !provider?.enabled ||
+ !provider.installed ||
+ provider.availability === "unavailable" ||
+ provider.auth.status === "unauthenticated" ||
+ !provider.models.some((model) => model.slug === selection.model && model.isLegacy !== true)
+ ) {
+ return null;
+ }
+ return createModelSelection(selection.instanceId, selection.model);
+}
+
+function resolveAdapterDefaultSelection(
+ providers: ReadonlyArray,
+): ModelSelection | null {
+ for (const provider of providers) {
+ if (
+ !provider.enabled ||
+ !provider.installed ||
+ provider.availability === "unavailable" ||
+ provider.auth.status === "unauthenticated"
+ ) {
+ continue;
+ }
+ const model =
+ provider.models.find((candidate) => candidate.isDefault && candidate.isLegacy !== true) ??
+ provider.models.find((candidate) => candidate.isLegacy !== true);
+ if (model) return createModelSelection(provider.instanceId, model.slug);
+ }
+ return null;
+}
+
+export function resolveNewThreadConfiguredState(input: {
+ configuredModel: ModelSelection | null | undefined;
+ carryModel: ModelSelection | null | undefined;
+ stickyActiveProvider: ModelSelection | null | undefined;
+ providers: ReadonlyArray;
+ settings: Pick;
+}) {
+ const modelSelection =
+ resolveSelectableNewThreadSelection(input.configuredModel, input.providers) ??
+ resolveSelectableNewThreadSelection(input.carryModel, input.providers) ??
+ resolveSelectableNewThreadSelection(input.stickyActiveProvider, input.providers) ??
+ resolveAdapterDefaultSelection(input.providers);
+ if (!modelSelection) {
+ return { modelSelection: null, runtimeMode: DEFAULT_RUNTIME_MODE };
+ }
+ const provider = input.providers.find(
+ (candidate) => candidate.instanceId === modelSelection.instanceId,
+ );
+ const model = provider?.models.find((candidate) => candidate.slug === modelSelection.model);
+ const options = resolveConfiguredProviderOptionDefaults({
+ settings: input.settings,
+ instanceId: modelSelection.instanceId,
+ descriptors: model?.capabilities?.optionDescriptors ?? [],
+ });
+ return {
+ modelSelection: createModelSelection(modelSelection.instanceId, modelSelection.model, options),
+ // Permission mode is provider-bound: the provider's configured default,
+ // else the app default. It never carries over from the viewed thread.
+ runtimeMode:
+ resolveConfiguredRuntimeMode(input.settings, modelSelection.instanceId) ??
+ DEFAULT_RUNTIME_MODE,
+ };
+}
+
// The workspace options the caller passed explicitly, shaped for the draft
// store: absent keys stay absent so they never overwrite existing draft
// state. Every reuse path applies exactly this set.
@@ -52,6 +136,7 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef
export function useNewThreadHandler() {
const projects = useProjects();
+ const serverConfigs = useServerConfigs();
// New-thread defaults are a user preference, and the settings UI only ever
// edits the primary environment's settings.json. Reading the target
// environment's own settings here would silently reset remote projects to
@@ -97,12 +182,15 @@ export function useNewThreadHandler() {
setDraftThreadContext,
setLogicalProjectDraftThreadId,
setModelSelection,
+ setRuntimeMode,
+ stickyActiveProvider,
+ stickyModelSelectionByProvider,
} = useComposerDraftStore.getState();
const currentRouteTarget = getCurrentRouteTarget();
- // A new thread carries the user's *working mode* from the thread being
- // viewed: model (including options like reasoning effort and context
- // window), permission mode, and interaction mode. Branch, worktree, and
- // env mode never carry implicitly — those come from the configured
+ // A new thread can carry the viewed provider/model and interaction
+ // mode. Model options and permission mode come from the selected
+ // provider's configured new-thread defaults instead. Branch, worktree,
+ // and env mode never carry implicitly — those come from the configured
// defaults unless the caller passes them explicitly.
const carrySourceShell =
currentRouteTarget?.kind === "server"
@@ -125,11 +213,6 @@ export function useNewThreadHandler() {
: null;
const carryModelSelection =
composerModelSelection ?? carrySourceShell?.modelSelection ?? null;
- const carryRuntimeMode =
- carrySourceComposer?.runtimeMode ??
- carrySourceShell?.runtimeMode ??
- carrySourceDraft?.runtimeMode ??
- null;
const carryInteractionMode =
carrySourceComposer?.interactionMode ??
carrySourceShell?.interactionMode ??
@@ -161,6 +244,19 @@ export function useNewThreadHandler() {
candidate.id === projectRef.projectId &&
candidate.environmentId === projectRef.environmentId,
);
+ const targetServerConfig = serverConfigs.get(projectRef.environmentId);
+ const targetProviders = targetServerConfig?.providers ?? [];
+ const targetSettings = targetServerConfig?.settings ?? primaryServerSettings;
+ const stickyModelSelection = stickyActiveProvider
+ ? stickyModelSelectionByProvider[stickyActiveProvider]
+ : null;
+ const newThreadState = resolveNewThreadConfiguredState({
+ configuredModel: targetSettings.newThreadModel,
+ carryModel: carryModelSelection,
+ stickyActiveProvider: stickyModelSelection,
+ providers: targetProviders,
+ settings: targetSettings,
+ });
// The shared resolver owns the priority order. The t3.json read is
// skipped entirely when a higher-priority source decides, and its
// query atom caches per project after the first call.
@@ -270,18 +366,19 @@ export function useNewThreadHandler() {
if (workspaceContext) {
setDraftThreadContext(emptyStoredDraftThread.draftId, {
...workspaceContext,
- ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),
+ runtimeMode: newThreadState.runtimeMode,
...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
});
- if (carryModelSelection) {
- // The carried selection is a complete snapshot of the viewed
- // thread's model state: absent options mean "no options", not
- // "keep the stale draft's options".
- setModelSelection(emptyStoredDraftThread.draftId, carryModelSelection, {
- replaceOptions: true,
- });
- }
}
+ // "New thread" always means current defaults for model, traits, and
+ // permission mode, even when the reused draft is already open and
+ // its workspace context is left alone above.
+ if (newThreadState.modelSelection) {
+ setModelSelection(emptyStoredDraftThread.draftId, newThreadState.modelSelection, {
+ replaceOptions: true,
+ });
+ }
+ setRuntimeMode(emptyStoredDraftThread.draftId, newThreadState.runtimeMode);
// The workspace context must also ride along here: when projectRef
// targets a different physical member of the logical project,
// createDraftThreadState treats the remap as a project change and
@@ -293,7 +390,7 @@ export function useNewThreadHandler() {
{
threadId: emptyStoredDraftThread.threadId,
...workspaceContext,
- ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),
+ runtimeMode: newThreadState.runtimeMode,
...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
},
);
@@ -405,17 +502,12 @@ export function useNewThreadHandler() {
envMode: initialEnvMode,
newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,
}),
- runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE,
+ runtimeMode: newThreadState.runtimeMode,
...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
});
applyStickyState(draftId);
- if (carryModelSelection) {
- // After sticky state so the viewed thread's exact selection
- // (model + options like effort and context window) wins over the
- // globally sticky one. replaceOptions: the carried selection is a
- // complete snapshot — absent options mean "no options", not "keep
- // whatever sticky state just wrote".
- setModelSelection(draftId, carryModelSelection, { replaceOptions: true });
+ if (newThreadState.modelSelection) {
+ setModelSelection(draftId, newThreadState.modelSelection, { replaceOptions: true });
}
carryComposerContentTo(draftId);
@@ -427,7 +519,14 @@ export function useNewThreadHandler() {
return { draftId, threadId };
})();
},
- [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, projects, router],
+ [
+ getCurrentRouteTarget,
+ primaryServerSettings,
+ projectGroupingSettings,
+ projects,
+ router,
+ serverConfigs,
+ ],
);
}
diff --git a/docs/user/composer.md b/docs/user/composer.md
index d2e49db247b0..f3c6bb48ef28 100644
--- a/docs/user/composer.md
+++ b/docs/user/composer.md
@@ -3,3 +3,20 @@
Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the
composer and shows how many characters need to be removed. Shorten the draft or split it into
multiple messages, then send again in the same thread.
+
+## New thread defaults
+
+Set which model every new thread starts with under **Settings > General > New thread model**, so
+you are not adjusting the model picker before each first message. Set each provider's traits and
+permission mode in its card under **Settings > Providers**.
+
+- **New thread model** is either the last model you used (the default) or one specific model.
+- Each enabled provider has its own defaults for the traits it supports, such as reasoning effort,
+ context window, and speed, plus a permission mode. The trait values shown are what new threads
+ on that provider start with; traits a model does not offer are skipped. Reset a provider's
+ defaults to return every model to its own defaults.
+
+A new draft opens with the chosen model and that provider's defaults already applied. Switching to
+another provider in an unstarted draft applies that provider's defaults and permission mode. A
+draft keeps following your current defaults until the thread starts; your typed prompt is never
+affected. Threads that already started are not changed.
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index f578f7ef7834..7f942d700204 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -193,6 +193,63 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
});
});
+describe("ServerSettings new-thread defaults", () => {
+ it("decodes legacy settings with last-used model and no provider defaults", () => {
+ const decoded = decodeServerSettings({});
+
+ expect(decoded.newThreadModel).toBeNull();
+ expect(decoded.providerNewThreadDefaults).toEqual({});
+ });
+
+ it("round-trips configured model, options, and runtime mode through patches", () => {
+ const patch = decodeServerSettingsPatch({
+ newThreadModel: { instanceId: "codex_personal", model: "gpt-5.6-sol" },
+ providerNewThreadDefaults: {
+ codex_personal: {
+ modelOptions: [
+ { id: "reasoningEffort", value: "high" },
+ { id: "fastMode", value: true },
+ ],
+ runtimeMode: "auto-accept-edits",
+ },
+ },
+ });
+
+ expect(patch).toEqual({
+ newThreadModel: { instanceId: "codex_personal", model: "gpt-5.6-sol" },
+ providerNewThreadDefaults: {
+ codex_personal: {
+ modelOptions: [
+ { id: "reasoningEffort", value: "high" },
+ { id: "fastMode", value: true },
+ ],
+ runtimeMode: "auto-accept-edits",
+ },
+ },
+ });
+ const decoded = decodeServerSettings({
+ newThreadModel: patch.newThreadModel,
+ providerNewThreadDefaults: patch.providerNewThreadDefaults,
+ });
+ expect(encodeServerSettings(decoded).newThreadModel).toEqual(patch.newThreadModel);
+ expect(encodeServerSettings(decoded).providerNewThreadDefaults).toEqual(
+ patch.providerNewThreadDefaults,
+ );
+ });
+
+ it("strips options from the global model choice", () => {
+ expect(
+ decodeServerSettings({
+ newThreadModel: {
+ instanceId: "codex",
+ model: "gpt-5.6-sol",
+ options: [{ id: "reasoningEffort", value: "high" }],
+ },
+ }).newThreadModel,
+ ).toEqual({ instanceId: "codex", model: "gpt-5.6-sol" });
+ });
+});
+
describe("ServerSettings worktree defaults", () => {
it("defaults start-from-origin on for legacy configs", () => {
expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(true);
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 8a60280a13f8..62d8dd77debe 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -7,9 +7,10 @@ import { ThreadEnvMode } from "./environment.ts";
import {
DEFAULT_TEXT_GENERATION_MODEL,
DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
+ ProviderOptionSelection,
ProviderOptionSelections,
} from "./model.ts";
-import { ModelSelection } from "./orchestration.ts";
+import { ModelSelection, RuntimeMode } from "./orchestration.ts";
import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts";
// ── Client Settings (local-only) ───────────────────────────────
@@ -561,6 +562,34 @@ export const BackgroundActivitySettings = Schema.Struct({
}).pipe(Schema.withDecodingDefault(Effect.succeed({})));
export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type;
+export const ProviderNewThreadDefaults = Schema.Struct({
+ modelOptions: Schema.optional(Schema.Array(ProviderOptionSelection)),
+ runtimeMode: Schema.optional(RuntimeMode),
+});
+export type ProviderNewThreadDefaults = typeof ProviderNewThreadDefaults.Type;
+
+const NewThreadModelSelectionWire = Schema.Struct({
+ instanceId: ProviderInstanceId,
+ model: TrimmedNonEmptyString,
+});
+const NewThreadModelSelection = ModelSelection.pipe(
+ Schema.decodeTo(
+ NewThreadModelSelectionWire,
+ SchemaTransformation.transformOrFail({
+ decode: (selection) =>
+ Effect.succeed({
+ instanceId: selection.instanceId,
+ model: selection.model,
+ } as typeof NewThreadModelSelectionWire.Encoded),
+ encode: (selection) =>
+ Effect.succeed({
+ instanceId: selection.instanceId,
+ model: selection.model,
+ } as ModelSelection),
+ }),
+ ),
+);
+
export const ServerSettings = Schema.Struct({
// Legacy token-by-token assistant output. Deliberately a fresh key (was
// `enableAssistantStreaming`): decoding drops the old key, so everyone,
@@ -592,6 +621,12 @@ export const ServerSettings = Schema.Struct({
Schema.withDecodingDefault(Effect.succeed(true)),
),
addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
+ newThreadModel: Schema.NullOr(NewThreadModelSelection).pipe(
+ Schema.withDecodingDefault(Effect.succeed(null)),
+ ),
+ providerNewThreadDefaults: Schema.Record(ProviderInstanceId, ProviderNewThreadDefaults).pipe(
+ Schema.withDecodingDefault(Effect.succeed({})),
+ ),
textGenerationModelSelection: ModelSelection.pipe(
Schema.withDecodingDefault(
Effect.succeed({
@@ -747,6 +782,10 @@ export const ServerSettingsPatch = Schema.Struct({
defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
addProjectBaseDirectory: Schema.optionalKey(TrimmedString),
+ newThreadModel: Schema.optionalKey(Schema.NullOr(NewThreadModelSelection)),
+ providerNewThreadDefaults: Schema.optionalKey(
+ Schema.Record(ProviderInstanceId, ProviderNewThreadDefaults),
+ ),
textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
sourceControlWritingStyle: Schema.optionalKey(
Schema.Struct({
diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts
index b67c45744734..48a0b7e2776d 100644
--- a/packages/shared/src/model.test.ts
+++ b/packages/shared/src/model.test.ts
@@ -12,6 +12,8 @@ import {
getProviderOptionStringSelectionValue,
normalizeCustomModelSlug,
normalizeModelSlug,
+ resolveConfiguredProviderOptionDefaults,
+ resolveConfiguredRuntimeMode,
} from "./model.ts";
const codexCaps: ModelCapabilities = createModelCapabilities({
@@ -155,3 +157,60 @@ describe("model slug normalization", () => {
expect(normalizeCustomModelSlug(" opus ")).toBe("opus");
});
});
+
+describe("configured provider defaults", () => {
+ const instanceId = ProviderInstanceId.make("codex");
+ const descriptors = codexCaps.optionDescriptors ?? [];
+
+ it("uses valid configured values and drops unsupported option ids", () => {
+ expect(
+ resolveConfiguredProviderOptionDefaults({
+ settings: {
+ providerNewThreadDefaults: {
+ [instanceId]: {
+ modelOptions: [
+ { id: "reasoningEffort", value: "xhigh" },
+ { id: "fastMode", value: true },
+ { id: "unknown", value: "value" },
+ ],
+ },
+ },
+ },
+ instanceId,
+ descriptors,
+ }),
+ ).toEqual([
+ { id: "reasoningEffort", value: "xhigh" },
+ { id: "fastMode", value: true },
+ ]);
+ });
+
+ it("falls back per option for invalid values and unknown instances", () => {
+ const settings = {
+ providerNewThreadDefaults: {
+ [instanceId]: { modelOptions: [{ id: "reasoningEffort", value: "invalid" }] },
+ },
+ };
+
+ expect(resolveConfiguredProviderOptionDefaults({ settings, instanceId, descriptors })).toEqual([
+ { id: "reasoningEffort", value: "high" },
+ ]);
+ expect(
+ resolveConfiguredProviderOptionDefaults({
+ settings,
+ instanceId: ProviderInstanceId.make("codex_other"),
+ descriptors,
+ }),
+ ).toEqual([{ id: "reasoningEffort", value: "high" }]);
+ });
+
+ it("resolves configured runtime mode independently", () => {
+ const settings = {
+ providerNewThreadDefaults: { [instanceId]: { runtimeMode: "approval-required" as const } },
+ };
+ expect(resolveConfiguredRuntimeMode(settings, instanceId)).toBe("approval-required");
+ expect(
+ resolveConfiguredRuntimeMode(settings, ProviderInstanceId.make("claudeAgent")),
+ ).toBeNull();
+ });
+});
diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts
index bdc0c0cc8efb..e858a9955694 100644
--- a/packages/shared/src/model.ts
+++ b/packages/shared/src/model.ts
@@ -8,6 +8,8 @@ import {
ProviderInstanceId,
type ProviderOptionDescriptor,
type ProviderOptionSelection,
+ type RuntimeMode,
+ type ServerSettings,
} from "@t3tools/contracts";
const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex");
@@ -212,6 +214,41 @@ export function buildProviderOptionSelectionsFromDescriptors(
return nextSelections.length > 0 ? nextSelections : undefined;
}
+export function resolveConfiguredProviderOptionDefaults(input: {
+ settings: Pick;
+ instanceId: ProviderInstanceId;
+ descriptors: ReadonlyArray;
+}): ReadonlyArray {
+ const configured = input.settings.providerNewThreadDefaults[input.instanceId]?.modelOptions;
+ const configuredById = new Map(configured?.map((selection) => [selection.id, selection.value]));
+ const selections: ProviderOptionSelection[] = [];
+
+ for (const descriptor of input.descriptors) {
+ const configuredValue = configuredById.get(descriptor.id);
+ const value =
+ descriptor.type === "boolean"
+ ? typeof configuredValue === "boolean"
+ ? configuredValue
+ : getProviderOptionCurrentValue(descriptor)
+ : typeof configuredValue === "string" &&
+ descriptor.options.some((option) => option.id === configuredValue)
+ ? configuredValue
+ : getProviderOptionCurrentValue(descriptor);
+ if (typeof value === "string" || typeof value === "boolean") {
+ selections.push({ id: descriptor.id, value });
+ }
+ }
+
+ return selections;
+}
+
+export function resolveConfiguredRuntimeMode(
+ settings: Pick,
+ instanceId: ProviderInstanceId,
+): RuntimeMode | null {
+ return settings.providerNewThreadDefaults[instanceId]?.runtimeMode ?? null;
+}
+
export function getModelSelectionOptionDescriptors(
modelSelection: ModelSelection | null | undefined,
caps?: ModelCapabilities | null | undefined,
diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts
index baa84a4e1aa8..7937b410ecfb 100644
--- a/packages/shared/src/serverSettings.test.ts
+++ b/packages/shared/src/serverSettings.test.ts
@@ -18,6 +18,31 @@ import {
} from "./serverSettings.ts";
describe("serverSettings helpers", () => {
+ it("replaces new-thread defaults so resets remove stale instance entries", () => {
+ const codexId = ProviderInstanceId.make("codex");
+ const claudeId = ProviderInstanceId.make("claudeAgent");
+ const current = {
+ ...DEFAULT_SERVER_SETTINGS,
+ newThreadModel: createModelSelection(codexId, "gpt-5.6-sol"),
+ providerNewThreadDefaults: {
+ [codexId]: { runtimeMode: "approval-required" as const },
+ [claudeId]: { modelOptions: [{ id: "effort", value: "high" }] },
+ },
+ };
+
+ const next = applyServerSettingsPatch(current, {
+ newThreadModel: null,
+ providerNewThreadDefaults: {
+ [claudeId]: { modelOptions: [{ id: "effort", value: "low" }] },
+ },
+ });
+
+ expect(next.newThreadModel).toBeNull();
+ expect(next.providerNewThreadDefaults).toEqual({
+ [claudeId]: { modelOptions: [{ id: "effort", value: "low" }] },
+ });
+ });
+
it("normalizes optional persisted strings", () => {
expect(normalizePersistedServerSettingString(undefined)).toBeUndefined();
expect(normalizePersistedServerSettingString(" ")).toBeUndefined();
diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts
index 21d819a1c9ea..767b5e50db8e 100644
--- a/packages/shared/src/serverSettings.ts
+++ b/packages/shared/src/serverSettings.ts
@@ -187,6 +187,10 @@ export function applyServerSettingsPatch(
...(patch.providerInstances !== undefined
? { providerInstances: patch.providerInstances }
: {}),
+ ...(patch.providerNewThreadDefaults !== undefined
+ ? { providerNewThreadDefaults: patch.providerNewThreadDefaults }
+ : {}),
+ ...(patch.newThreadModel !== undefined ? { newThreadModel: patch.newThreadModel } : {}),
...(patch.sourceControlWriterModelSelection !== undefined
? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection }
: {}),