From 9111611bdc5c5f992ec931f215af70f4b6428418 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 14 Jun 2026 21:36:05 +0300 Subject: [PATCH] fix: start draft sessions from default model/agent and honor OpenCode default_agent A new draft session inherited the previous session's model/agent instead of resetting to defaults, because opening a draft restored the directory snapshot without re-applying the startup default cascade. When the prior session ran in a worktree, defaults were resolved against the worktree directory's provider list, which omits project/global-scoped providers, so the default agent's model fell back to opencode/big-pickle. Resolve the default agent/model via a shared cascade (settings default -> OpenCode default_agent -> build -> first), resolve the model from the agent's pinned model/variant or OpenCode's config model, and activate the project's config (not the worktree's) when opening a draft. --- CHANGELOG.md | 3 + packages/ui/src/stores/useConfigStore.ts | 338 ++++++++++++++++++----- packages/ui/src/sync/session-ui-store.ts | 11 +- 3 files changed, 276 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37534f46..90c3e6ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ All notable changes to this project will be documented in this file. - Notifications: notification streams now stay connected more reliably behind proxies (thanks to @kostazol). - Mobile: the empty Changes view keeps a close control visible (thanks to @lilyzhaun). - Security: file previews and downloads now reject paths outside the allowed workspace unless access has been granted. +- Sessions: fixed a bug where a running session would briefly flicker as idle (in the sidebar, the send/stop button, and the status row) when the app is protected by a password. +- Desktop: you can now open developer tools from the Help menu. +- Sessions: new draft sessions now start from the default model and agent instead of inheriting the previous session's selection, and fall back to OpenCode's own `default_agent` (and its model) when no OpenChamber default is set. ## [1.12.4] - 2026-06-11 diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 16061e26..07038eb0 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -265,6 +265,128 @@ const resolveProviderModelSelection = ({ return null; }; +type DefaultAgentModelSelection = { + agentName: string | undefined; + providerId?: string; + modelId?: string; + variant?: string; +}; + +// Shared default-selection cascade used both at startup (loadAgents) and when opening a +// fresh draft (applyDefaultModelAgentSelection), so the two paths stay identical. +// +// Agent: settings.defaultAgent → opencode default_agent → build → first primary → first +// Model: settings.defaultModel → resolved agent's pinned model+variant → opencode config.model +// → opencode/big-pickle → first +// +// The opencode default_agent / default model (config fields on the OpenCode server) are honored +// only when our own settings have no valid default. OpenCode itself resolves a model the same way: +// an agent's pinned model wins, otherwise the global `model` config applies — so we check the +// agent's model before opencodeDefaultModel. When the agent supplies the model, its `variant` is +// carried through too (if the model actually exposes that variant). +const resolveDefaultAgentModelSelection = ({ + agents, + providers, + settingsDefaultAgent, + settingsDefaultModel, + settingsDefaultVariant, + opencodeDefaultAgent, + opencodeDefaultModel, +}: { + agents: Agent[]; + providers: ProviderWithModelList[]; + settingsDefaultAgent?: string; + settingsDefaultModel?: string; + settingsDefaultVariant?: string; + opencodeDefaultAgent?: string; + opencodeDefaultModel?: string; +}): DefaultAgentModelSelection => { + if (agents.length === 0) { + return { agentName: undefined }; + } + + const resolveVariant = (providerId: string, modelId: string, variant?: string): string | undefined => { + if (!variant) { + return undefined; + } + const model = providers + .find((provider) => provider.id === providerId) + ?.models.find((entry) => entry.id === modelId) as { variants?: Record } | undefined; + return model?.variants && Object.prototype.hasOwnProperty.call(model.variants, variant) + ? variant + : undefined; + }; + + // --- Agent cascade --- + const primaryAgents = agents.filter((agent) => isPrimaryMode(agent.mode)); + + let resolvedAgent: Agent | undefined; + if (settingsDefaultAgent) { + resolvedAgent = agents.find((agent) => agent.name === settingsDefaultAgent); + } + if (!resolvedAgent && opencodeDefaultAgent) { + const candidate = agents.find((agent) => agent.name === opencodeDefaultAgent); + // OpenCode requires the default agent to be a visible primary agent. + if (candidate && isPrimaryMode(candidate.mode) && candidate.hidden !== true) { + resolvedAgent = candidate; + } + } + if (!resolvedAgent) { + resolvedAgent = primaryAgents.find((agent) => agent.name === "build") || primaryAgents[0] || agents[0]; + } + if (!resolvedAgent) { + return { agentName: undefined }; + } + + // --- Model cascade --- + let providerId: string | undefined; + let modelId: string | undefined; + let variant: string | undefined; + + if (settingsDefaultModel) { + const parsed = parseModelString(settingsDefaultModel); + if (parsed && hasProviderModel(providers, parsed.providerId, parsed.modelId)) { + providerId = parsed.providerId; + modelId = parsed.modelId; + variant = resolveVariant(providerId, modelId, settingsDefaultVariant); + } + } + + if (!providerId + && resolvedAgent.model?.providerID + && resolvedAgent.model?.modelID + && hasProviderModel(providers, resolvedAgent.model.providerID, resolvedAgent.model.modelID)) { + providerId = resolvedAgent.model.providerID; + modelId = resolvedAgent.model.modelID; + variant = resolveVariant(providerId, modelId, resolvedAgent.variant); + } + + // OpenCode's global default model — used when neither our settings nor the agent pin a model. + if (!providerId && opencodeDefaultModel) { + const parsed = parseModelString(opencodeDefaultModel); + if (parsed && hasProviderModel(providers, parsed.providerId, parsed.modelId)) { + providerId = parsed.providerId; + modelId = parsed.modelId; + } + } + + if (!providerId) { + if (hasProviderModel(providers, FALLBACK_PROVIDER_ID, FALLBACK_MODEL_ID)) { + providerId = FALLBACK_PROVIDER_ID; + modelId = FALLBACK_MODEL_ID; + } else { + const firstProvider = providers[0]; + const firstModel = firstProvider?.models[0]; + if (firstProvider && firstModel) { + providerId = firstProvider.id; + modelId = firstModel.id; + } + } + } + + return { agentName: resolvedAgent.name, providerId, modelId, variant }; +}; + const resolveGitGenerationModelSelection = ({ providers, settingsZenModel, @@ -652,6 +774,12 @@ interface ConfigStore { settingsDefaultModel: string | undefined; // format: "provider/model" settingsDefaultVariant: string | undefined; settingsDefaultAgent: string | undefined; + // OpenCode server's own `default_agent` config field (name of a primary agent), used as a + // fallback when our own settingsDefaultAgent is unset. Sourced from opencodeClient.getConfig(). + opencodeDefaultAgent: string | undefined; + // OpenCode server's own global `model` config field ("provider/model"), used as a fallback + // when neither our settingsDefaultModel nor the resolved agent pins a model. + opencodeDefaultModel: string | undefined; settingsAutoCreateWorktree: boolean; settingsGitmojiEnabled: boolean; settingsDefaultFileViewerPreview: boolean; @@ -730,6 +858,7 @@ interface ConfigStore { cycleCurrentVariant: () => void; getCurrentModelVariants: () => string[]; setAgent: (agentName: string | undefined) => void; + applyDefaultModelAgentSelection: () => void; setSelectedProvider: (providerId: string) => void; setSettingsDefaultModel: (model: string | undefined) => void; setSettingsDefaultVariant: (variant: string | undefined) => void; @@ -790,6 +919,8 @@ export const useConfigStore = create()( settingsDefaultModel: undefined, settingsDefaultVariant: undefined, settingsDefaultAgent: undefined, + opencodeDefaultAgent: undefined, + opencodeDefaultModel: undefined, settingsAutoCreateWorktree: false, settingsGitmojiEnabled: false, settingsDefaultFileViewerPreview: false, @@ -1573,17 +1704,24 @@ export const useConfigStore = create()( for (let attempt = 0; attempt < 3; attempt++) { try { - // Fetch agents and OpenChamber settings in parallel - const [agents, openChamberDefaults] = await Promise.all([ + // Fetch agents, OpenChamber settings, and the OpenCode config in parallel. + // The OpenCode config is best-effort: a failure should not block agent + // loading, it just means we won't honor its default_agent this round. + const [agents, openChamberDefaults, opencodeConfig] = await Promise.all([ measureStartupTrace( 'loadAgents:api', () => opencodeClient.withDirectory(fromDirectoryKey(directoryKey), () => opencodeClient.listAgents()), { directoryKey, source, requestedDirectory, effectiveDirectory, attempt: attempt + 1 }, ), fetchOpenChamberDefaults(), + opencodeClient + .withDirectory(fromDirectoryKey(directoryKey), () => opencodeClient.getConfig()) + .catch(() => null), ]); const safeAgents = Array.isArray(agents) ? agents : []; + const opencodeDefaultAgent = normalizeOptionalString(opencodeConfig?.default_agent); + const opencodeDefaultModel = normalizeOptionalString(opencodeConfig?.model); const providerLoad = _inFlightProviders.get(directoryKey); if (providerLoad) { @@ -1635,6 +1773,8 @@ export const useConfigStore = create()( settingsDefaultModel: openChamberDefaults.defaultModel, settingsDefaultVariant: openChamberDefaults.defaultVariant, settingsDefaultAgent: openChamberDefaults.defaultAgent, + opencodeDefaultAgent, + opencodeDefaultModel, settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false, settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false, settingsDefaultFileViewerPreview: openChamberDefaults.defaultFileViewerPreview ?? false, @@ -1727,82 +1867,45 @@ export const useConfigStore = create()( return provider.models.some((m) => m.id === modelId); }; - // --- 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 fallbackAgent = buildAgent || primaryAgents[0] || safeAgents[0]; - - let resolvedAgent: Agent = fallbackAgent; - - // Track invalid settings to clear - const invalidSettings: { defaultModel?: string; defaultVariant?: string; defaultAgent?: string } = {}; - - // 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 = ''; - } + // Detect invalid OpenChamber settings so we can clear them from storage. + // This is independent of resolution: even though the cascade below falls + // back gracefully, stale settings pointing at removed agents/models/variants + // should be cleaned up. + const invalidSettings: { defaultModel?: string; defaultVariant?: string; defaultAgent?: string } = {}; + if (openChamberDefaults.defaultAgent && !safeAgents.some((agent) => agent.name === openChamberDefaults.defaultAgent)) { + invalidSettings.defaultAgent = ''; } - - // --- Model Selection --- - // Priority: settings.defaultModel → agent's preferred model → opencode/big-pickle - let resolvedProviderId: string | undefined; - let resolvedModelId: string | undefined; - let resolvedVariant: 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; - - if (openChamberDefaults.defaultVariant) { - const provider = providers.find((p) => p.id === parsed.providerId); - const model = provider?.models.find((m) => m.id === parsed.modelId) as { variants?: Record } | undefined; - const variants = model?.variants; - if (variants && Object.prototype.hasOwnProperty.call(variants, openChamberDefaults.defaultVariant)) { - resolvedVariant = openChamberDefaults.defaultVariant; - } else { - invalidSettings.defaultVariant = ''; - } - } - } 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) { - const { providerID, modelID } = resolvedAgent.model; - if (validateModel(providerID, modelID)) { - resolvedProviderId = providerID; - resolvedModelId = 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]; - const firstModel = firstProvider?.models[0]; - if (firstProvider && firstModel) { - resolvedProviderId = firstProvider.id; - resolvedModelId = firstModel.id; + if (openChamberDefaults.defaultModel) { + const parsed = parseModelString(openChamberDefaults.defaultModel); + if (!parsed || !validateModel(parsed.providerId, parsed.modelId)) { + invalidSettings.defaultModel = ''; + } else if (openChamberDefaults.defaultVariant) { + const provider = providers.find((p) => p.id === parsed.providerId); + const model = provider?.models.find((m) => m.id === parsed.modelId) as { variants?: Record } | undefined; + const variants = model?.variants; + if (!(variants && Object.prototype.hasOwnProperty.call(variants, openChamberDefaults.defaultVariant))) { + invalidSettings.defaultVariant = ''; } } } + // Resolve agent + model via the shared cascade: + // settings.defaultAgent → opencode default_agent → build → first primary → first + // settings.defaultModel → resolved agent's model+variant → opencode/big-pickle → first + const resolvedDefault = resolveDefaultAgentModelSelection({ + agents: safeAgents, + providers, + settingsDefaultAgent: openChamberDefaults.defaultAgent, + settingsDefaultModel: openChamberDefaults.defaultModel, + settingsDefaultVariant: openChamberDefaults.defaultVariant, + opencodeDefaultAgent, + opencodeDefaultModel, + }); + const resolvedAgentName = resolvedDefault.agentName ?? safeAgents[0].name; + const resolvedProviderId = resolvedDefault.providerId; + const resolvedModelId = resolvedDefault.modelId; + const resolvedVariant = resolvedDefault.variant; + set((state) => { const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { providers, @@ -1819,7 +1922,7 @@ export const useConfigStore = create()( ...baseSnapshot, providers, agents: safeAgents, - currentAgentName: resolvedAgent.name, + currentAgentName: resolvedAgentName, currentProviderId: resolvedProviderId ?? baseSnapshot.currentProviderId, currentModelId: resolvedModelId ?? baseSnapshot.currentModelId, currentVariant: resolvedVariant, @@ -1833,7 +1936,7 @@ export const useConfigStore = create()( }; if (state.activeDirectoryKey === directoryKey) { - nextState.currentAgentName = resolvedAgent.name; + nextState.currentAgentName = resolvedAgentName; if (resolvedProviderId && resolvedModelId) { nextState.currentProviderId = resolvedProviderId; nextState.currentModelId = resolvedModelId; @@ -2090,6 +2193,91 @@ export const useConfigStore = create()( } }, + // Re-applies the same priority cascade used at app startup (see loadAgents): + // agent: settings.defaultAgent → build → first primary → first agent + // model: settings.defaultModel → agent's preferred model → opencode/big-pickle → first + // Used when entering a fresh draft session so model/agent reset to defaults + // instead of sticking to the previously open session's selection. + applyDefaultModelAgentSelection: () => { + const { + agents, + providers, + settingsDefaultModel, + settingsDefaultVariant, + settingsDefaultAgent, + opencodeDefaultAgent, + opencodeDefaultModel, + } = get(); + + if (agents.length === 0 || providers.length === 0) { + return; + } + + const { + agentName: resolvedAgentName, + providerId: resolvedProviderId, + modelId: resolvedModelId, + variant: resolvedVariant, + } = resolveDefaultAgentModelSelection({ + agents, + providers, + settingsDefaultAgent, + settingsDefaultModel, + settingsDefaultVariant, + opencodeDefaultAgent, + opencodeDefaultModel, + }); + + if (!resolvedAgentName) { + return; + } + + set((state) => { + const directoryKey = state.activeDirectoryKey; + const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { + providers: state.providers, + agents: state.agents, + currentProviderId: state.currentProviderId, + currentModelId: state.currentModelId, + currentVariant: state.currentVariant, + currentAgentName: state.currentAgentName, + selectedProviderId: state.selectedProviderId, + agentModelSelections: state.agentModelSelections, + defaultProviders: state.defaultProviders, + }; + + const nextSnapshot: DirectoryScopedConfig = { + ...baseSnapshot, + currentAgentName: resolvedAgentName, + ...(resolvedProviderId && resolvedModelId + ? { + currentProviderId: resolvedProviderId, + currentModelId: resolvedModelId, + currentVariant: resolvedVariant, + selectedProviderId: resolvedProviderId, + } + : {}), + }; + + const nextState: Partial = { + currentAgentName: resolvedAgentName, + directoryScoped: { + ...state.directoryScoped, + [directoryKey]: nextSnapshot, + }, + }; + + if (resolvedProviderId && resolvedModelId) { + nextState.currentProviderId = resolvedProviderId; + nextState.currentModelId = resolvedModelId; + nextState.currentVariant = resolvedVariant; + nextState.selectedProviderId = resolvedProviderId; + } + + return nextState; + }); + }, + setSettingsDefaultModel: (model: string | undefined) => { set({ settingsDefaultModel: model }); }, diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index fcf0a4b0..a2f86d4f 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -617,7 +617,16 @@ export const useSessionUIStore = create()((set, get) => ({ useInputStore.getState().setPendingInputText(options.initialPrompt) } - void activateConfigForDirectory(directory) + // Config (providers/agents/default model+agent) lives at the PROJECT level. When the user + // came from a worktree session, `directory` is the worktree path, whose provider list does + // not include project/global-scoped providers (e.g. the default agent's non-opencode model) + // — resolving defaults against it would wrongly fall back to opencode/big-pickle. Activate + // the project's config instead so the default cascade matches app startup, then re-apply it + // (a fresh draft must start from defaults, not inherit the previous session's selection). + const configDirectory = normalizePath(selectedProject?.path ?? null) ?? directory + void activateConfigForDirectory(configDirectory).then(() => { + useConfigStore.getState().applyDefaultModelAgentSelection() + }) }, // ---------------------------------------------------------------------------