From e1fe8d5d4334528b9628d6d794de188d131f5153 Mon Sep 17 00:00:00 2001
From: Adam Firestone
Date: Wed, 19 Aug 2026 15:57:51 -0500
Subject: [PATCH] feat: configure provider defaults for new threads
- Add per-provider model traits and permission-mode defaults
- Apply configured defaults across web and mobile new-thread flows
---
.../threads/new-task-flow-provider.tsx | 97 +++++---
apps/mobile/src/lib/modelOptions.test.ts | 89 +++++++-
apps/mobile/src/lib/modelOptions.ts | 51 +++++
apps/web/src/AppRoot.tsx | 2 +
apps/web/src/components/ChatView.tsx | 39 +++-
apps/web/src/components/chat/ChatComposer.tsx | 35 +--
apps/web/src/components/chat/TraitsPicker.tsx | 12 +-
.../src/components/chat/runtimeModeConfig.ts | 35 +++
.../settings/ProjectSettingsPanel.tsx | 91 +-------
.../settings/ProviderInstanceCard.tsx | 21 ++
.../ProviderNewThreadDefaultsSection.tsx | 172 ++++++++++++++
.../ProviderSettingsPanel.logic.test.ts | 25 ++
.../settings/ProviderSettingsPanel.logic.ts | 20 ++
.../settings/ProviderSettingsPanel.tsx | 14 ++
.../settings/SettingsPanels.logic.ts | 3 +-
.../components/settings/SettingsPanels.tsx | 81 ++++++-
.../settings/settingsSearch.test.ts | 27 +++
.../src/components/settings/settingsSearch.ts | 13 +-
apps/web/src/composerDraftStore.test.ts | 188 ++++++++++++---
apps/web/src/composerDraftStore.ts | 94 ++++----
apps/web/src/draftDefaultsSync.test.ts | 215 ++++++++++++++++++
apps/web/src/draftDefaultsSync.tsx | 134 +++++++++++
apps/web/src/hooks/useHandleNewThread.test.ts | 119 ++++++++++
apps/web/src/hooks/useHandleNewThread.ts | 159 ++++++++++---
docs/user/composer.md | 17 ++
packages/contracts/src/settings.test.ts | 57 +++++
packages/contracts/src/settings.ts | 41 +++-
packages/shared/src/model.test.ts | 59 +++++
packages/shared/src/model.ts | 37 +++
packages/shared/src/serverSettings.test.ts | 25 ++
packages/shared/src/serverSettings.ts | 4 +
31 files changed, 1696 insertions(+), 280 deletions(-)
create mode 100644 apps/web/src/components/chat/runtimeModeConfig.ts
create mode 100644 apps/web/src/components/settings/ProviderNewThreadDefaultsSection.tsx
create mode 100644 apps/web/src/draftDefaultsSync.test.ts
create mode 100644 apps/web/src/draftDefaultsSync.tsx
create mode 100644 apps/web/src/hooks/useHandleNewThread.test.ts
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 14f0fcc95a2..e8ce566ffae 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 8a9dabbe034..da1861e504f 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 cb7a8c4198e..f0648afde4a 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 31290c6004f..fc65894e58b 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 83672d659da..6ab4a0d956f 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 975c8886940..f4c747e5f1e 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 924fbddeab7..3f906a3bd7f 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 00000000000..0e6e8432e4f
--- /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 11cfaf889d0..2945c5ceb56 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 00000000000..c1cfa40923c
--- /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 bf558f5a4d6..ad7bf069582 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 1c7dac391f6..852590303f3 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 773463a3835..00a8cbda1ed 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 b3e9df6a7a8..237e945a318 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";
@@ -205,7 +206,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 08ab356ba01..4bf67ec7079 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,
@@ -1721,10 +1727,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 =
@@ -1739,6 +1745,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);
@@ -2113,6 +2128,66 @@ export function GeneralSettingsPanel() {
/>
) : null}
+ updateSettings({ newThreadModel: null })}
+ />
+ ) : null
+ }
+ control={
+
+
+ {settings.newThreadModel !== null && resolvedNewThreadModel ? (
+
+ updateSettings({ newThreadModel: createModelSelection(instanceId, model) })
+ }
+ />
+ ) : null}
+
+ }
+ />
+
{
targetId: "appearance",
});
});
+
+ 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);
+ });
});
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 2c8bbfd0823..42a84ea8bf0 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -135,7 +135,7 @@ export const SETTINGS_SEARCH_ITEMS = [
},
{
id: "new-threads",
- title: "New threads",
+ title: "New thread workspace",
to: "/settings/general",
},
{
@@ -144,6 +144,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",
@@ -200,6 +205,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 3e4106c583f..86450af9e32 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 61bd7162f7a..f72a270ba67 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 00000000000..6c2ff24c883
--- /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 00000000000..72a21226c9f
--- /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 00000000000..59241e390b3
--- /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 64176c0873a..8484b95c619 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 d2e49db247b..f3c6bb48ef2 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 f578f7ef783..7f942d70020 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 8a60280a13f..62d8dd77deb 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 b67c4574473..48a0b7e2776 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 bdc0c0cc8ef..e858a995569 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 baa84a4e1aa..7937b410ecf 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 21d819a1c9e..767b5e50db8 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 }
: {}),