From 48f1e3ef0fae0fe308179aa1a43bc1fc0d02e270 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 26 Dec 2025 04:38:38 +0200 Subject: [PATCH] feat(settings): add default model and agent settings with UI integration --- .../src-tauri/src/commands/settings.rs | 10 + .../sections/settings/DefaultsSettings.tsx | 179 ++++++++++++++ .../sections/settings/SettingsPage.tsx | 4 + packages/ui/src/lib/desktop.ts | 2 + packages/ui/src/lib/persistence.ts | 6 + packages/ui/src/stores/useAgentsStore.ts | 13 +- packages/ui/src/stores/useConfigStore.ts | 228 +++++++++++++++--- packages/ui/src/stores/useSessionStore.ts | 18 +- packages/web/server/index.js | 7 + 9 files changed, 429 insertions(+), 38 deletions(-) create mode 100644 packages/ui/src/components/sections/settings/DefaultsSettings.tsx diff --git a/packages/desktop/src-tauri/src/commands/settings.rs b/packages/desktop/src-tauri/src/commands/settings.rs index af4758c5..518efea1 100644 --- a/packages/desktop/src-tauri/src/commands/settings.rs +++ b/packages/desktop/src-tauri/src/commands/settings.rs @@ -131,6 +131,16 @@ fn sanitize_settings_update(payload: &Value) -> Value { result_obj.insert("markdownDisplayMode".to_string(), json!(s)); } } + if let Some(Value::String(s)) = obj.get("defaultModel") { + if !s.is_empty() { + result_obj.insert("defaultModel".to_string(), json!(s)); + } + } + if let Some(Value::String(s)) = obj.get("defaultAgent") { + if !s.is_empty() { + result_obj.insert("defaultAgent".to_string(), json!(s)); + } + } // Boolean fields if let Some(Value::Bool(b)) = obj.get("useSystemTheme") { diff --git a/packages/ui/src/components/sections/settings/DefaultsSettings.tsx b/packages/ui/src/components/sections/settings/DefaultsSettings.tsx new file mode 100644 index 00000000..70412574 --- /dev/null +++ b/packages/ui/src/components/sections/settings/DefaultsSettings.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import { RiInformationLine } from '@remixicon/react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { ModelSelector } from '@/components/sections/agents/ModelSelector'; +import { AgentSelector } from '@/components/sections/commands/AgentSelector'; +import { updateDesktopSettings } from '@/lib/persistence'; +import { isDesktopRuntime, getDesktopSettings } from '@/lib/desktop'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; + +export const DefaultsSettings: React.FC = () => { + const setProvider = useConfigStore((state) => state.setProvider); + const setModel = useConfigStore((state) => state.setModel); + const setAgent = useConfigStore((state) => state.setAgent); + const setSettingsDefaultModel = useConfigStore((state) => state.setSettingsDefaultModel); + const setSettingsDefaultAgent = useConfigStore((state) => state.setSettingsDefaultAgent); + const providers = useConfigStore((state) => state.providers); + + const [defaultModel, setDefaultModel] = React.useState(); + const [defaultAgent, setDefaultAgent] = React.useState(); + const [isLoading, setIsLoading] = React.useState(true); + + // Parse "provider/model" string into separate parts + const parsedModel = React.useMemo(() => { + if (!defaultModel) return { providerId: '', modelId: '' }; + const parts = defaultModel.split('/'); + if (parts.length !== 2) return { providerId: '', modelId: '' }; + return { providerId: parts[0] || '', modelId: parts[1] || '' }; + }, [defaultModel]); + + // Load current settings + React.useEffect(() => { + const loadSettings = async () => { + try { + let data: { defaultModel?: string; defaultAgent?: string } | null = null; + + // 1. Desktop runtime (Tauri) + if (isDesktopRuntime()) { + data = await getDesktopSettings(); + } else { + // 2. Runtime settings API (VSCode) + const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; + if (runtimeSettings) { + try { + const result = await runtimeSettings.load(); + const settings = result?.settings; + if (settings) { + data = { + defaultModel: typeof settings.defaultModel === 'string' ? settings.defaultModel : undefined, + defaultAgent: typeof settings.defaultAgent === 'string' ? settings.defaultAgent : undefined, + }; + } + } catch { + // Fall through to fetch + } + } + + // 3. Fetch API (Web) + if (!data) { + const response = await fetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (response.ok) { + data = await response.json(); + } + } + } + + if (data) { + setDefaultModel(data.defaultModel); + setDefaultAgent(data.defaultAgent); + } + } catch (error) { + console.warn('Failed to load defaults settings:', error); + } finally { + setIsLoading(false); + } + }; + loadSettings(); + }, []); + + const handleModelChange = React.useCallback(async (providerId: string, modelId: string) => { + const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined; + setDefaultModel(newValue); + + // Update config store settings default (used by setAgent logic) + setSettingsDefaultModel(newValue); + + // Also update current model immediately so new sessions use this model + if (providerId && modelId) { + const provider = providers.find((p) => p.id === providerId); + if (provider) { + setProvider(providerId); + setModel(modelId); + } + } + + try { + await updateDesktopSettings({ + defaultModel: newValue ?? '', + }); + } catch (error) { + console.warn('Failed to save default model:', error); + } + }, [providers, setProvider, setModel, setSettingsDefaultModel]); + + const handleAgentChange = React.useCallback(async (agentName: string) => { + const newValue = agentName || undefined; + setDefaultAgent(newValue); + + // Update config store settings default + setSettingsDefaultAgent(newValue); + + // Update current agent (setAgent will respect settingsDefaultModel) + if (agentName) { + setAgent(agentName); + } + + try { + await updateDesktopSettings({ + defaultAgent: newValue ?? '', + }); + } catch (error) { + console.warn('Failed to save default agent:', error); + } + }, [setAgent, setSettingsDefaultAgent]); + + if (isLoading) { + return null; + } + + return ( +
+
+
+

