feat(projects): pin a thinking level next to a project's model
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.
This commit is contained in:
@@ -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<ProjectIdentityFieldsProps> = ({ 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<ProjectIdentityFieldsProps> = ({ fo
|
||||
iconBackground,
|
||||
setIconBackground,
|
||||
parsedDefaultModel,
|
||||
defaultVariant,
|
||||
handleDefaultModelChange,
|
||||
handleDefaultVariantChange,
|
||||
isUploadingIcon,
|
||||
isRemovingCustomIcon,
|
||||
isDiscoveringIcon,
|
||||
@@ -53,6 +69,15 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ 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<ProjectIdentityFieldsProps> = ({ fo
|
||||
</ProjectSettingsSubsection>
|
||||
|
||||
<ProjectSettingsSubsection
|
||||
title={t('settings.projects.page.field.defaultModel')}
|
||||
info={t('settings.projects.page.field.defaultModelDescription')}
|
||||
settingsItem="projects.default-model"
|
||||
title={t('settings.projects.page.section.chatDefaults')}
|
||||
info={t('settings.projects.page.section.chatDefaultsDescription')}
|
||||
contentClassName="space-y-0"
|
||||
>
|
||||
<ModelSelector
|
||||
providerId={parsedDefaultModel.providerId}
|
||||
modelId={parsedDefaultModel.modelId}
|
||||
onChange={handleDefaultModelChange}
|
||||
className={cn('h-8 min-h-8 rounded-md px-3 max-w-48', PROJECT_SETTINGS_CONTROL_WIDTH)}
|
||||
/>
|
||||
<SettingsFieldRow
|
||||
settingsItem="projects.default-model"
|
||||
label={t('settings.projects.page.field.projectModel')}
|
||||
>
|
||||
<ModelSelector
|
||||
providerId={parsedDefaultModel.providerId}
|
||||
modelId={parsedDefaultModel.modelId}
|
||||
onChange={handleDefaultModelChange}
|
||||
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
|
||||
{availableVariants.length > 0 ? (
|
||||
<SettingsFieldRow
|
||||
settingsItem="projects.default-thinking"
|
||||
label={t('settings.projects.page.field.projectThinking')}
|
||||
>
|
||||
<Select
|
||||
value={defaultVariant ?? NO_VARIANT_VALUE}
|
||||
onValueChange={(value) => handleDefaultVariantChange(value === NO_VARIANT_VALUE ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
size={SETTINGS_SELECT_SIZE}
|
||||
className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}
|
||||
aria-label={t('settings.projects.page.field.projectThinking')}
|
||||
>
|
||||
<SelectValue>
|
||||
{defaultVariant
|
||||
? formatVariantLabel(defaultVariant)
|
||||
: t('settings.projects.page.option.thinkingDefault')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_VARIANT_VALUE}>{t('settings.projects.page.option.thinkingDefault')}</SelectItem>
|
||||
{availableVariants.map((variant) => (
|
||||
<SelectItem key={variant} value={variant}>{formatVariantLabel(variant)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsFieldRow>
|
||||
) : null}
|
||||
</ProjectSettingsSubsection>
|
||||
|
||||
<ProjectSettingsSubsection
|
||||
|
||||
@@ -37,6 +37,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
color: data.color,
|
||||
iconBackground: data.iconBackground,
|
||||
defaultModel: data.defaultModel ?? null,
|
||||
defaultVariant: data.defaultVariant ?? null,
|
||||
});
|
||||
}, [selectedProject, updateProjectMeta]);
|
||||
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [iconBackground, setIconBackground] = React.useState<string | null>(null);
|
||||
const [defaultModel, setDefaultModel] = React.useState<string | undefined>(undefined);
|
||||
const [defaultVariant, setDefaultVariant] = React.useState<string | undefined>(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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'プロジェクトアイコンの背景色',
|
||||
|
||||
@@ -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': '프로젝트 아이콘 배경색',
|
||||
|
||||
@@ -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ę',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Колір тла значка проєкту",
|
||||
|
||||
@@ -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': '项目图标背景颜色',
|
||||
|
||||
@@ -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': '專案圖示背景顏色',
|
||||
|
||||
@@ -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) : [];
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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<TestAgent[]>();
|
||||
listAgentsImpl = async () => pendingAgents.promise;
|
||||
|
||||
@@ -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<ConfigStore>()(
|
||||
agents,
|
||||
providers,
|
||||
projectDefaultModel: options?.projectDefaultModel,
|
||||
projectDefaultVariant: options?.projectDefaultVariant,
|
||||
settingsDefaultAgent,
|
||||
settingsDefaultModel,
|
||||
settingsDefaultVariant,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<ProjectsStore>()(
|
||||
color?: string | null;
|
||||
iconBackground?: string | null;
|
||||
defaultModel?: string | null;
|
||||
defaultVariant?: string | null;
|
||||
}) => {
|
||||
if (isVSCodeProjectsRuntime) {
|
||||
return;
|
||||
@@ -749,6 +756,19 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
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 });
|
||||
|
||||
@@ -1181,6 +1181,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
void activateConfigForDirectory(configDirectory).then(() => {
|
||||
useConfigStore.getState().applyDefaultModelAgentSelection({
|
||||
projectDefaultModel: selectedProject?.defaultModel,
|
||||
projectDefaultVariant: selectedProject?.defaultVariant,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user