From 3f8203169e46b3b01d1b2af88286582fc65e299a Mon Sep 17 00:00:00 2001 From: BinBandit Date: Fri, 20 Mar 2026 15:29:34 +1100 Subject: [PATCH 1/2] feat(web): add default model and provider settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `defaultModel` and `defaultProvider` fields to app settings so users can configure the model and provider used for new threads and projects when no thread-specific or project-level override is set. - Add `defaultProvider` (optional ProviderKind) and `defaultModel` (optional TrimmedNonEmptyString) to the AppSettings schema - Wire settings into the ChatView provider/model selection hierarchy: draft → thread → project → settings default → built-in default - Update Sidebar project creation to propagate the default model into new projects so the setting takes effect immediately - Add draft-thread model fallback through settings default - Add "Defaults" section to Settings page with model dropdown (provider dropdown conditionally rendered when >1 providers available) Co-Authored-By: Claude Opus 4.6 --- apps/web/src/appSettings.ts | 4 +- apps/web/src/components/ChatView.tsx | 12 ++- apps/web/src/components/Sidebar.tsx | 2 +- apps/web/src/routes/_chat.settings.tsx | 118 ++++++++++++++++++++++++- 4 files changed, 129 insertions(+), 7 deletions(-) diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index d060c2ef06..e2bf58b935 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,6 +1,6 @@ import { useCallback } from "react"; import { Option, Schema } from "effect"; -import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts"; +import { ProviderKind, TrimmedNonEmptyString } from "@t3tools/contracts"; import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { useLocalStorage } from "./hooks/useLocalStorage"; @@ -35,6 +35,8 @@ const AppSettingsSchema = Schema.Struct({ Schema.withConstructorDefault(() => Option.some([])), ), textGenerationModel: Schema.optional(TrimmedNonEmptyString), + defaultProvider: Schema.optional(ProviderKind), + defaultModel: Schema.optional(TrimmedNonEmptyString), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9ebb4ec9e8..d7e72d54ba 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -445,11 +445,11 @@ export default function ChatView({ threadId }: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex, + fallbackDraftProject?.model ?? settings.defaultModel ?? DEFAULT_MODEL_BY_PROVIDER.codex, localDraftError, ) : undefined, - [draftThread, fallbackDraftProject?.model, localDraftError, threadId], + [draftThread, fallbackDraftProject?.model, settings.defaultModel, localDraftError, threadId], ); const activeThread = serverThread ?? localDraftThread; const runtimeMode = @@ -574,10 +574,14 @@ export default function ChatView({ threadId }: ChatViewProps) { const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? selectedProviderByThreadId ?? null) : null; - const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; + const selectedProvider: ProviderKind = + lockedProvider ?? selectedProviderByThreadId ?? settings.defaultProvider ?? "codex"; const baseThreadModel = resolveModelSlugForProvider( selectedProvider, - activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), + activeThread?.model ?? + activeProject?.model ?? + settings.defaultModel ?? + getDefaultModel(selectedProvider), ); const customModelsForSelectedProvider = settings.customCodexModels; const selectedModel = useMemo(() => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index bcef110c1b..97b24f7583 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -433,7 +433,7 @@ export default function Sidebar() { projectId, title, workspaceRoot: cwd, - defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, + defaultModel: appSettings.defaultModel ?? DEFAULT_MODEL_BY_PROVIDER.codex, createdAt, }); await handleNewThread(projectId, { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index e79592c99b..db9e26de54 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -2,8 +2,9 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts"; -import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; +import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { getAppModelOptions, MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; +import { PROVIDER_OPTIONS } from "../session-logic"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; @@ -112,6 +113,19 @@ function SettingsRouteView() { const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; + const effectiveDefaultProvider: ProviderKind = settings.defaultProvider ?? "codex"; + const defaultModelOptionsForProvider = getAppModelOptions( + effectiveDefaultProvider, + settings.customCodexModels, + settings.defaultModel, + ); + const effectiveDefaultModel = settings.defaultModel ?? getDefaultModel(effectiveDefaultProvider); + const selectedDefaultModelLabel = + defaultModelOptionsForProvider.find((option) => option.slug === effectiveDefaultModel)?.name ?? + effectiveDefaultModel; + + const availableProviderOptions = PROVIDER_OPTIONS.filter((option) => option.available); + const gitTextGenerationModelOptions = getAppModelOptions( "codex", settings.customCodexModels, @@ -380,6 +394,108 @@ function SettingsRouteView() { +
+
+

Defaults

+

+ Set the default provider and model for new threads. These are used when no + thread-specific or project-level override is set. +

+
+ +
+ {availableProviderOptions.length > 1 ? ( +
+
+

Default provider

+

+ Provider used for new threads when none is specified. +

+
+ +
+ ) : null} + +
+
+

Default model

+

+ Model used for new threads when no project or thread override is set. +

+
+ +
+ + {(settings.defaultProvider !== defaults.defaultProvider || + settings.defaultModel !== defaults.defaultModel) ? ( +
+ +
+ ) : null} +
+
+

Models

From aec0916a5f3ced5f1dc6ac35786b46937af78121 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Fri, 20 Mar 2026 15:34:24 +1100 Subject: [PATCH 2/2] fix(web): add defaultModel to addProjectFromPath dependency array The useCallback for addProjectFromPath captured appSettings.defaultModel but omitted it from the dependency array, causing a stale closure when the setting changed without a re-render triggered by defaultThreadEnvMode. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/Sidebar.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 97b24f7583..afa1d3d619 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -462,6 +462,7 @@ export default function Sidebar() { isAddingProject, projects, shouldBrowseForProjectImmediately, + appSettings.defaultModel, appSettings.defaultThreadEnvMode, ], );