Default model & agent

+ + + + + + Set the default model and agent for new sessions.
+ When not set, uses agent's preferred model or opencode/big-pickle as fallback. +
+
+
+
+ +
+
+ + +
+ +
+ + +
+
+ + {(defaultModel || defaultAgent) && ( +
+ New sessions will start with:{' '} + {defaultModel && {defaultModel}} + {defaultModel && defaultAgent && ' / '} + {defaultAgent && {defaultAgent}} +
+ )} +
+ ); +}; diff --git a/packages/ui/src/components/sections/settings/SettingsPage.tsx b/packages/ui/src/components/sections/settings/SettingsPage.tsx index cced594c..61ba3a68 100644 --- a/packages/ui/src/components/sections/settings/SettingsPage.tsx +++ b/packages/ui/src/components/sections/settings/SettingsPage.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { AppearanceSettings } from './AppearanceSettings'; import { AboutSettings } from './AboutSettings'; import { SessionRetentionSettings } from './SessionRetentionSettings'; +import { DefaultsSettings } from './DefaultsSettings'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useDeviceInfo } from '@/lib/device'; import { isWebRuntime } from '@/lib/desktop'; @@ -16,6 +17,9 @@ export const SettingsPage: React.FC = () => { className="settings-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6" > +
+ +
diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index b4d8751b..93dc1a78 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -41,6 +41,8 @@ export type DesktopSettings = { showReasoningTraces?: boolean; autoDeleteEnabled?: boolean; autoDeleteAfterDays?: number; + defaultModel?: string; // format: "provider/model" + defaultAgent?: string; }; export type DesktopSettingsApi = { diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index cbb83e9a..33007f7f 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -125,6 +125,12 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) { result.autoDeleteAfterDays = candidate.autoDeleteAfterDays; } + if (typeof candidate.defaultModel === 'string' && candidate.defaultModel.length > 0) { + result.defaultModel = candidate.defaultModel; + } + if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) { + result.defaultAgent = candidate.defaultAgent; + } return result; }; diff --git a/packages/ui/src/stores/useAgentsStore.ts b/packages/ui/src/stores/useAgentsStore.ts index 1218c32a..e86beed9 100644 --- a/packages/ui/src/stores/useAgentsStore.ts +++ b/packages/ui/src/stores/useAgentsStore.ts @@ -31,15 +31,22 @@ export interface AgentConfig { } // Extended Agent type for API properties not in SDK types -export type AgentWithExtras = Agent & { native?: boolean; hidden?: boolean }; +export type AgentWithExtras = Agent & { + native?: boolean; + hidden?: boolean; + options?: { hidden?: boolean }; +}; // Helper to check if agent is built-in (handles both SDK 'builtIn' and API 'native') export const isAgentBuiltIn = (agent: Agent): boolean => agent.builtIn || (agent as AgentWithExtras).native === true; // Helper to check if agent is hidden (internal agents like title, compaction, summary) -export const isAgentHidden = (agent: Agent): boolean => - (agent as AgentWithExtras).hidden === true; +// Checks both top-level hidden and options.hidden (OpenCode API inconsistency workaround) +export const isAgentHidden = (agent: Agent): boolean => { + const extended = agent as AgentWithExtras; + return extended.hidden === true || extended.options?.hidden === true; +}; // Helper to filter only visible (non-hidden) agents export const filterVisibleAgents = (agents: Agent[]): Agent[] => diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index f104882a..25c45e80 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -8,10 +8,78 @@ import type { ModelMetadata } from "@/types"; import { getSafeStorage } from "./utils/safeStorage"; import type { SessionStore } from "./types/sessionTypes"; import { filterVisibleAgents } from "./useAgentsStore"; +import { isDesktopRuntime, getDesktopSettings } from "@/lib/desktop"; +import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"; +import { updateDesktopSettings } from "@/lib/persistence"; const MODELS_DEV_API_URL = "https://models.dev/api.json"; const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata"; +const FALLBACK_PROVIDER_ID = "opencode"; +const FALLBACK_MODEL_ID = "big-pickle"; + +interface OpenChamberDefaults { + defaultModel?: string; + defaultAgent?: string; +} + +const fetchOpenChamberDefaults = async (): Promise => { + try { + // 1. Desktop runtime (Tauri) + if (isDesktopRuntime()) { + const settings = await getDesktopSettings(); + return { + defaultModel: settings?.defaultModel, + defaultAgent: settings?.defaultAgent, + }; + } + + // 2. Runtime settings API (VSCode) + const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; + if (runtimeSettings) { + try { + const result = await runtimeSettings.load(); + const data = result?.settings; + if (data) { + return { + defaultModel: typeof data?.defaultModel === 'string' ? data.defaultModel : undefined, + defaultAgent: typeof data?.defaultAgent === 'string' ? data.defaultAgent : undefined, + }; + } + } catch { + // Fall through to fetch + } + } + + // 3. Fetch API (Web) + const response = await fetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + return {}; + } + const data = await response.json(); + return { + defaultModel: typeof data?.defaultModel === 'string' ? data.defaultModel : undefined, + defaultAgent: typeof data?.defaultAgent === 'string' ? data.defaultAgent : undefined, + }; + } catch { + return {}; + } +}; + +const parseModelString = (modelString: string): { providerId: string; modelId: string } | null => { + if (!modelString || typeof modelString !== 'string') { + return null; + } + const parts = modelString.split('/'); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { providerId: parts[0], modelId: parts[1] }; +}; + const normalizeProviderId = (value: string) => value?.toLowerCase?.() ?? ''; const isPrimaryMode = (mode?: string) => mode === "primary" || mode === "all" || mode === undefined || mode === null; @@ -217,12 +285,17 @@ interface ConfigStore { isConnected: boolean; isInitialized: boolean; modelsMetadata: Map; + // OpenChamber settings-based defaults (take precedence over agent preferences) + settingsDefaultModel: string | undefined; // format: "provider/model" + settingsDefaultAgent: string | undefined; loadProviders: () => Promise; loadAgents: () => Promise; setProvider: (providerId: string) => void; setModel: (modelId: string) => void; setAgent: (agentName: string | undefined) => void; + setSettingsDefaultModel: (model: string | undefined) => void; + setSettingsDefaultAgent: (agent: string | undefined) => void; saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void; getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null; checkConnection: () => Promise; @@ -257,12 +330,12 @@ export const useConfigStore = create()( isConnected: false, isInitialized: false, modelsMetadata: new Map(), + settingsDefaultModel: undefined, + settingsDefaultAgent: undefined, loadProviders: async () => { const previousProviders = get().providers; const previousDefaults = get().defaultProviders; - const previousProviderId = get().currentProviderId; - const previousModelId = get().currentModelId; let lastError: unknown = null; for (let attempt = 0; attempt < 3; attempt++) { @@ -281,16 +354,10 @@ export const useConfigStore = create()( }; }); - const defaultProviderId = defaults.provider || processedProviders[0]?.id || ""; - const provider = processedProviders.find((p) => p.id === defaultProviderId); - const defaultModelId = defaults.model || provider?.models?.[0]?.id || ""; - + // Only store providers and defaults - model/agent selection handled in loadAgents set({ providers: processedProviders, defaultProviders: defaults, - - currentProviderId: defaultProviderId, - currentModelId: defaultModelId, }); const metadata = await metadataPromise; @@ -310,8 +377,6 @@ export const useConfigStore = create()( set({ providers: previousProviders, defaultProviders: previousDefaults, - currentProviderId: previousProviderId, - currentModelId: previousModelId, }); }, @@ -355,9 +420,18 @@ export const useConfigStore = create()( for (let attempt = 0; attempt < 3; attempt++) { try { - const agents = await opencodeClient.listAgents(); + // Fetch agents and OpenChamber settings in parallel + const [agents, openChamberDefaults] = await Promise.all([ + opencodeClient.listAgents(), + fetchOpenChamberDefaults(), + ]); const safeAgents = Array.isArray(agents) ? agents : []; - set({ agents: safeAgents }); + set({ + agents: safeAgents, + // Store settings defaults so setAgent can respect them + settingsDefaultModel: openChamberDefaults.defaultModel, + settingsDefaultAgent: openChamberDefaults.defaultAgent, + }); const { providers } = get(); @@ -366,33 +440,101 @@ export const useConfigStore = create()( return true; } + // --- Agent Selection --- + // Priority: settings.defaultAgent → build → first primary → first agent const primaryAgents = safeAgents.filter((agent) => isPrimaryMode(agent.mode)); const buildAgent = primaryAgents.find((agent) => agent.name === "build"); - const defaultAgent = buildAgent || primaryAgents[0] || safeAgents[0]; + const fallbackAgent = buildAgent || primaryAgents[0] || safeAgents[0]; - const existingAgentName = get().currentAgentName; - const existingAgent = existingAgentName ? safeAgents.find((agent) => agent.name === existingAgentName) : undefined; - const resolvedAgentName = existingAgent ? existingAgentName : defaultAgent.name; + let resolvedAgent: Agent | undefined; - if (resolvedAgentName !== existingAgentName) { - set({ currentAgentName: resolvedAgentName }); - } + // Helper to validate model exists in providers + const validateModel = (providerId: string, modelId: string): boolean => { + const provider = providers.find((p) => p.id === providerId); + if (!provider) return false; + return provider.models.some((m) => m.id === modelId); + }; - const agentForDefaults = existingAgent || defaultAgent; - if (agentForDefaults?.model?.providerID && agentForDefaults?.model?.modelID) { - const agentProvider = providers.find((p) => p.id === agentForDefaults.model!.providerID); - if (agentProvider) { - const agentModel = agentProvider.models.find((model) => model.id === agentForDefaults.model!.modelID); + // Track invalid settings to clear + const invalidSettings: { defaultModel?: string; defaultAgent?: string } = {}; - if (agentModel) { - set({ - currentProviderId: agentForDefaults.model!.providerID, - currentModelId: agentForDefaults.model!.modelID, - }); + // 1. Check OpenChamber settings for default agent + if (openChamberDefaults.defaultAgent) { + const settingsAgent = safeAgents.find((agent) => agent.name === openChamberDefaults.defaultAgent); + if (settingsAgent) { + resolvedAgent = settingsAgent; + } else { + // Agent no longer exists - mark for clearing + invalidSettings.defaultAgent = ''; + } + } + + // 2. Fall back to default logic + if (!resolvedAgent) { + resolvedAgent = fallbackAgent; + } + + set({ currentAgentName: resolvedAgent.name }); + + // --- Model Selection --- + // Priority: settings.defaultModel → agent's preferred model → opencode/big-pickle + let resolvedProviderId: string | undefined; + let resolvedModelId: string | undefined; + + // 1. Check OpenChamber settings for default model + if (openChamberDefaults.defaultModel) { + const parsed = parseModelString(openChamberDefaults.defaultModel); + if (parsed && validateModel(parsed.providerId, parsed.modelId)) { + resolvedProviderId = parsed.providerId; + resolvedModelId = parsed.modelId; + } else { + // Model no longer exists - mark for clearing + invalidSettings.defaultModel = ''; + } + } + + // 2. Fall back to agent's preferred model + if (!resolvedProviderId && resolvedAgent?.model?.providerID && resolvedAgent?.model?.modelID) { + if (validateModel(resolvedAgent.model.providerID, resolvedAgent.model.modelID)) { + resolvedProviderId = resolvedAgent.model.providerID; + resolvedModelId = resolvedAgent.model.modelID; + } + } + + // 3. Fall back to opencode/big-pickle + if (!resolvedProviderId) { + if (validateModel(FALLBACK_PROVIDER_ID, FALLBACK_MODEL_ID)) { + resolvedProviderId = FALLBACK_PROVIDER_ID; + resolvedModelId = FALLBACK_MODEL_ID; + } else { + // Last resort: first provider's first model + const firstProvider = providers[0]; + if (firstProvider && firstProvider.models[0]) { + resolvedProviderId = firstProvider.id; + resolvedModelId = firstProvider.models[0].id; } } } + if (resolvedProviderId && resolvedModelId) { + set({ + currentProviderId: resolvedProviderId, + currentModelId: resolvedModelId, + }); + } + + // Clear invalid settings from storage (best-effort cleanup) + if (Object.keys(invalidSettings).length > 0) { + // Also clear from store state + set({ + settingsDefaultModel: invalidSettings.defaultModel !== undefined ? undefined : get().settingsDefaultModel, + settingsDefaultAgent: invalidSettings.defaultAgent !== undefined ? undefined : get().settingsDefaultAgent, + }); + updateDesktopSettings(invalidSettings).catch(() => { + // Ignore errors - best effort cleanup + }); + } + return true; } catch (error) { lastError = error; @@ -407,7 +549,7 @@ export const useConfigStore = create()( }, setAgent: (agentName: string | undefined) => { - const { agents, providers } = get(); + const { agents, providers, settingsDefaultModel } = get(); set({ currentAgentName: agentName }); @@ -452,6 +594,22 @@ export const useConfigStore = create()( } } + // If settings has a default model, use it instead of agent's preferred + if (settingsDefaultModel) { + const parsed = parseModelString(settingsDefaultModel); + if (parsed) { + const settingsProvider = providers.find((p) => p.id === parsed.providerId); + if (settingsProvider?.models.some((m) => m.id === parsed.modelId)) { + set({ + currentProviderId: parsed.providerId, + currentModelId: parsed.modelId, + }); + return; + } + } + } + + // Fall back to agent's preferred model const agent = agents.find((candidate) => candidate.name === agentName); if (agent?.model?.providerID && agent?.model?.modelID) { const agentProvider = providers.find((provider) => provider.id === agent.model!.providerID); @@ -469,6 +627,14 @@ export const useConfigStore = create()( } }, + setSettingsDefaultModel: (model: string | undefined) => { + set({ settingsDefaultModel: model }); + }, + + setSettingsDefaultAgent: (agent: string | undefined) => { + set({ settingsDefaultAgent: agent }); + }, + checkConnection: async () => { const maxAttempts = 5; let attempt = 0; diff --git a/packages/ui/src/stores/useSessionStore.ts b/packages/ui/src/stores/useSessionStore.ts index d91e86bb..9bcc8db4 100644 --- a/packages/ui/src/stores/useSessionStore.ts +++ b/packages/ui/src/stores/useSessionStore.ts @@ -126,10 +126,20 @@ export const useSessionStore = create()( try { const configState = useConfigStore.getState(); const visibleAgents = configState.getVisibleAgents(); - const agentName = - configState.currentAgentName || - visibleAgents.find((agent) => agent.name === 'build')?.name || - visibleAgents[0]?.name; + + // Priority: settingsDefaultAgent → build → first visible + let agentName: string | undefined; + if (configState.settingsDefaultAgent) { + const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent); + if (settingsAgent) { + agentName = settingsAgent.name; + } + } + if (!agentName) { + agentName = + visibleAgents.find((agent) => agent.name === 'build')?.name || + visibleAgents[0]?.name; + } if (agentName) { configState.setAgent(agentName); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 30beab02..a37571d0 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -415,6 +415,13 @@ const sanitizeSettingsUpdate = (payload) => { result.typographySizes = typography; } + if (typeof candidate.defaultModel === 'string' && candidate.defaultModel.length > 0) { + result.defaultModel = candidate.defaultModel; + } + if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) { + result.defaultAgent = candidate.defaultAgent; + } + return result; };