From 2740c7b3a24dcc68521289486ff2de8674a9d509 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 22 Aug 2026 20:31:20 +0300 Subject: [PATCH] feat(projects): pin a thinking level next to a project's model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project could pin the model new chats start on, but not the level to run it at: the default cascade dropped any variant as soon as a project model won, and only ever considered the global one — which belongs to the global model. Projects now carry `defaultVariant` alongside `defaultModel`, stored and sanitized only next to that model, and the cascade passes it through. Both controls sit in one "Defaults for new chats" group laid out like the Sessions defaults, and the level appears only for models that offer them. --- CHANGELOG.md | 1 + .../projects/ProjectIdentityFields.tsx | 78 ++++++++++++++++--- .../sections/projects/ProjectsPage.tsx | 1 + .../projects/useProjectIdentityForm.ts | 18 ++++- packages/ui/src/lib/api/types.ts | 2 + .../ui/src/lib/i18n/messages/de.settings.ts | 7 +- .../ui/src/lib/i18n/messages/en.settings.ts | 7 +- .../ui/src/lib/i18n/messages/es.settings.ts | 7 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 7 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 7 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 7 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 7 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 7 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 7 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 7 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 7 +- packages/ui/src/lib/modelVariants.ts | 20 +++++ packages/ui/src/lib/settings/search.ts | 12 +++ packages/ui/src/stores/useConfigStore.test.ts | 51 ++++++++++++ packages/ui/src/stores/useConfigStore.ts | 9 ++- .../ui/src/stores/useProjectsStore.test.ts | 47 +++++++++++ packages/ui/src/stores/useProjectsStore.ts | 20 +++++ packages/ui/src/sync/session-ui-store.ts | 1 + .../settings-normalization-runtime.js | 3 + .../settings-normalization-runtime.test.js | 16 ++++ 25 files changed, 322 insertions(+), 34 deletions(-) create mode 100644 packages/ui/src/lib/modelVariants.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 65dc0aab..7d6cb91b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - Desktop/Remote instances: disconnecting from a connection set to not keep the server running now actually stops that remote server. - Chat: in a chat without a project, the work status card again steps aside when the context panel is open, instead of sitting next to it. - Settings/General: changing the default model, variant or agent no longer repoints an open chat that already carries a model you picked for it. Chats following the default still switch immediately. +- Settings/Projects: a project can now pin a thinking level next to its model, for models that offer levels. Both sit in one Defaults for new chats group, laid out like the Sessions defaults. - **Settings:** the project selector on Providers, Agents, MCP, Commands and Skills now only changes what those pages show. It used to switch the whole app, so opening another project's configuration moved your chat, session list and file tree with it. - Settings/Providers: the provider you select no longer jumps to a different one on its own. Changing the chat's model or agent, and background provider refreshes, used to move the settings selection with them. - **Chat sessions:** start chats without choosing a project. They live in their own Chats section, rather than inheriting a project's repository and worktree context. diff --git a/packages/ui/src/components/sections/projects/ProjectIdentityFields.tsx b/packages/ui/src/components/sections/projects/ProjectIdentityFields.tsx index 17bc4648..afcef630 100644 --- a/packages/ui/src/components/sections/projects/ProjectIdentityFields.tsx +++ b/packages/ui/src/components/sections/projects/ProjectIdentityFields.tsx @@ -3,6 +3,15 @@ import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Icon } from '@/components/icon/Icon'; import { ModelSelector } from '@/components/sections/agents/ModelSelector'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + SettingsFieldRow, + SETTINGS_CUSTOM_TRIGGER_CLASS, + SETTINGS_SELECT_ROW_TRIGGER_CLASS, + SETTINGS_SELECT_SIZE, +} from '@/components/sections/shared/SettingsSection'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { modelVariantNames } from '@/lib/modelVariants'; import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useI18n } from '@/lib/i18n'; @@ -19,9 +28,14 @@ type ProjectIdentityFieldsProps = { form: ProjectIdentityFormState; }; +const NO_VARIANT_VALUE = '__default__'; + +const formatVariantLabel = (variant: string): string => variant.charAt(0).toUpperCase() + variant.slice(1); + export const ProjectIdentityFields: React.FC = ({ form }) => { const { t } = useI18n(); const { currentTheme } = useThemeSystem(); + const providers = useConfigStore((state) => state.providers); const { name, setName, @@ -32,7 +46,9 @@ export const ProjectIdentityFields: React.FC = ({ fo iconBackground, setIconBackground, parsedDefaultModel, + defaultVariant, handleDefaultModelChange, + handleDefaultVariantChange, isUploadingIcon, isRemovingCustomIcon, isDiscoveringIcon, @@ -53,6 +69,15 @@ export const ProjectIdentityFields: React.FC = ({ fo project, } = form; + const availableVariants = React.useMemo(() => { + const { providerId, modelId } = parsedDefaultModel; + if (!providerId || !modelId) return []; + const model = providers + .find((provider) => provider.id === providerId) + ?.models.find((entry) => entry.id === modelId); + return modelVariantNames(model); + }, [parsedDefaultModel, providers]); + if (!project) { return null; } @@ -75,16 +100,51 @@ export const ProjectIdentityFields: React.FC = ({ fo - + + + + + {availableVariants.length > 0 ? ( + + + + ) : null} { color: data.color, iconBackground: data.iconBackground, defaultModel: data.defaultModel ?? null, + defaultVariant: data.defaultVariant ?? null, }); }, [selectedProject, updateProjectMeta]); diff --git a/packages/ui/src/components/sections/projects/useProjectIdentityForm.ts b/packages/ui/src/components/sections/projects/useProjectIdentityForm.ts index 45639afd..7d4d9cec 100644 --- a/packages/ui/src/components/sections/projects/useProjectIdentityForm.ts +++ b/packages/ui/src/components/sections/projects/useProjectIdentityForm.ts @@ -24,11 +24,12 @@ export type ProjectIdentitySaveData = { color: string | null; iconBackground: string | null; defaultModel: string | null; + defaultVariant: string | null; }; type EditableProject = Pick< ProjectEntry, - 'id' | 'label' | 'icon' | 'color' | 'iconBackground' | 'defaultModel' | 'iconImage' | 'path' + 'id' | 'label' | 'icon' | 'color' | 'iconBackground' | 'defaultModel' | 'defaultVariant' | 'iconImage' | 'path' >; export const useProjectIdentityForm = (project: EditableProject | null) => { @@ -45,6 +46,7 @@ export const useProjectIdentityForm = (project: EditableProject | null) => { const [color, setColor] = React.useState(null); const [iconBackground, setIconBackground] = React.useState(null); const [defaultModel, setDefaultModel] = React.useState(undefined); + const [defaultVariant, setDefaultVariant] = React.useState(undefined); const [isUploadingIcon, setIsUploadingIcon] = React.useState(false); const [isRemovingCustomIcon, setIsRemovingCustomIcon] = React.useState(false); const [isDiscoveringIcon, setIsDiscoveringIcon] = React.useState(false); @@ -73,6 +75,7 @@ export const useProjectIdentityForm = (project: EditableProject | null) => { setColor(null); setIconBackground(null); setDefaultModel(undefined); + setDefaultVariant(undefined); return; } setName(project.label ?? ''); @@ -80,6 +83,7 @@ export const useProjectIdentityForm = (project: EditableProject | null) => { setColor(project.color ?? null); setIconBackground(project.iconBackground ?? null); setDefaultModel(project.defaultModel); + setDefaultVariant(project.defaultVariant); setPendingRemoveImageIcon(false); clearPendingUploadIcon(); setPreviewImageFailed(false); @@ -110,12 +114,20 @@ export const useProjectIdentityForm = (project: EditableProject | null) => { || color !== (project?.color ?? null) || iconBackground !== (project?.iconBackground ?? null) || (defaultModel ?? undefined) !== (project?.defaultModel ?? undefined) + || (defaultVariant ?? undefined) !== (project?.defaultVariant ?? undefined) || pendingRemoveImageIcon || Boolean(pendingUploadIconFile) ); const handleDefaultModelChange = React.useCallback((providerId: string, modelId: string) => { setDefaultModel(providerId && modelId ? `${providerId}/${modelId}` : undefined); + // Variants belong to a model. Carrying the old one over would pin a name + // the new model may not have. + setDefaultVariant(undefined); + }, []); + + const handleDefaultVariantChange = React.useCallback((variant: string | undefined) => { + setDefaultVariant(variant); }, []); const handleUploadIcon = React.useCallback((file: File | null) => { @@ -232,11 +244,13 @@ export const useProjectIdentityForm = (project: EditableProject | null) => { color, iconBackground: normalizeProjectIconBackground(willRemoveImageIcon ? null : iconBackground), defaultModel: defaultModel ?? null, + defaultVariant: defaultModel ? defaultVariant ?? null : null, }; }, [ clearPendingUploadIcon, color, defaultModel, + defaultVariant, icon, iconBackground, name, @@ -262,8 +276,10 @@ export const useProjectIdentityForm = (project: EditableProject | null) => { iconBackground, setIconBackground, defaultModel, + defaultVariant, parsedDefaultModel, handleDefaultModelChange, + handleDefaultVariantChange, isUploadingIcon, isRemovingCustomIcon, isDiscoveringIcon, diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index fd555f3f..eaca77ed 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -645,6 +645,8 @@ export interface ProjectEntry { iconBackground?: string | null; color?: string | null; defaultModel?: string; + /** Variant of `defaultModel`, when that model exposes any. */ + defaultVariant?: string; addedAt?: number; lastOpenedAt?: number; sidebarCollapsed?: boolean; diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 86a306e7..fc95a62f 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1102,9 +1102,12 @@ export const settingsDict = { 'settings.projects.page.title.default': 'Projekt-Einstellungen', 'settings.projects.page.section.worktree': 'Arbeitsbaum', 'settings.projects.page.field.projectName': 'Projektname', + 'settings.projects.page.field.projectModel': 'Projektmodell', + 'settings.projects.page.field.projectThinking': 'Projekt-Denkstufe', + 'settings.projects.page.section.chatDefaults': 'Vorgaben für neue Chats', + 'settings.projects.page.section.chatDefaultsDescription': 'Gilt beim Start eines neuen Chats in diesem Projekt. Ohne Angabe greifen die globalen Vorgaben. Die Denkstufe erscheint nur bei Modellen, die Stufen anbieten.', 'settings.projects.page.field.projectNamePlaceholder': 'Projektname', - 'settings.projects.page.field.defaultModel': 'Standardmodell für neue Chats', - 'settings.projects.page.field.defaultModelDescription': 'Wird verwendet, wenn ein neuer Chat in diesem Projekt gestartet wird. Fallback auf globale Standardeinstellungen, wenn nicht gesetzt.', + 'settings.projects.page.option.thinkingDefault': 'Modellvorgabe', 'settings.projects.page.field.accentColor': 'Akzentfarbe', 'settings.projects.page.field.projectIcon': 'Projekt-Icon', 'settings.projects.page.field.projectIconBackgroundAria': 'Hintergrundfarbe des Projekt-Icons', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index e8e68187..1b30bcde 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1164,9 +1164,12 @@ export const settingsDict = { 'settings.projects.page.title.default': 'Project Settings', 'settings.projects.page.section.worktree': 'Worktree', 'settings.projects.page.field.projectName': 'Project Name', + 'settings.projects.page.field.projectModel': 'Project Model', + 'settings.projects.page.field.projectThinking': 'Project Thinking', + 'settings.projects.page.section.chatDefaults': 'Defaults for new chats', + 'settings.projects.page.section.chatDefaultsDescription': 'Used when starting a new chat in this project. Falls back to the global defaults when unset. Thinking appears only for models that offer levels.', 'settings.projects.page.field.projectNamePlaceholder': 'Project name', - 'settings.projects.page.field.defaultModel': 'Default model for new chats', - 'settings.projects.page.field.defaultModelDescription': 'Used when starting a new chat in this project. Falls back to global defaults when unset.', + 'settings.projects.page.option.thinkingDefault': 'Model default', 'settings.projects.page.field.accentColor': 'Accent Color', 'settings.projects.page.field.projectIcon': 'Project Icon', 'settings.projects.page.field.projectIconBackgroundAria': 'Project icon background color', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index fc764ed3..5beb47b4 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1132,9 +1132,12 @@ export const settingsDict = { "settings.projects.page.title.default": "Configuración del proyecto", "settings.projects.page.section.worktree": "Worktree", "settings.projects.page.field.projectName": "Nombre del proyecto", + "settings.projects.page.field.projectModel": "Modelo del proyecto", + "settings.projects.page.field.projectThinking": "Razonamiento del proyecto", + "settings.projects.page.section.chatDefaults": "Valores por defecto para chats nuevos", + "settings.projects.page.section.chatDefaultsDescription": "Se usa al iniciar un chat nuevo en este proyecto. Si está vacío, se aplican los valores globales. El razonamiento solo aparece en modelos que ofrecen niveles.", "settings.projects.page.field.projectNamePlaceholder": "Nombre del proyecto", - "settings.projects.page.field.defaultModel": "Modelo predeterminado para chats nuevos", - "settings.projects.page.field.defaultModelDescription": "Se usa al iniciar un chat nuevo en este proyecto. Si no se define, se usan los valores globales.", + "settings.projects.page.option.thinkingDefault": "El del modelo", "settings.projects.page.field.accentColor": "Color de énfasis", "settings.projects.page.field.projectIcon": "Icono del proyecto", "settings.projects.page.field.projectIconBackgroundAria": "Color de fondo del icono del proyecto", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index c7310394..cdaffae8 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1050,9 +1050,12 @@ export const settingsDict = { 'settings.projects.page.title.default': 'Paramètres du projet', 'settings.projects.page.section.worktree': 'Worktree', 'settings.projects.page.field.projectName': 'Nom du projet', + 'settings.projects.page.field.projectModel': 'Modèle du projet', + 'settings.projects.page.field.projectThinking': 'Réflexion du projet', + 'settings.projects.page.section.chatDefaults': 'Valeurs par défaut des nouveaux chats', + 'settings.projects.page.section.chatDefaultsDescription': 'Utilisées au démarrage d’un nouveau chat dans ce projet. À défaut, les valeurs globales s’appliquent. La réflexion n’apparaît que pour les modèles qui proposent des niveaux.', 'settings.projects.page.field.projectNamePlaceholder': 'Nom du projet', - 'settings.projects.page.field.defaultModel': 'Modèle par défaut pour les nouveaux chats', - 'settings.projects.page.field.defaultModelDescription': 'Utilisé lors du démarrage d\'un nouveau chat dans ce projet. Revient aux valeurs globales si non défini.', + 'settings.projects.page.option.thinkingDefault': 'Celui du modèle', 'settings.projects.page.field.accentColor': 'Couleur d\'accentuation', 'settings.projects.page.field.projectIcon': 'Icône du projet', 'settings.projects.page.field.projectIconBackgroundAria': 'Couleur d’arrière-plan de l’icône du projet', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 226942c9..b83da80c 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1165,9 +1165,12 @@ export const settingsDict = { 'settings.projects.page.title.default': 'プロジェクト設定', 'settings.projects.page.section.worktree': 'ワークツリー', 'settings.projects.page.field.projectName': 'プロジェクト名', + 'settings.projects.page.field.projectModel': 'プロジェクトのモデル', + 'settings.projects.page.field.projectThinking': 'プロジェクトの思考レベル', + 'settings.projects.page.section.chatDefaults': '新規チャットの既定値', + 'settings.projects.page.section.chatDefaultsDescription': 'このプロジェクトで新しいチャットを始めるときに使われます。未設定ならグローバルの既定値になります。思考レベルは、レベルを持つモデルでのみ表示されます。', 'settings.projects.page.field.projectNamePlaceholder': 'プロジェクト名', - 'settings.projects.page.field.defaultModel': '新規チャットのデフォルトモデル', - 'settings.projects.page.field.defaultModelDescription': 'このプロジェクトで新しいチャットを開始するときに使用されます。未設定の場合はグローバル既定値にフォールバックします。', + 'settings.projects.page.option.thinkingDefault': 'モデルの既定', 'settings.projects.page.field.accentColor': 'アクセントカラー', 'settings.projects.page.field.projectIcon': 'プロジェクトアイコン', 'settings.projects.page.field.projectIconBackgroundAria': 'プロジェクトアイコンの背景色', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index e1cb2e21..a8e05bba 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1132,9 +1132,12 @@ export const settingsDict = { 'settings.projects.page.title.default': '프로젝트 설정', 'settings.projects.page.section.worktree': 'Worktree', 'settings.projects.page.field.projectName': '프로젝트 이름', + 'settings.projects.page.field.projectModel': '프로젝트 모델', + 'settings.projects.page.field.projectThinking': '프로젝트 사고 수준', + 'settings.projects.page.section.chatDefaults': '새 채팅 기본값', + 'settings.projects.page.section.chatDefaultsDescription': '이 프로젝트에서 새 채팅을 시작할 때 사용합니다. 비워 두면 전역 기본값을 따릅니다. 사고 수준은 수준을 제공하는 모델에서만 표시됩니다.', 'settings.projects.page.field.projectNamePlaceholder': '프로젝트 이름', - 'settings.projects.page.field.defaultModel': '새 채팅의 기본 모델', - 'settings.projects.page.field.defaultModelDescription': '이 프로젝트에서 새 채팅을 시작할 때 사용됩니다. 설정하지 않으면 전역 기본값으로 대체됩니다.', + 'settings.projects.page.option.thinkingDefault': '모델 기본값', 'settings.projects.page.field.accentColor': '강조 색상', 'settings.projects.page.field.projectIcon': '프로젝트 아이콘', 'settings.projects.page.field.projectIconBackgroundAria': '프로젝트 아이콘 배경색', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index a2dee338..3555958f 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1358,9 +1358,12 @@ export const settingsDict = { 'settings.projects.page.field.projectIcon': 'Ikona projektu', 'settings.projects.page.field.projectIconBackgroundAria': 'Kolor tła ikony projektu', 'settings.projects.page.field.projectName': 'Nazwa projektu', + 'settings.projects.page.field.projectModel': 'Model projektu', + 'settings.projects.page.field.projectThinking': 'Poziom myślenia projektu', + 'settings.projects.page.section.chatDefaults': 'Domyślne ustawienia nowych czatów', + 'settings.projects.page.section.chatDefaultsDescription': 'Używane przy starcie nowego czatu w tym projekcie. Bez ustawienia obowiązują wartości globalne. Poziom myślenia pojawia się tylko przy modelach, które mają poziomy.', 'settings.projects.page.field.projectNamePlaceholder': 'Nazwa projektu', - 'settings.projects.page.field.defaultModel': 'Domyślny model dla nowych czatów', - 'settings.projects.page.field.defaultModelDescription': 'Używany przy rozpoczynaniu nowego czatu w tym projekcie. Gdy nie ustawiono, stosowane są globalne domyślne wartości.', + 'settings.projects.page.option.thinkingDefault': 'Ustawienie modelu', 'settings.projects.page.section.worktree': 'Worktree', 'settings.projects.page.title.default': 'Project Settings', 'settings.projects.page.toast.customIconAlreadySet': 'Dla tego projektu ustawiono już własną ikonę', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 2bb63236..963fbf91 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1132,9 +1132,12 @@ export const settingsDict = { "settings.projects.page.title.default": "Configurações do projeto", "settings.projects.page.section.worktree": "Worktree", "settings.projects.page.field.projectName": "Nome do projeto", + "settings.projects.page.field.projectModel": "Modelo do projeto", + "settings.projects.page.field.projectThinking": "Raciocínio do projeto", + "settings.projects.page.section.chatDefaults": "Padrões para novos chats", + "settings.projects.page.section.chatDefaultsDescription": "Usados ao iniciar um novo chat neste projeto. Sem definição, valem os padrões globais. O raciocínio só aparece em modelos que oferecem níveis.", "settings.projects.page.field.projectNamePlaceholder": "Nome do projeto", - "settings.projects.page.field.defaultModel": "Modelo padrão para novos chats", - "settings.projects.page.field.defaultModelDescription": "Usado ao iniciar um novo chat neste projeto. Se não definido, usa os padrões globais.", + "settings.projects.page.option.thinkingDefault": "Padrão do modelo", "settings.projects.page.field.accentColor": "Cor de destaque", "settings.projects.page.field.projectIcon": "Ícone do projeto", "settings.projects.page.field.projectIconBackgroundAria": "Cor de fundo do ícone do projeto", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 54327463..00035bca 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1132,9 +1132,12 @@ export const settingsDict = { "settings.projects.page.title.default": "Параметри проєкту", "settings.projects.page.section.worktree": "Worktree", "settings.projects.page.field.projectName": "Назва проєкту", + "settings.projects.page.field.projectModel": "Модель проєкту", + "settings.projects.page.field.projectThinking": "Міркування проєкту", + "settings.projects.page.section.chatDefaults": "Значення за замовчуванням для нових чатів", + "settings.projects.page.section.chatDefaultsDescription": "Використовується при старті нового чату в цьому проєкті. Якщо не задано, береться глобальне значення. Міркування показується лише для моделей, які мають рівні.", "settings.projects.page.field.projectNamePlaceholder": "Назва проєкту", - "settings.projects.page.field.defaultModel": "Модель за замовчуванням для нових чатів", - "settings.projects.page.field.defaultModelDescription": "Використовується під час початку нового чату в цьому проєкті. Якщо не задано, застосовуються глобальні значення.", + "settings.projects.page.option.thinkingDefault": "Як у моделі", "settings.projects.page.field.accentColor": "Колір акценту", "settings.projects.page.field.projectIcon": "Значок проєкту", "settings.projects.page.field.projectIconBackgroundAria": "Колір тла значка проєкту", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 3ad65279..eddb6b73 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1132,9 +1132,12 @@ export const settingsDict = { 'settings.projects.page.title.default': '项目设置', 'settings.projects.page.section.worktree': '工作树', 'settings.projects.page.field.projectName': '项目名称', + 'settings.projects.page.field.projectModel': '项目模型', + 'settings.projects.page.field.projectThinking': '项目思考级别', + 'settings.projects.page.section.chatDefaults': '新聊天的默认设置', + 'settings.projects.page.section.chatDefaultsDescription': '在此项目中开始新聊天时使用。未设置时使用全局默认值。思考级别仅在提供级别的模型上显示。', 'settings.projects.page.field.projectNamePlaceholder': '项目名称', - 'settings.projects.page.field.defaultModel': '新聊天的默认模型', - 'settings.projects.page.field.defaultModelDescription': '在此项目中开始新聊天时使用。未设置时回退到全局默认值。', + 'settings.projects.page.option.thinkingDefault': '模型默认', 'settings.projects.page.field.accentColor': '强调色', 'settings.projects.page.field.projectIcon': '项目图标', 'settings.projects.page.field.projectIconBackgroundAria': '项目图标背景颜色', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 0eb083d0..b1fdb079 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1039,9 +1039,12 @@ export const settingsDict = { 'settings.projects.page.title.default': '專案設定', 'settings.projects.page.section.worktree': 'Worktree', 'settings.projects.page.field.projectName': '專案名稱', + 'settings.projects.page.field.projectModel': '專案模型', + 'settings.projects.page.field.projectThinking': '專案思考層級', + 'settings.projects.page.section.chatDefaults': '新聊天的預設設定', + 'settings.projects.page.section.chatDefaultsDescription': '在此專案中開始新聊天時使用。未設定時使用全域預設值。思考層級僅在提供層級的模型上顯示。', 'settings.projects.page.field.projectNamePlaceholder': '專案名稱', - 'settings.projects.page.field.defaultModel': '新聊天的預設模型', - 'settings.projects.page.field.defaultModelDescription': '在此專案中開始新聊天時使用。若未設定,則回退至全域預設值。', + 'settings.projects.page.option.thinkingDefault': '模型預設', 'settings.projects.page.field.accentColor': '強調色', 'settings.projects.page.field.projectIcon': '專案圖示', 'settings.projects.page.field.projectIconBackgroundAria': '專案圖示背景顏色', diff --git a/packages/ui/src/lib/modelVariants.ts b/packages/ui/src/lib/modelVariants.ts new file mode 100644 index 00000000..3cb40503 --- /dev/null +++ b/packages/ui/src/lib/modelVariants.ts @@ -0,0 +1,20 @@ +import type { Provider } from '@opencode-ai/sdk/v2'; + +type ProviderModel = Provider['models'][string]; + +/** + * Names of the thinking levels a model exposes, empty when it has none. + * + * The SDK's model type does not describe `variants`, so the shape is asserted + * here once instead of at every call site that offers the levels. + */ +export const modelVariantNames = (model: ProviderModel | undefined): string[] => { + if (!model) { + return []; + } + // SAFETY: the payload types `variants` as an optional object whose keys are + // the variant names. Only the key set is read, and it is returned as strings, + // so no caller depends on the value shape. + const variants = (model as { variants?: object }).variants; + return variants ? Object.keys(variants) : []; +}; diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index c7fb00d4..f247f679 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -550,6 +550,18 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ titleKey: 'settings.projects.page.field.projectName', keywords: ['label', 'display name', 'project metadata'], }, + { + id: 'projects.default-model', + page: 'projects', + titleKey: 'settings.projects.page.field.projectModel', + keywords: ['model', 'default', 'new chat', 'project metadata'], + }, + { + id: 'projects.default-thinking', + page: 'projects', + titleKey: 'settings.projects.page.field.projectThinking', + keywords: ['thinking', 'variant', 'reasoning', 'effort', 'model', 'project metadata'], + }, { id: 'projects.accent-color', page: 'projects', diff --git a/packages/ui/src/stores/useConfigStore.test.ts b/packages/ui/src/stores/useConfigStore.test.ts index ec8fd62a..ace3cb21 100644 --- a/packages/ui/src/stores/useConfigStore.test.ts +++ b/packages/ui/src/stores/useConfigStore.test.ts @@ -671,6 +671,57 @@ describe('useConfigStore provider persistence', () => { expect(getConfigCalls).toBe(0); }); + test('a project default carries its own thinking level', async () => { + // The project pins a model plus the level to run it at. Before, the level + // was dropped and only the global settings variant was ever considered — + // and that one belongs to the global model, not this project's. + const projectProvider = provider('anthropic', 'claude-opus-5', { high: {}, low: {} }); + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [projectProvider], + agents: [testAgent('build')], + currentProviderId: '', + currentModelId: '', + currentVariant: undefined, + settingsDefaultModel: undefined, + settingsDefaultVariant: 'low', + selectionSource: 'auto', + directoryScoped: {}, + }); + + useConfigStore.getState().applyDefaultModelAgentSelection({ + projectDefaultModel: 'anthropic/claude-opus-5', + projectDefaultVariant: 'high', + }); + + const state = useConfigStore.getState(); + expect(state.currentProviderId).toBe('anthropic'); + expect(state.currentModelId).toBe('claude-opus-5'); + expect(state.currentVariant).toBe('high'); + }); + + test('a thinking level the project model does not offer is ignored', async () => { + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [provider('anthropic', 'claude-opus-5')], + agents: [testAgent('build')], + currentProviderId: '', + currentModelId: '', + currentVariant: undefined, + settingsDefaultModel: undefined, + settingsDefaultVariant: undefined, + selectionSource: 'auto', + directoryScoped: {}, + }); + + useConfigStore.getState().applyDefaultModelAgentSelection({ + projectDefaultModel: 'anthropic/claude-opus-5', + projectDefaultVariant: 'high', + }); + + expect(useConfigStore.getState().currentVariant).toBe(undefined); + }); + test('manual selection survives an in-flight loadAgents refresh', async () => { const pendingAgents = deferred(); listAgentsImpl = async () => pendingAgents.promise; diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 4977d314..fae9ca95 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -291,6 +291,7 @@ const resolveDefaultAgentModelSelection = ({ agents, providers, projectDefaultModel, + projectDefaultVariant, settingsDefaultAgent, settingsDefaultModel, settingsDefaultVariant, @@ -300,6 +301,7 @@ const resolveDefaultAgentModelSelection = ({ agents: Agent[]; providers: ProviderWithModelList[]; projectDefaultModel?: string; + projectDefaultVariant?: string; settingsDefaultAgent?: string; settingsDefaultModel?: string; settingsDefaultVariant?: string; @@ -355,7 +357,9 @@ const resolveDefaultAgentModelSelection = ({ if (parsed && hasProviderModel(providers, parsed.providerId, parsed.modelId)) { providerId = parsed.providerId; modelId = parsed.modelId; - variant = resolveVariant(providerId, modelId, projectDefaultModel ? undefined : settingsDefaultVariant); + // A project default carries its own variant; the settings variant + // belongs to the settings model and must not leak onto it. + variant = resolveVariant(providerId, modelId, projectDefaultModel ? projectDefaultVariant : settingsDefaultVariant); } } @@ -1097,7 +1101,7 @@ interface ConfigStore { cycleCurrentVariant: () => void; getCurrentModelVariants: () => string[]; setAgent: (agentName: string | undefined) => void; - applyDefaultModelAgentSelection: (options?: { projectDefaultModel?: string }) => void; + applyDefaultModelAgentSelection: (options?: { projectDefaultModel?: string; projectDefaultVariant?: string }) => void; applyOpenCodeConfigDefaults: (directory?: string | null, source?: string, config?: Config) => void; setSelectedProvider: (providerId: string) => void; setSettingsDefaultModel: (model: string | undefined) => void; @@ -2603,6 +2607,7 @@ export const useConfigStore = create()( agents, providers, projectDefaultModel: options?.projectDefaultModel, + projectDefaultVariant: options?.projectDefaultVariant, settingsDefaultAgent, settingsDefaultModel, settingsDefaultVariant, diff --git a/packages/ui/src/stores/useProjectsStore.test.ts b/packages/ui/src/stores/useProjectsStore.test.ts index 34c02726..f82d2c54 100644 --- a/packages/ui/src/stores/useProjectsStore.test.ts +++ b/packages/ui/src/stores/useProjectsStore.test.ts @@ -19,3 +19,50 @@ describe("useProjectsStore settings synchronization", () => { expect(useProjectsStore.getState().manualProjectOrder).toEqual([]) }) }) + +describe("useProjectsStore default model and thinking level", () => { + const seed = (project: ProjectEntry) => { + useProjectsStore.setState({ + projects: [project], + activeProjectId: project.id, + manualProjectOrder: [project.id], + }) + } + + test("keeps a thinking level next to the model it belongs to", () => { + seed({ id: "project-a", path: "/repo" } as ProjectEntry) + + useProjectsStore.getState().updateProjectMeta("project-a", { + defaultModel: "anthropic/claude-opus-5", + defaultVariant: "high", + }) + + const project = useProjectsStore.getState().projects[0] + expect(project?.defaultModel).toBe("anthropic/claude-opus-5") + expect(project?.defaultVariant).toBe("high") + }) + + test("drops the thinking level when the model is cleared", () => { + seed({ + id: "project-a", + path: "/repo", + defaultModel: "anthropic/claude-opus-5", + defaultVariant: "high", + } as ProjectEntry) + + useProjectsStore.getState().updateProjectMeta("project-a", { defaultModel: null }) + + const project = useProjectsStore.getState().projects[0] + expect(project?.defaultModel).toBe(undefined) + expect(project?.defaultVariant).toBe(undefined) + }) + + test("ignores a thinking level that arrives without a model", () => { + useProjectsStore.getState().synchronizeFromSettings({ + projects: [{ id: "project-a", path: "/repo", defaultVariant: "high" }], + } as DesktopSettings) + + const project = useProjectsStore.getState().projects[0] + expect(project?.defaultVariant).toBe(undefined) + }) +}) diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index 0f37f137..1ab2c9f5 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -60,6 +60,7 @@ interface ProjectsStore { color?: string | null; iconBackground?: string | null; defaultModel?: string | null; + defaultVariant?: string | null; }) => void; uploadProjectIcon: (id: string, file: File) => Promise<{ ok: boolean; error?: string }>; removeProjectIcon: (id: string) => Promise<{ ok: boolean; error?: string }>; @@ -291,6 +292,10 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => { const defaultModel = normalizeDefaultModel(candidate.defaultModel); if (defaultModel) { project.defaultModel = defaultModel; + // A variant only means something next to the model it belongs to. + if (typeof candidate.defaultVariant === 'string' && candidate.defaultVariant.trim().length > 0) { + project.defaultVariant = candidate.defaultVariant.trim(); + } } if (candidate.iconBackground === null) { project.iconBackground = null; @@ -521,6 +526,7 @@ const vscodeWorkspaceProjectsEqual = (left: ProjectEntry[], right: ProjectEntry[ && leftProject.color === rightProject.color && leftProject.iconBackground === rightProject.iconBackground && leftProject.defaultModel === rightProject.defaultModel + && leftProject.defaultVariant === rightProject.defaultVariant && leftProject.addedAt === rightProject.addedAt && leftProject.lastOpenedAt === rightProject.lastOpenedAt && leftProject.sidebarCollapsed === rightProject.sidebarCollapsed @@ -724,6 +730,7 @@ export const useProjectsStore = create()( color?: string | null; iconBackground?: string | null; defaultModel?: string | null; + defaultVariant?: string | null; }) => { if (isVSCodeProjectsRuntime) { return; @@ -749,6 +756,19 @@ export const useProjectsStore = create()( delete updated.defaultModel; } } + if (meta.defaultVariant !== undefined) { + const trimmed = meta.defaultVariant?.trim(); + if (trimmed) { + updated.defaultVariant = trimmed; + } else { + delete updated.defaultVariant; + } + } + // A variant without its model is meaningless, and the model may have + // just been cleared in this same update. + if (!updated.defaultModel) { + delete updated.defaultVariant; + } return updated; }); set({ projects: nextProjects }); diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 750f0724..bdc50fe4 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -1181,6 +1181,7 @@ export const useSessionUIStore = create()((set, get) => ({ void activateConfigForDirectory(configDirectory).then(() => { useConfigStore.getState().applyDefaultModelAgentSelection({ projectDefaultModel: selectedProject?.defaultModel, + projectDefaultVariant: selectedProject?.defaultVariant, }) }) diff --git a/packages/web/server/lib/opencode/settings-normalization-runtime.js b/packages/web/server/lib/opencode/settings-normalization-runtime.js index 90c1d3b0..458e1eb8 100644 --- a/packages/web/server/lib/opencode/settings-normalization-runtime.js +++ b/packages/web/server/lib/opencode/settings-normalization-runtime.js @@ -155,6 +155,7 @@ export const createSettingsNormalizationRuntime = (dependencies) => { const iconBackground = normalizeIconBackground(candidate.iconBackground); const color = typeof candidate.color === 'string' ? candidate.color.trim() : ''; const defaultModel = typeof candidate.defaultModel === 'string' ? candidate.defaultModel.trim() : ''; + const defaultVariant = typeof candidate.defaultVariant === 'string' ? candidate.defaultVariant.trim() : ''; const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null; const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt) ? Number(candidate.lastOpenedAt) @@ -175,6 +176,8 @@ export const createSettingsNormalizationRuntime = (dependencies) => { ...(iconBackground ? { iconBackground } : {}), ...(color ? { color } : {}), ...(defaultModel && defaultModel.includes('/') ? { defaultModel } : {}), + // A variant is meaningless without the model it belongs to. + ...(defaultModel && defaultModel.includes('/') && defaultVariant ? { defaultVariant } : {}), ...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}), ...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}), }; diff --git a/packages/web/server/lib/opencode/settings-normalization-runtime.test.js b/packages/web/server/lib/opencode/settings-normalization-runtime.test.js index 1874ab37..0fb35d89 100644 --- a/packages/web/server/lib/opencode/settings-normalization-runtime.test.js +++ b/packages/web/server/lib/opencode/settings-normalization-runtime.test.js @@ -106,6 +106,22 @@ describe('settings normalization runtime - symlink resolution', () => { expect(result[0].path).toBe('/resolved/missing/path'); }); + it('keeps a default thinking level next to its model and drops it alone', () => { + const runtime = createTestRuntime({ + realpathSync: (p) => p, + path: { resolve: (p) => p, sep: '/', dirname: (p) => p.split('/').slice(0, -1).join('/') || '/' }, + }); + + const projects = [ + { id: 'proj1', path: '/a', defaultModel: 'anthropic/claude-opus-5', defaultVariant: 'high' }, + { id: 'proj2', path: '/b', defaultVariant: 'high' }, + ]; + + const result = runtime.sanitizeProjects(projects); + expect(result[0].defaultVariant).toBe('high'); + expect(result[1].defaultVariant).toBe(undefined); + }); + it('deduplicates projects that resolve to the same realpath', () => { const runtime = createTestRuntime({ realpathSync: (p) => p.startsWith('/symlink') ? '/real/project' : p,