2025-12-07 19:32:53 +02:00
|
|
|
import { create } from "zustand";
|
|
|
|
|
import type { StoreApi, UseBoundStore } from "zustand";
|
2026-06-30 04:47:52 -04:00
|
|
|
import { devtools, persist } from "zustand/middleware";
|
2026-06-17 11:21:43 +03:00
|
|
|
import type { Provider, Agent, Config } from "@opencode-ai/sdk/v2";
|
2025-12-07 19:32:53 +02:00
|
|
|
import { opencodeClient } from "@/lib/opencode/client";
|
|
|
|
|
import { scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
|
|
|
|
|
import type { ModelMetadata } from "@/types";
|
2026-06-30 04:47:52 -04:00
|
|
|
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
|
2025-12-18 19:02:42 +02:00
|
|
|
import { filterVisibleAgents } from "./useAgentsStore";
|
2026-06-24 06:34:51 +11:00
|
|
|
import { isPrimaryMode } from "@/components/chat/mobileControlsUtils";
|
2026-03-31 18:47:00 +03:00
|
|
|
import { useSessionUIStore } from "@/sync/session-ui-store";
|
|
|
|
|
import { useSelectionStore } from "@/sync/selection-store";
|
2025-12-26 04:38:38 +02:00
|
|
|
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
|
|
|
|
|
import { updateDesktopSettings } from "@/lib/persistence";
|
2026-01-06 21:31:04 +02:00
|
|
|
import { useDirectoryStore } from "@/stores/useDirectoryStore";
|
2026-06-14 22:04:32 +03:00
|
|
|
import { useProjectsStore } from "@/stores/useProjectsStore";
|
|
|
|
|
import { resolveProjectForSessionDirectory } from "@/lib/projectResolution";
|
2026-01-06 21:31:04 +02:00
|
|
|
import { streamDebugEnabled } from "@/stores/utils/streamDebug";
|
2026-05-01 00:34:36 +03:00
|
|
|
import { parseModelIdentifier } from "@/lib/modelIdentifier";
|
2026-06-02 00:43:05 +03:00
|
|
|
import { runtimeFetch } from "@/lib/runtime-fetch";
|
2026-06-05 15:09:24 +03:00
|
|
|
import { markStartupTrace, measureStartupTrace } from "@/lib/startupTrace";
|
2026-07-13 09:43:35 +11:00
|
|
|
import { normalizePath } from "@/lib/pathNormalization";
|
2026-06-17 11:21:43 +03:00
|
|
|
import { getSyncConfig, subscribeToSyncConfigChanges } from "@/sync/sync-refs";
|
2026-07-21 20:52:20 +03:00
|
|
|
import { getRuntimeKey } from "@/lib/runtime-switch";
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const MODELS_DEV_API_URL = "https://models.dev/api.json";
|
|
|
|
|
const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata";
|
|
|
|
|
|
2025-12-26 04:38:38 +02:00
|
|
|
const FALLBACK_PROVIDER_ID = "opencode";
|
|
|
|
|
const FALLBACK_MODEL_ID = "big-pickle";
|
2026-06-26 09:38:09 -07:00
|
|
|
// Sentinel selectedProviderId used by the providers UI while the "Add provider"
|
2026-06-28 13:05:00 +03:00
|
|
|
// form is open. It is intentionally not a real provider id and must not be
|
|
|
|
|
// persisted as a stable provider selection.
|
2026-06-26 09:38:09 -07:00
|
|
|
const ADD_PROVIDER_SENTINEL = "__add_provider__";
|
2026-02-24 05:23:57 -03:00
|
|
|
const GIT_UTILITY_PROVIDER_ID = "zen";
|
|
|
|
|
const GIT_UTILITY_PREFERRED_MODEL_ID = "big-pickle";
|
2026-06-08 14:54:02 +03:00
|
|
|
const PROVIDER_CONFIG_REFRESH_CONCURRENCY = 4;
|
2025-12-26 04:38:38 +02:00
|
|
|
|
2026-07-04 02:48:07 +03:00
|
|
|
const normalizeSttProvider = (value: unknown): 'local' | 'openai-compatible' | undefined => {
|
|
|
|
|
if (value === 'local' || value === 'openai-compatible') {
|
|
|
|
|
return value;
|
2026-05-13 19:45:03 +07:00
|
|
|
}
|
2026-07-04 02:48:07 +03:00
|
|
|
// Legacy providers: 'server' used an OpenAI-compatible endpoint;
|
|
|
|
|
// 'browser' and 'wasm' map to the local default.
|
|
|
|
|
if (value === 'server') {
|
|
|
|
|
return 'openai-compatible';
|
2026-05-13 19:45:03 +07:00
|
|
|
}
|
2026-07-04 02:48:07 +03:00
|
|
|
if (value === 'browser' || value === 'wasm') {
|
|
|
|
|
return 'local';
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
2026-05-13 19:45:03 +07:00
|
|
|
};
|
|
|
|
|
|
2025-12-26 04:38:38 +02:00
|
|
|
interface OpenChamberDefaults {
|
|
|
|
|
defaultModel?: string;
|
2026-01-08 16:08:51 +02:00
|
|
|
defaultVariant?: string;
|
2025-12-26 04:38:38 +02:00
|
|
|
defaultAgent?: string;
|
2026-01-07 23:37:32 +02:00
|
|
|
autoCreateWorktree?: boolean;
|
2026-01-17 01:09:21 -08:00
|
|
|
gitmojiEnabled?: boolean;
|
2026-04-23 07:14:14 -06:00
|
|
|
defaultFileViewerPreview?: boolean;
|
2026-02-12 19:37:28 -08:00
|
|
|
zenModel?: string;
|
2026-04-17 16:07:26 +08:00
|
|
|
messageStreamTransport?: 'auto' | 'ws' | 'sse';
|
2026-07-04 02:48:07 +03:00
|
|
|
sttProvider?: 'local' | 'openai-compatible';
|
2026-05-13 19:45:03 +07:00
|
|
|
sttServerUrl?: string;
|
|
|
|
|
sttModel?: string;
|
2026-07-04 02:48:07 +03:00
|
|
|
sttLocalModel?: string;
|
2026-05-13 19:45:03 +07:00
|
|
|
sttLanguage?: string;
|
2025-12-26 04:38:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
2026-06-05 15:09:24 +03:00
|
|
|
markStartupTrace('config.defaults:start');
|
|
|
|
|
const started = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
|
|
|
const finish = (source: string, result: OpenChamberDefaults) => {
|
|
|
|
|
const ended = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
|
|
|
markStartupTrace('config.defaults:end', {
|
|
|
|
|
source,
|
|
|
|
|
durationMs: Math.round(ended - started),
|
|
|
|
|
hasDefaultModel: Boolean(result.defaultModel),
|
|
|
|
|
hasDefaultAgent: Boolean(result.defaultAgent),
|
|
|
|
|
});
|
|
|
|
|
return result;
|
|
|
|
|
};
|
2025-12-26 04:38:38 +02:00
|
|
|
try {
|
2026-02-05 01:59:49 +02:00
|
|
|
// 1. Runtime settings API (VSCode)
|
2025-12-26 04:38:38 +02:00
|
|
|
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
|
|
|
|
if (runtimeSettings) {
|
|
|
|
|
try {
|
|
|
|
|
const result = await runtimeSettings.load();
|
|
|
|
|
const data = result?.settings;
|
|
|
|
|
if (data) {
|
2026-01-08 16:08:51 +02:00
|
|
|
const defaultModel = typeof data?.defaultModel === 'string' ? data.defaultModel.trim() : '';
|
|
|
|
|
const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : '';
|
|
|
|
|
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
|
2026-01-17 01:09:21 -08:00
|
|
|
const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined;
|
2026-04-23 07:14:14 -06:00
|
|
|
const defaultFileViewerPreview = typeof data?.defaultFileViewerPreview === 'boolean' ? data.defaultFileViewerPreview : undefined;
|
2026-02-12 19:37:28 -08:00
|
|
|
const zenModel = typeof data?.zenModel === 'string' ? data.zenModel.trim() : '';
|
2026-04-17 16:07:26 +08:00
|
|
|
const messageStreamTransport =
|
|
|
|
|
data?.messageStreamTransport === 'ws' || data?.messageStreamTransport === 'sse' || data?.messageStreamTransport === 'auto'
|
|
|
|
|
? data.messageStreamTransport
|
|
|
|
|
: undefined;
|
2026-07-04 02:48:07 +03:00
|
|
|
const sttProvider = normalizeSttProvider(data?.sttProvider);
|
2026-05-13 19:45:03 +07:00
|
|
|
const sttServerUrl = typeof data?.sttServerUrl === 'string' ? data.sttServerUrl.trim() : undefined;
|
|
|
|
|
const sttModel = typeof data?.sttModel === 'string' ? data.sttModel.trim() : undefined;
|
2026-07-04 02:48:07 +03:00
|
|
|
const sttLocalModel = typeof data?.sttLocalModel === 'string' ? data.sttLocalModel.trim() : undefined;
|
2026-05-13 19:45:03 +07:00
|
|
|
const sttLanguage = typeof data?.sttLanguage === 'string' ? data.sttLanguage.trim() : undefined;
|
2026-01-08 16:08:51 +02:00
|
|
|
|
2026-06-05 15:09:24 +03:00
|
|
|
return finish('runtime-settings', {
|
2026-01-08 16:08:51 +02:00
|
|
|
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
|
|
|
|
|
defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined,
|
|
|
|
|
defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined,
|
2026-01-07 23:37:32 +02:00
|
|
|
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
|
2026-01-17 01:09:21 -08:00
|
|
|
gitmojiEnabled,
|
2026-04-23 07:14:14 -06:00
|
|
|
defaultFileViewerPreview,
|
2026-02-12 19:37:28 -08:00
|
|
|
zenModel: zenModel.length > 0 ? zenModel : undefined,
|
2026-04-17 16:07:26 +08:00
|
|
|
messageStreamTransport,
|
2026-05-13 19:45:03 +07:00
|
|
|
sttProvider,
|
|
|
|
|
sttServerUrl,
|
|
|
|
|
sttModel,
|
2026-07-04 02:48:07 +03:00
|
|
|
sttLocalModel,
|
2026-05-13 19:45:03 +07:00
|
|
|
sttLanguage,
|
2026-06-05 15:09:24 +03:00
|
|
|
});
|
2025-12-26 04:38:38 +02:00
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Fall through to fetch
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 01:59:49 +02:00
|
|
|
// 2. Fetch API (Web/server)
|
2026-06-02 00:43:05 +03:00
|
|
|
const response = await runtimeFetch('/api/config/settings', {
|
2025-12-26 04:38:38 +02:00
|
|
|
method: 'GET',
|
|
|
|
|
headers: { Accept: 'application/json' },
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) {
|
2026-06-05 15:09:24 +03:00
|
|
|
return finish('settings-route-not-ok', {});
|
2025-12-26 04:38:38 +02:00
|
|
|
}
|
|
|
|
|
const data = await response.json();
|
2026-01-08 16:08:51 +02:00
|
|
|
const defaultModel = typeof data?.defaultModel === 'string' ? data.defaultModel.trim() : '';
|
|
|
|
|
const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : '';
|
|
|
|
|
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
|
2026-01-17 01:09:21 -08:00
|
|
|
const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined;
|
2026-04-23 07:14:14 -06:00
|
|
|
const defaultFileViewerPreview = typeof data?.defaultFileViewerPreview === 'boolean' ? data.defaultFileViewerPreview : undefined;
|
2026-02-12 19:37:28 -08:00
|
|
|
const zenModel = typeof data?.zenModel === 'string' ? data.zenModel.trim() : '';
|
2026-04-17 16:07:26 +08:00
|
|
|
const messageStreamTransport =
|
|
|
|
|
data?.messageStreamTransport === 'ws' || data?.messageStreamTransport === 'sse' || data?.messageStreamTransport === 'auto'
|
|
|
|
|
? data.messageStreamTransport
|
|
|
|
|
: undefined;
|
2026-07-04 02:48:07 +03:00
|
|
|
const sttProvider = normalizeSttProvider(data?.sttProvider);
|
2026-05-13 19:45:03 +07:00
|
|
|
const sttServerUrl = typeof data?.sttServerUrl === 'string' ? data.sttServerUrl.trim() : undefined;
|
|
|
|
|
const sttModel = typeof data?.sttModel === 'string' ? data.sttModel.trim() : undefined;
|
2026-07-04 02:48:07 +03:00
|
|
|
const sttLocalModel = typeof data?.sttLocalModel === 'string' ? data.sttLocalModel.trim() : undefined;
|
2026-05-13 19:45:03 +07:00
|
|
|
const sttLanguage = typeof data?.sttLanguage === 'string' ? data.sttLanguage.trim() : undefined;
|
2026-01-08 16:08:51 +02:00
|
|
|
|
2026-06-05 15:09:24 +03:00
|
|
|
return finish('settings-route', {
|
2026-01-08 16:08:51 +02:00
|
|
|
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
|
|
|
|
|
defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined,
|
|
|
|
|
defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined,
|
2026-01-07 23:37:32 +02:00
|
|
|
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
|
2026-01-17 01:09:21 -08:00
|
|
|
gitmojiEnabled,
|
2026-04-23 07:14:14 -06:00
|
|
|
defaultFileViewerPreview,
|
2026-02-12 19:37:28 -08:00
|
|
|
zenModel: zenModel.length > 0 ? zenModel : undefined,
|
2026-04-17 16:07:26 +08:00
|
|
|
messageStreamTransport,
|
2026-05-13 19:45:03 +07:00
|
|
|
sttProvider,
|
|
|
|
|
sttServerUrl,
|
|
|
|
|
sttModel,
|
2026-07-04 02:48:07 +03:00
|
|
|
sttLocalModel,
|
2026-05-13 19:45:03 +07:00
|
|
|
sttLanguage,
|
2026-06-05 15:09:24 +03:00
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
markStartupTrace('config.defaults:error', { error: error instanceof Error ? error.message : String(error) });
|
|
|
|
|
return finish('error', {});
|
2025-12-26 04:38:38 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const parseModelString = (modelString: string): { providerId: string; modelId: string } | null => {
|
2026-05-01 00:34:36 +03:00
|
|
|
return parseModelIdentifier(modelString);
|
2025-12-26 04:38:38 +02:00
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const normalizeProviderId = (value: string) => value?.toLowerCase?.() ?? '';
|
|
|
|
|
|
|
|
|
|
type ProviderModel = Provider["models"][string];
|
|
|
|
|
type ProviderWithModelList = Omit<Provider, "models"> & { models: ProviderModel[] };
|
|
|
|
|
|
2026-02-24 05:23:57 -03:00
|
|
|
type GitModelSelection = { providerId: string; modelId: string };
|
2026-06-08 14:54:02 +03:00
|
|
|
type ProviderModelSelection = { providerId: string; modelId: string; variant?: string } | null;
|
2026-02-24 05:23:57 -03:00
|
|
|
|
2026-06-28 13:05:00 +03:00
|
|
|
const sanitizePersistedSelectedProviderId = (providerId: string | undefined): string => (
|
|
|
|
|
providerId === ADD_PROVIDER_SENTINEL ? "" : (providerId ?? "")
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-24 05:23:57 -03:00
|
|
|
const normalizeOptionalString = (value: unknown): string | undefined => {
|
|
|
|
|
if (typeof value !== "string") {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
const trimmed = value.trim();
|
|
|
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const hasProviderModel = (
|
|
|
|
|
providers: ProviderWithModelList[],
|
|
|
|
|
providerId: string,
|
|
|
|
|
modelId: string
|
|
|
|
|
): boolean => {
|
|
|
|
|
const provider = providers.find((item) => item.id === providerId);
|
|
|
|
|
if (!provider) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return provider.models.some((model) => model.id === modelId);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-08 14:54:02 +03:00
|
|
|
const resolveProviderModelSelection = ({
|
|
|
|
|
providers,
|
|
|
|
|
currentProviderId,
|
|
|
|
|
currentModelId,
|
|
|
|
|
currentVariant,
|
|
|
|
|
settingsDefaultModel,
|
|
|
|
|
settingsDefaultVariant,
|
|
|
|
|
}: {
|
|
|
|
|
providers: ProviderWithModelList[];
|
|
|
|
|
currentProviderId?: string;
|
|
|
|
|
currentModelId?: string;
|
|
|
|
|
currentVariant?: string;
|
|
|
|
|
settingsDefaultModel?: string;
|
|
|
|
|
settingsDefaultVariant?: string;
|
|
|
|
|
}): ProviderModelSelection => {
|
|
|
|
|
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<string, unknown> } | undefined;
|
|
|
|
|
|
|
|
|
|
return model?.variants && Object.prototype.hasOwnProperty.call(model.variants, variant)
|
|
|
|
|
? variant
|
|
|
|
|
: undefined;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (currentProviderId && currentModelId && hasProviderModel(providers, currentProviderId, currentModelId)) {
|
|
|
|
|
return {
|
|
|
|
|
providerId: currentProviderId,
|
|
|
|
|
modelId: currentModelId,
|
|
|
|
|
variant: resolveVariant(currentProviderId, currentModelId, currentVariant),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (settingsDefaultModel) {
|
|
|
|
|
const parsed = parseModelString(settingsDefaultModel);
|
|
|
|
|
if (parsed && hasProviderModel(providers, parsed.providerId, parsed.modelId)) {
|
|
|
|
|
return {
|
|
|
|
|
providerId: parsed.providerId,
|
|
|
|
|
modelId: parsed.modelId,
|
|
|
|
|
variant: resolveVariant(parsed.providerId, parsed.modelId, settingsDefaultVariant),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (hasProviderModel(providers, FALLBACK_PROVIDER_ID, FALLBACK_MODEL_ID)) {
|
|
|
|
|
return { providerId: FALLBACK_PROVIDER_ID, modelId: FALLBACK_MODEL_ID };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const firstProvider = providers[0];
|
|
|
|
|
const firstModel = firstProvider?.models[0];
|
|
|
|
|
if (firstProvider && firstModel) {
|
|
|
|
|
return { providerId: firstProvider.id, modelId: firstModel.id };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-14 21:36:05 +03:00
|
|
|
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
|
2026-07-09 13:54:05 +03:00
|
|
|
// Model: project.defaultModel → settings.defaultModel → resolved agent's pinned model+variant → opencode config.model
|
2026-06-14 21:36:05 +03:00
|
|
|
// → 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,
|
2026-07-09 13:54:05 +03:00
|
|
|
projectDefaultModel,
|
2026-08-22 20:31:20 +03:00
|
|
|
projectDefaultVariant,
|
2026-06-14 21:36:05 +03:00
|
|
|
settingsDefaultAgent,
|
|
|
|
|
settingsDefaultModel,
|
|
|
|
|
settingsDefaultVariant,
|
|
|
|
|
opencodeDefaultAgent,
|
|
|
|
|
opencodeDefaultModel,
|
|
|
|
|
}: {
|
|
|
|
|
agents: Agent[];
|
|
|
|
|
providers: ProviderWithModelList[];
|
2026-07-09 13:54:05 +03:00
|
|
|
projectDefaultModel?: string;
|
2026-08-22 20:31:20 +03:00
|
|
|
projectDefaultVariant?: string;
|
2026-06-14 21:36:05 +03:00
|
|
|
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<string, unknown> } | 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;
|
|
|
|
|
|
2026-07-09 13:54:05 +03:00
|
|
|
const effectiveDefaultModel = projectDefaultModel || settingsDefaultModel;
|
|
|
|
|
|
|
|
|
|
if (effectiveDefaultModel) {
|
|
|
|
|
const parsed = parseModelString(effectiveDefaultModel);
|
2026-06-14 21:36:05 +03:00
|
|
|
if (parsed && hasProviderModel(providers, parsed.providerId, parsed.modelId)) {
|
|
|
|
|
providerId = parsed.providerId;
|
|
|
|
|
modelId = parsed.modelId;
|
2026-08-22 20:31:20 +03:00
|
|
|
// 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);
|
2026-06-14 21:36:05 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-24 05:23:57 -03:00
|
|
|
const resolveGitGenerationModelSelection = ({
|
|
|
|
|
providers,
|
|
|
|
|
settingsZenModel,
|
|
|
|
|
}: {
|
|
|
|
|
providers: ProviderWithModelList[];
|
|
|
|
|
settingsZenModel?: string;
|
|
|
|
|
}): GitModelSelection | null => {
|
|
|
|
|
const zenModel = normalizeOptionalString(settingsZenModel);
|
|
|
|
|
|
|
|
|
|
if (!Array.isArray(providers) || providers.length === 0) {
|
|
|
|
|
if (zenModel) {
|
|
|
|
|
return { providerId: GIT_UTILITY_PROVIDER_ID, modelId: zenModel };
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (zenModel && hasProviderModel(providers, GIT_UTILITY_PROVIDER_ID, zenModel)) {
|
|
|
|
|
return { providerId: GIT_UTILITY_PROVIDER_ID, modelId: zenModel };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (hasProviderModel(providers, GIT_UTILITY_PROVIDER_ID, GIT_UTILITY_PREFERRED_MODEL_ID)) {
|
|
|
|
|
return { providerId: GIT_UTILITY_PROVIDER_ID, modelId: GIT_UTILITY_PREFERRED_MODEL_ID };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const zenProvider = providers.find((provider) => provider.id === GIT_UTILITY_PROVIDER_ID);
|
|
|
|
|
if (zenProvider?.models.length) {
|
|
|
|
|
const randomIndex = Math.floor(Math.random() * zenProvider.models.length);
|
|
|
|
|
const randomModelId = normalizeOptionalString(zenProvider.models[randomIndex]?.id);
|
|
|
|
|
if (randomModelId) {
|
|
|
|
|
return { providerId: GIT_UTILITY_PROVIDER_ID, modelId: randomModelId };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
interface ModelsDevModelEntry {
|
|
|
|
|
id?: string;
|
|
|
|
|
name?: string;
|
|
|
|
|
tool_call?: boolean;
|
|
|
|
|
reasoning?: boolean;
|
|
|
|
|
temperature?: boolean;
|
|
|
|
|
attachment?: boolean;
|
2026-08-02 16:22:55 +03:00
|
|
|
structured_output?: boolean;
|
2025-12-07 19:32:53 +02:00
|
|
|
modalities?: {
|
|
|
|
|
input?: string[];
|
|
|
|
|
output?: string[];
|
|
|
|
|
};
|
|
|
|
|
cost?: {
|
|
|
|
|
input?: number;
|
|
|
|
|
output?: number;
|
|
|
|
|
cache_read?: number;
|
|
|
|
|
cache_write?: number;
|
|
|
|
|
};
|
|
|
|
|
limit?: {
|
|
|
|
|
context?: number;
|
|
|
|
|
output?: number;
|
|
|
|
|
};
|
|
|
|
|
knowledge?: string;
|
|
|
|
|
release_date?: string;
|
|
|
|
|
last_updated?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ModelsDevProviderEntry {
|
|
|
|
|
id?: string;
|
|
|
|
|
models?: Record<string, ModelsDevModelEntry | undefined>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
|
|
|
typeof value === "object" && value !== null;
|
|
|
|
|
|
|
|
|
|
const isStringArray = (value: unknown): value is string[] =>
|
|
|
|
|
Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
|
|
|
|
|
|
|
|
const isModelsDevModelEntry = (value: unknown): value is ModelsDevModelEntry => {
|
|
|
|
|
if (!isRecord(value)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const candidate = value as ModelsDevModelEntry;
|
|
|
|
|
if (candidate.modalities) {
|
|
|
|
|
const { input, output } = candidate.modalities;
|
|
|
|
|
if (input && !isStringArray(input)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (output && !isStringArray(output)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isModelsDevProviderEntry = (value: unknown): value is ModelsDevProviderEntry => {
|
|
|
|
|
if (!isRecord(value)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const candidate = value as ModelsDevProviderEntry;
|
|
|
|
|
return candidate.models === undefined || isRecord(candidate.models);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const buildModelMetadataKey = (providerId: string, modelId: string) => {
|
|
|
|
|
const normalizedProvider = normalizeProviderId(providerId);
|
|
|
|
|
if (!normalizedProvider || !modelId) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
return `${normalizedProvider}/${modelId}`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-07 21:47:53 +09:00
|
|
|
const mapModalities = (cap: { text: boolean; audio: boolean; image: boolean; video: boolean; pdf: boolean } | undefined): string[] => {
|
|
|
|
|
if (!cap) return [];
|
2026-03-23 20:02:27 +08:00
|
|
|
const result: string[] = [];
|
|
|
|
|
if (cap.text) result.push('text');
|
|
|
|
|
if (cap.audio) result.push('audio');
|
|
|
|
|
if (cap.image) result.push('image');
|
|
|
|
|
if (cap.video) result.push('video');
|
|
|
|
|
if (cap.pdf) result.push('pdf');
|
|
|
|
|
return result;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const deriveModelMetadata = (providerId: string, model: ProviderModel): ModelMetadata => ({
|
|
|
|
|
id: model.id,
|
|
|
|
|
providerId,
|
|
|
|
|
name: model.name,
|
2026-04-07 21:47:53 +09:00
|
|
|
tool_call: model.capabilities?.toolcall,
|
|
|
|
|
reasoning: model.capabilities?.reasoning,
|
|
|
|
|
temperature: model.capabilities?.temperature,
|
|
|
|
|
attachment: model.capabilities?.attachment,
|
|
|
|
|
modalities: model.capabilities ? {
|
2026-03-23 20:02:27 +08:00
|
|
|
input: mapModalities(model.capabilities.input),
|
|
|
|
|
output: mapModalities(model.capabilities.output),
|
2026-04-07 21:47:53 +09:00
|
|
|
} : undefined,
|
|
|
|
|
cost: model.cost ? {
|
2026-03-23 20:02:27 +08:00
|
|
|
input: model.cost.input,
|
|
|
|
|
output: model.cost.output,
|
2026-04-07 21:47:53 +09:00
|
|
|
cache_read: model.cost.cache?.read,
|
|
|
|
|
cache_write: model.cost.cache?.write,
|
|
|
|
|
} : undefined,
|
2026-03-23 20:02:27 +08:00
|
|
|
limit: model.limit,
|
|
|
|
|
release_date: model.release_date,
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const transformModelsDevResponse = (payload: unknown): Map<string, ModelMetadata> => {
|
|
|
|
|
const metadataMap = new Map<string, ModelMetadata>();
|
|
|
|
|
|
|
|
|
|
if (!isRecord(payload)) {
|
|
|
|
|
return metadataMap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [providerKey, providerValue] of Object.entries(payload)) {
|
|
|
|
|
if (!isModelsDevProviderEntry(providerValue)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const providerId = typeof providerValue.id === 'string' && providerValue.id.length > 0 ? providerValue.id : providerKey;
|
|
|
|
|
const models = providerValue.models;
|
|
|
|
|
if (!models || !isRecord(models)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [modelKey, modelValue] of Object.entries(models)) {
|
|
|
|
|
if (!isModelsDevModelEntry(modelValue)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resolvedModelId =
|
|
|
|
|
typeof modelKey === 'string' && modelKey.length > 0
|
|
|
|
|
? modelKey
|
|
|
|
|
: modelValue.id;
|
|
|
|
|
|
|
|
|
|
if (!resolvedModelId || typeof resolvedModelId !== 'string' || resolvedModelId.length === 0) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const metadata: ModelMetadata = {
|
|
|
|
|
id: typeof modelValue.id === 'string' && modelValue.id.length > 0 ? modelValue.id : resolvedModelId,
|
|
|
|
|
providerId,
|
|
|
|
|
name: typeof modelValue.name === 'string' ? modelValue.name : undefined,
|
|
|
|
|
tool_call: typeof modelValue.tool_call === 'boolean' ? modelValue.tool_call : undefined,
|
|
|
|
|
reasoning: typeof modelValue.reasoning === 'boolean' ? modelValue.reasoning : undefined,
|
|
|
|
|
temperature: typeof modelValue.temperature === 'boolean' ? modelValue.temperature : undefined,
|
|
|
|
|
attachment: typeof modelValue.attachment === 'boolean' ? modelValue.attachment : undefined,
|
2026-08-02 16:22:55 +03:00
|
|
|
structured_output:
|
|
|
|
|
typeof modelValue.structured_output === 'boolean' ? modelValue.structured_output : undefined,
|
2025-12-07 19:32:53 +02:00
|
|
|
modalities: modelValue.modalities
|
|
|
|
|
? {
|
|
|
|
|
input: isStringArray(modelValue.modalities.input) ? modelValue.modalities.input : undefined,
|
|
|
|
|
output: isStringArray(modelValue.modalities.output) ? modelValue.modalities.output : undefined,
|
|
|
|
|
}
|
|
|
|
|
: undefined,
|
|
|
|
|
cost: modelValue.cost,
|
|
|
|
|
limit: modelValue.limit,
|
|
|
|
|
knowledge: typeof modelValue.knowledge === 'string' ? modelValue.knowledge : undefined,
|
|
|
|
|
release_date: typeof modelValue.release_date === 'string' ? modelValue.release_date : undefined,
|
|
|
|
|
last_updated: typeof modelValue.last_updated === 'string' ? modelValue.last_updated : undefined,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const key = buildModelMetadataKey(providerId, resolvedModelId);
|
|
|
|
|
if (key) {
|
|
|
|
|
metadataMap.set(key, metadata);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return metadataMap;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fetchModelsDevMetadata = async (): Promise<Map<string, ModelMetadata>> => {
|
|
|
|
|
if (typeof fetch !== 'function') {
|
|
|
|
|
return new Map();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sources = [MODELS_DEV_PROXY_URL, MODELS_DEV_API_URL];
|
|
|
|
|
|
|
|
|
|
for (const source of sources) {
|
|
|
|
|
const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined;
|
|
|
|
|
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : undefined;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const isAbsoluteUrl = /^https?:\/\//i.test(source);
|
|
|
|
|
const requestInit: RequestInit = {
|
|
|
|
|
signal: controller?.signal,
|
|
|
|
|
headers: {
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
},
|
|
|
|
|
cache: 'no-store',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (isAbsoluteUrl) {
|
|
|
|
|
requestInit.mode = 'cors';
|
|
|
|
|
} else {
|
|
|
|
|
requestInit.credentials = 'same-origin';
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const response = isAbsoluteUrl
|
|
|
|
|
? await fetch(source, requestInit)
|
|
|
|
|
: await runtimeFetch(source, requestInit);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`Metadata request to ${source} returned status ${response.status}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
return transformModelsDevResponse(data);
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
if ((error as Error)?.name === 'AbortError') {
|
|
|
|
|
console.warn(`Model metadata request aborted (${source})`);
|
|
|
|
|
} else {
|
|
|
|
|
console.warn(`Failed to fetch model metadata from ${source}:`, error);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
if (timeout) {
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new Map();
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
let modelsMetadataInFlight: Promise<Map<string, ModelMetadata>> | null = null;
|
|
|
|
|
|
|
|
|
|
const ensureModelsMetadataFetch = (
|
|
|
|
|
getModelsMetadata: () => Map<string, ModelMetadata>,
|
|
|
|
|
setModelsMetadata: (metadata: Map<string, ModelMetadata>) => void,
|
|
|
|
|
) => {
|
|
|
|
|
const existing = getModelsMetadata();
|
|
|
|
|
if (existing.size > 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (modelsMetadataInFlight) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 15:09:24 +03:00
|
|
|
markStartupTrace('modelsMetadata:queued');
|
|
|
|
|
modelsMetadataInFlight = measureStartupTrace('modelsMetadata', fetchModelsDevMetadata)
|
2026-01-06 21:31:04 +02:00
|
|
|
.then((metadata) => {
|
|
|
|
|
if (metadata.size > 0) {
|
2026-06-05 15:09:24 +03:00
|
|
|
markStartupTrace('modelsMetadata:set', { entries: metadata.size });
|
2026-01-06 21:31:04 +02:00
|
|
|
setModelsMetadata(metadata);
|
|
|
|
|
}
|
|
|
|
|
return metadata;
|
|
|
|
|
})
|
|
|
|
|
.catch(() => new Map<string, ModelMetadata>())
|
|
|
|
|
.finally(() => {
|
|
|
|
|
modelsMetadataInFlight = null;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
2026-04-25 16:56:37 +03:00
|
|
|
const CONNECTION_PROBE_TIMEOUT_MS = 800;
|
|
|
|
|
|
|
|
|
|
const probeOpenCodeHealth = async (timeoutMs = CONNECTION_PROBE_TIMEOUT_MS): Promise<boolean> => {
|
|
|
|
|
return Promise.race([
|
|
|
|
|
opencodeClient.checkHealth().catch(() => false),
|
|
|
|
|
sleep(Math.max(1, timeoutMs)).then(() => false),
|
|
|
|
|
]);
|
|
|
|
|
};
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
const DIRECTORY_KEY_GLOBAL = "__global__";
|
|
|
|
|
|
|
|
|
|
const toDirectoryKey = (directory: string | null | undefined): string => {
|
|
|
|
|
const trimmed = typeof directory === 'string' ? directory.trim() : '';
|
|
|
|
|
return trimmed.length > 0 ? trimmed : DIRECTORY_KEY_GLOBAL;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fromDirectoryKey = (key: string): string | null => (key === DIRECTORY_KEY_GLOBAL ? null : key);
|
|
|
|
|
|
|
|
|
|
const resolveInitialDirectoryKey = (): string => {
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
return DIRECTORY_KEY_GLOBAL;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const directory = opencodeClient.getDirectory() ?? useDirectoryStore.getState().currentDirectory;
|
2026-06-15 03:16:34 +03:00
|
|
|
return toConfigDirectoryKey(directory);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Persisted worktree→project mapping. The runtime worktree map
|
|
|
|
|
// (availableWorktreesByProject) is populated by async git discovery and isn't
|
|
|
|
|
// ready when initializeApp runs on startup — so without this, a worktree's first
|
|
|
|
|
// config load can't resolve to its project and duplicates the project's load.
|
|
|
|
|
// We cache resolved mappings to localStorage so subsequent launches resolve the
|
|
|
|
|
// project synchronously at init time. worktree→project is effectively immutable,
|
|
|
|
|
// so a cached entry is safe to trust.
|
2026-07-21 20:52:20 +03:00
|
|
|
const WORKTREE_PROJECT_MAP_KEY = 'oc.worktreeProjectMap.v2';
|
|
|
|
|
const LEGACY_WORKTREE_PROJECT_MAP_KEY = 'oc.worktreeProjectMap';
|
|
|
|
|
const MAX_WORKTREE_PROJECT_RUNTIME_MAPS = 8;
|
|
|
|
|
type WorktreeProjectMapEnvelope = {
|
|
|
|
|
version: 2;
|
|
|
|
|
legacyClaimed: boolean;
|
|
|
|
|
runtimes: Record<string, { updatedAt: number; entries: Record<string, string> }>;
|
|
|
|
|
};
|
|
|
|
|
const _worktreeProjectMaps = new Map<string, Record<string, string>>();
|
|
|
|
|
const readWorktreeProjectEnvelope = (): WorktreeProjectMapEnvelope => {
|
|
|
|
|
try {
|
|
|
|
|
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(WORKTREE_PROJECT_MAP_KEY) : null;
|
|
|
|
|
if (!raw) return { version: 2, legacyClaimed: false, runtimes: {} };
|
|
|
|
|
const parsed = JSON.parse(raw) as Partial<WorktreeProjectMapEnvelope>;
|
|
|
|
|
if (parsed.version !== 2 || !parsed.runtimes || typeof parsed.runtimes !== 'object') {
|
|
|
|
|
return { version: 2, legacyClaimed: false, runtimes: {} };
|
|
|
|
|
}
|
|
|
|
|
return { version: 2, legacyClaimed: parsed.legacyClaimed === true, runtimes: parsed.runtimes };
|
|
|
|
|
} catch {
|
|
|
|
|
return { version: 2, legacyClaimed: false, runtimes: {} };
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const writeWorktreeProjectEnvelope = (envelope: WorktreeProjectMapEnvelope): void => {
|
|
|
|
|
const runtimes = Object.fromEntries(
|
|
|
|
|
Object.entries(envelope.runtimes)
|
|
|
|
|
.sort(([, left], [, right]) => right.updatedAt - left.updatedAt)
|
|
|
|
|
.slice(0, MAX_WORKTREE_PROJECT_RUNTIME_MAPS),
|
|
|
|
|
);
|
|
|
|
|
localStorage.setItem(WORKTREE_PROJECT_MAP_KEY, JSON.stringify({ ...envelope, runtimes }));
|
|
|
|
|
};
|
2026-06-15 03:16:34 +03:00
|
|
|
const getWorktreeProjectMap = (): Record<string, string> => {
|
2026-07-21 20:52:20 +03:00
|
|
|
const runtimeKey = getRuntimeKey() || 'default';
|
|
|
|
|
const existing = _worktreeProjectMaps.get(runtimeKey);
|
|
|
|
|
if (existing) return existing;
|
|
|
|
|
const envelope = readWorktreeProjectEnvelope();
|
|
|
|
|
let map = envelope.runtimes[runtimeKey]?.entries ?? null;
|
|
|
|
|
if (!map && !envelope.legacyClaimed) {
|
2026-06-15 03:16:34 +03:00
|
|
|
try {
|
2026-07-21 20:52:20 +03:00
|
|
|
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(LEGACY_WORKTREE_PROJECT_MAP_KEY) : null;
|
|
|
|
|
map = raw ? (JSON.parse(raw) as Record<string, string>) : {};
|
|
|
|
|
envelope.legacyClaimed = true;
|
|
|
|
|
envelope.runtimes[runtimeKey] = { updatedAt: Date.now(), entries: map };
|
|
|
|
|
writeWorktreeProjectEnvelope(envelope);
|
|
|
|
|
localStorage.removeItem(LEGACY_WORKTREE_PROJECT_MAP_KEY);
|
2026-06-15 03:16:34 +03:00
|
|
|
} catch {
|
2026-07-21 20:52:20 +03:00
|
|
|
map = {};
|
2026-06-15 03:16:34 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-21 20:52:20 +03:00
|
|
|
const result = map ?? {};
|
|
|
|
|
_worktreeProjectMaps.set(runtimeKey, result);
|
|
|
|
|
return result;
|
2026-06-15 03:16:34 +03:00
|
|
|
};
|
|
|
|
|
const rememberWorktreeProject = (worktree: string, project: string): void => {
|
|
|
|
|
if (!worktree || !project || worktree === project) return;
|
|
|
|
|
const map = getWorktreeProjectMap();
|
|
|
|
|
if (map[worktree] === project) return;
|
|
|
|
|
map[worktree] = project;
|
|
|
|
|
try {
|
2026-07-21 20:52:20 +03:00
|
|
|
const runtimeKey = getRuntimeKey() || 'default';
|
|
|
|
|
const envelope = readWorktreeProjectEnvelope();
|
|
|
|
|
envelope.legacyClaimed = true;
|
|
|
|
|
envelope.runtimes[runtimeKey] = { updatedAt: Date.now(), entries: map };
|
|
|
|
|
writeWorktreeProjectEnvelope(envelope);
|
|
|
|
|
localStorage.removeItem(LEGACY_WORKTREE_PROJECT_MAP_KEY);
|
2026-06-15 03:16:34 +03:00
|
|
|
} catch {
|
|
|
|
|
// localStorage quota exceeded — ignore; live resolution still works.
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-16 19:23:38 +03:00
|
|
|
const normalizeConfigPath = (value: string | null | undefined): string | null => {
|
2026-07-13 09:43:35 +11:00
|
|
|
const result = normalizePath(value);
|
|
|
|
|
if (result === null) return null;
|
|
|
|
|
return result || '/';
|
2026-06-16 19:23:38 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getKnownProjectDirectories = (): string[] => {
|
|
|
|
|
try {
|
|
|
|
|
return useProjectsStore.getState().projects
|
|
|
|
|
.map((project) => normalizeConfigPath(project.path))
|
|
|
|
|
.filter((path): path is string => Boolean(path));
|
|
|
|
|
} catch {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getFallbackProjectDirectory = (): string | null => {
|
|
|
|
|
try {
|
|
|
|
|
const { projects, activeProjectId } = useProjectsStore.getState();
|
|
|
|
|
const active = activeProjectId
|
|
|
|
|
? projects.find((project) => project.id === activeProjectId)
|
|
|
|
|
: null;
|
|
|
|
|
return normalizeConfigPath(active?.path ?? projects[0]?.path ?? null);
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-15 03:16:34 +03:00
|
|
|
/**
|
|
|
|
|
* Map a directory to its CONFIG scope. Providers/agents/defaults are defined at
|
|
|
|
|
* the PROJECT level (opencode.json), so a worktree must inherit its parent
|
|
|
|
|
* project's config instead of maintaining — and re-fetching — its own
|
|
|
|
|
* per-worktree snapshot. Returns the owning project's path when the directory is
|
|
|
|
|
* a known worktree, else the directory unchanged.
|
|
|
|
|
*/
|
|
|
|
|
const resolveConfigDirectory = (directory: string | null | undefined): string | null => {
|
2026-06-16 19:23:38 +03:00
|
|
|
const dir = normalizeConfigPath(directory);
|
|
|
|
|
const projects = getKnownProjectDirectories();
|
|
|
|
|
if (!dir) return null;
|
|
|
|
|
if (projects.includes(dir)) return dir;
|
|
|
|
|
|
|
|
|
|
// 1. Persisted mapping — resolves synchronously when the async worktree
|
|
|
|
|
// discovery has not populated the runtime map yet.
|
|
|
|
|
const cached = normalizeConfigPath(getWorktreeProjectMap()[dir]);
|
2026-06-15 03:16:34 +03:00
|
|
|
if (cached) return cached;
|
|
|
|
|
// 2. Live resolution via projects + discovered worktree map; cache the hit.
|
|
|
|
|
try {
|
|
|
|
|
const project = resolveProjectForSessionDirectory(
|
|
|
|
|
useProjectsStore.getState().projects,
|
|
|
|
|
useSessionUIStore.getState().availableWorktreesByProject,
|
|
|
|
|
dir,
|
|
|
|
|
);
|
2026-06-16 19:23:38 +03:00
|
|
|
const projectPath = normalizeConfigPath(project?.path ?? null);
|
|
|
|
|
if (projectPath && projectPath !== dir) {
|
|
|
|
|
rememberWorktreeProject(dir, projectPath);
|
|
|
|
|
return projectPath;
|
2026-06-15 03:16:34 +03:00
|
|
|
}
|
|
|
|
|
} catch {
|
2026-06-16 19:23:38 +03:00
|
|
|
return null;
|
2026-06-15 03:16:34 +03:00
|
|
|
}
|
2026-06-16 19:23:38 +03:00
|
|
|
return null;
|
2026-06-15 03:16:34 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toConfigDirectoryKey = (directory: string | null | undefined): string =>
|
|
|
|
|
toDirectoryKey(resolveConfigDirectory(directory));
|
|
|
|
|
|
|
|
|
|
// Runtime freshness tracking (NOT persisted) for the stale-while-revalidate
|
|
|
|
|
// background refresh, keyed by config-directory key. Prevents re-fetching
|
|
|
|
|
// project-scoped providers/agents we just loaded — e.g. initializeApp loading a
|
|
|
|
|
// project, then activateDirectory firing for the same project moments later.
|
|
|
|
|
const _providersLoadedAt = new Map<string, number>();
|
|
|
|
|
const _agentsLoadedAt = new Map<string, number>();
|
|
|
|
|
const CONFIG_REFRESH_TTL_MS = 30_000;
|
2026-06-16 19:23:38 +03:00
|
|
|
const PROJECT_CONFIG_PREWARM_DELAY_MS = 1_000;
|
2026-06-15 03:16:34 +03:00
|
|
|
const isConfigFresh = (loadedAt: Map<string, number>, key: string): boolean => {
|
|
|
|
|
const at = loadedAt.get(key);
|
|
|
|
|
return typeof at === 'number' && Date.now() - at < CONFIG_REFRESH_TTL_MS;
|
2026-01-06 21:31:04 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface DirectoryScopedConfig {
|
2026-01-08 16:08:51 +02:00
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
providers: ProviderWithModelList[];
|
|
|
|
|
agents: Agent[];
|
|
|
|
|
currentProviderId: string;
|
|
|
|
|
currentModelId: string;
|
2026-01-08 16:08:51 +02:00
|
|
|
currentVariant?: string | undefined;
|
2026-01-06 21:31:04 +02:00
|
|
|
currentAgentName: string | undefined;
|
|
|
|
|
selectedProviderId: string;
|
|
|
|
|
agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } };
|
|
|
|
|
defaultProviders: { [key: string]: string };
|
2026-06-17 11:21:43 +03:00
|
|
|
opencodeDefaultAgent?: string;
|
|
|
|
|
opencodeDefaultModel?: string;
|
|
|
|
|
selectionSource?: "auto" | "manual";
|
2026-01-06 21:31:04 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-15 03:16:34 +03:00
|
|
|
/**
|
|
|
|
|
* Lift the active directory's cached provider/agent snapshot into the top-level
|
|
|
|
|
* fields the pickers read (`providers`, `agents`, selections), so a cold start
|
|
|
|
|
* paints instantly from persisted data. Falls back to whatever top-level data
|
|
|
|
|
* was persisted; handles legacy persisted blobs that only stored directoryScoped.
|
|
|
|
|
*/
|
|
|
|
|
const hydrateActiveDirectorySnapshot = <T extends Partial<ConfigStore>>(merged: T): T => {
|
|
|
|
|
const directoryScoped = merged.directoryScoped;
|
|
|
|
|
const activeKey = merged.activeDirectoryKey;
|
|
|
|
|
if (!directoryScoped || !activeKey) return merged;
|
|
|
|
|
const snapshot = directoryScoped[activeKey];
|
|
|
|
|
if (!snapshot) return merged;
|
|
|
|
|
|
|
|
|
|
const next: Partial<ConfigStore> = { ...merged };
|
|
|
|
|
if ((!merged.providers || merged.providers.length === 0) && snapshot.providers?.length) {
|
|
|
|
|
next.providers = snapshot.providers;
|
2026-06-08 14:54:02 +03:00
|
|
|
}
|
2026-06-15 03:16:34 +03:00
|
|
|
if ((!merged.agents || merged.agents.length === 0) && snapshot.agents?.length) {
|
|
|
|
|
next.agents = snapshot.agents;
|
2026-06-08 14:54:02 +03:00
|
|
|
}
|
2026-06-15 03:16:34 +03:00
|
|
|
if (!merged.defaultProviders || Object.keys(merged.defaultProviders).length === 0) {
|
|
|
|
|
if (snapshot.defaultProviders && Object.keys(snapshot.defaultProviders).length > 0) {
|
|
|
|
|
next.defaultProviders = snapshot.defaultProviders;
|
|
|
|
|
}
|
2026-06-08 14:54:02 +03:00
|
|
|
}
|
2026-06-17 11:21:43 +03:00
|
|
|
if (snapshot.opencodeDefaultAgent !== undefined) {
|
|
|
|
|
next.opencodeDefaultAgent = snapshot.opencodeDefaultAgent;
|
|
|
|
|
}
|
|
|
|
|
if (snapshot.opencodeDefaultModel !== undefined) {
|
|
|
|
|
next.opencodeDefaultModel = snapshot.opencodeDefaultModel;
|
|
|
|
|
}
|
|
|
|
|
if (snapshot.selectionSource) {
|
|
|
|
|
next.selectionSource = snapshot.selectionSource;
|
|
|
|
|
}
|
2026-06-15 03:16:34 +03:00
|
|
|
return next as T;
|
2026-06-08 14:54:02 +03:00
|
|
|
};
|
|
|
|
|
|
2026-06-17 11:21:43 +03:00
|
|
|
const createEmptyDirectoryScopedConfig = (
|
|
|
|
|
providers: ProviderWithModelList[] = [],
|
|
|
|
|
agents: Agent[] = [],
|
|
|
|
|
): DirectoryScopedConfig => ({
|
|
|
|
|
providers,
|
|
|
|
|
agents,
|
|
|
|
|
currentProviderId: "",
|
|
|
|
|
currentModelId: "",
|
|
|
|
|
currentVariant: undefined,
|
|
|
|
|
currentAgentName: undefined,
|
|
|
|
|
selectedProviderId: "",
|
|
|
|
|
agentModelSelections: {},
|
|
|
|
|
defaultProviders: {},
|
|
|
|
|
opencodeDefaultAgent: undefined,
|
|
|
|
|
opencodeDefaultModel: undefined,
|
|
|
|
|
selectionSource: "auto",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const hasValidVariant = (
|
|
|
|
|
providers: ProviderWithModelList[],
|
|
|
|
|
providerId: string,
|
|
|
|
|
modelId: string,
|
|
|
|
|
variant: string | undefined,
|
|
|
|
|
): boolean => {
|
|
|
|
|
if (!variant) return true;
|
|
|
|
|
const model = providers
|
|
|
|
|
.find((provider) => provider.id === providerId)
|
|
|
|
|
?.models.find((entry) => entry.id === modelId) as { variants?: Record<string, unknown> } | undefined;
|
|
|
|
|
return !!model?.variants && Object.prototype.hasOwnProperty.call(model.variants, variant);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const resolveSelectionWithManualGuard = ({
|
|
|
|
|
agents,
|
|
|
|
|
providers,
|
|
|
|
|
currentAgentName,
|
|
|
|
|
currentProviderId,
|
|
|
|
|
currentModelId,
|
|
|
|
|
currentVariant,
|
|
|
|
|
selectionSource,
|
|
|
|
|
resolvedAgentName,
|
|
|
|
|
resolvedProviderId,
|
|
|
|
|
resolvedModelId,
|
|
|
|
|
resolvedVariant,
|
|
|
|
|
}: {
|
|
|
|
|
agents: Agent[];
|
|
|
|
|
providers: ProviderWithModelList[];
|
|
|
|
|
currentAgentName: string | undefined;
|
|
|
|
|
currentProviderId: string;
|
|
|
|
|
currentModelId: string;
|
|
|
|
|
currentVariant: string | undefined;
|
|
|
|
|
selectionSource: "auto" | "manual";
|
|
|
|
|
resolvedAgentName: string | undefined;
|
|
|
|
|
resolvedProviderId: string | undefined;
|
|
|
|
|
resolvedModelId: string | undefined;
|
|
|
|
|
resolvedVariant: string | undefined;
|
|
|
|
|
}) => {
|
|
|
|
|
const manualAgentName = currentAgentName && agents.some((agent) => agent.name === currentAgentName)
|
|
|
|
|
? currentAgentName
|
|
|
|
|
: undefined;
|
|
|
|
|
const manualModelValid = !!currentProviderId
|
|
|
|
|
&& !!currentModelId
|
|
|
|
|
&& hasProviderModel(providers, currentProviderId, currentModelId)
|
|
|
|
|
&& hasValidVariant(providers, currentProviderId, currentModelId, currentVariant);
|
|
|
|
|
const preserveManual = selectionSource === "manual" && (!!manualAgentName || manualModelValid);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
agentName: preserveManual ? (manualAgentName ?? resolvedAgentName) : resolvedAgentName,
|
|
|
|
|
providerId: preserveManual && manualModelValid ? currentProviderId : resolvedProviderId,
|
|
|
|
|
modelId: preserveManual && manualModelValid ? currentModelId : resolvedModelId,
|
|
|
|
|
variant: preserveManual && manualModelValid ? currentVariant : resolvedVariant,
|
|
|
|
|
selectionSource: preserveManual ? "manual" as const : "auto" as const,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
interface ConfigStore {
|
|
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
activeDirectoryKey: string;
|
|
|
|
|
directoryScoped: Record<string, DirectoryScopedConfig>;
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
providers: ProviderWithModelList[];
|
|
|
|
|
agents: Agent[];
|
|
|
|
|
currentProviderId: string;
|
|
|
|
|
currentModelId: string;
|
2026-01-08 14:48:06 +02:00
|
|
|
currentVariant: string | undefined;
|
2025-12-07 19:32:53 +02:00
|
|
|
currentAgentName: string | undefined;
|
2025-12-27 02:22:59 +02:00
|
|
|
selectedProviderId: string;
|
2025-12-07 19:32:53 +02:00
|
|
|
agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } };
|
|
|
|
|
defaultProviders: { [key: string]: string };
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "auto" | "manual";
|
2025-12-07 19:32:53 +02:00
|
|
|
isConnected: boolean;
|
2026-04-22 21:03:02 +03:00
|
|
|
hasEverConnected: boolean;
|
|
|
|
|
connectionPhase: "connecting" | "connected" | "reconnecting";
|
2026-04-22 00:33:21 +03:00
|
|
|
lastDisconnectReason: string | null;
|
2025-12-07 19:32:53 +02:00
|
|
|
isInitialized: boolean;
|
|
|
|
|
modelsMetadata: Map<string, ModelMetadata>;
|
2025-12-26 04:38:38 +02:00
|
|
|
// OpenChamber settings-based defaults (take precedence over agent preferences)
|
|
|
|
|
settingsDefaultModel: string | undefined; // format: "provider/model"
|
2026-01-08 16:08:51 +02:00
|
|
|
settingsDefaultVariant: string | undefined;
|
2025-12-26 04:38:38 +02:00
|
|
|
settingsDefaultAgent: string | undefined;
|
2026-06-14 21:36:05 +03:00
|
|
|
// OpenCode server's own `default_agent` config field (name of a primary agent), used as a
|
2026-06-17 11:21:43 +03:00
|
|
|
// fallback when our own settingsDefaultAgent is unset. Sourced from sync config.
|
2026-06-14 21:36:05 +03:00
|
|
|
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;
|
2026-01-07 23:37:32 +02:00
|
|
|
settingsAutoCreateWorktree: boolean;
|
2026-01-17 01:09:21 -08:00
|
|
|
settingsGitmojiEnabled: boolean;
|
2026-04-23 07:14:14 -06:00
|
|
|
settingsDefaultFileViewerPreview: boolean;
|
2026-02-12 19:37:28 -08:00
|
|
|
settingsZenModel: string | undefined;
|
2026-04-17 16:07:26 +08:00
|
|
|
settingsMessageStreamTransport: 'auto' | 'ws' | 'sse';
|
2026-04-12 09:33:15 +02:00
|
|
|
// Voice provider preference ('browser', 'openai', 'openai-compatible', or 'say' for macOS)
|
2026-07-04 02:48:07 +03:00
|
|
|
voiceProvider: 'browser' | 'local' | 'openai' | 'openai-compatible' | 'say';
|
|
|
|
|
setVoiceProvider: (provider: 'browser' | 'local' | 'openai' | 'openai-compatible' | 'say') => void;
|
2026-02-09 13:55:10 -08:00
|
|
|
// TTS settings
|
|
|
|
|
speechRate: number;
|
|
|
|
|
speechPitch: number;
|
|
|
|
|
speechVolume: number;
|
|
|
|
|
sayVoice: string;
|
|
|
|
|
browserVoice: string;
|
2026-07-04 02:48:07 +03:00
|
|
|
localTtsVoiceId: number;
|
2026-02-09 13:55:10 -08:00
|
|
|
openaiVoice: string;
|
|
|
|
|
openaiApiKey: string;
|
2026-04-11 21:47:33 +02:00
|
|
|
openaiCompatibleUrl: string;
|
2026-05-24 05:58:22 +08:00
|
|
|
openaiCompatibleApiKey: string;
|
2026-04-11 21:47:33 +02:00
|
|
|
openaiCompatibleVoice: string;
|
2026-04-12 09:33:15 +02:00
|
|
|
openaiCompatibleTtsModel: string;
|
2026-07-04 02:48:07 +03:00
|
|
|
// STT (dictation) settings
|
|
|
|
|
dictationEnabled: boolean;
|
|
|
|
|
sttProvider: 'local' | 'openai-compatible';
|
2026-04-11 21:47:33 +02:00
|
|
|
sttServerUrl: string;
|
2026-05-24 05:58:22 +08:00
|
|
|
sttApiKey: string;
|
2026-04-11 21:47:33 +02:00
|
|
|
sttModel: string;
|
2026-07-04 02:48:07 +03:00
|
|
|
sttLocalModel: string;
|
2026-04-11 21:47:33 +02:00
|
|
|
sttLanguage: string;
|
2026-02-09 13:55:10 -08:00
|
|
|
showMessageTTSButtons: boolean;
|
2026-07-05 23:19:10 +03:00
|
|
|
ttsInputMode: 'sanitized' | 'raw' | 'summarized';
|
2026-02-09 13:55:10 -08:00
|
|
|
// Summarization settings
|
|
|
|
|
summarizeMessageTTS: boolean;
|
|
|
|
|
summarizeVoiceConversation: boolean;
|
|
|
|
|
summarizeCharacterThreshold: number;
|
|
|
|
|
summarizeMaxLength: number;
|
|
|
|
|
setSpeechRate: (rate: number) => void;
|
|
|
|
|
setSpeechPitch: (pitch: number) => void;
|
|
|
|
|
setSpeechVolume: (volume: number) => void;
|
|
|
|
|
setSayVoice: (voice: string) => void;
|
|
|
|
|
setBrowserVoice: (voice: string) => void;
|
2026-07-04 02:48:07 +03:00
|
|
|
setLocalTtsVoiceId: (voiceId: number) => void;
|
2026-02-09 13:55:10 -08:00
|
|
|
setOpenaiVoice: (voice: string) => void;
|
|
|
|
|
setOpenaiApiKey: (apiKey: string) => void;
|
2026-04-11 21:47:33 +02:00
|
|
|
setOpenaiCompatibleUrl: (url: string) => void;
|
2026-05-24 05:58:22 +08:00
|
|
|
setOpenaiCompatibleApiKey: (apiKey: string) => void;
|
2026-04-11 21:47:33 +02:00
|
|
|
setOpenaiCompatibleVoice: (voice: string) => void;
|
2026-04-12 09:33:15 +02:00
|
|
|
setOpenaiCompatibleTtsModel: (model: string) => void;
|
2026-07-04 02:48:07 +03:00
|
|
|
setDictationEnabled: (enabled: boolean) => void;
|
|
|
|
|
setSttProvider: (provider: 'local' | 'openai-compatible') => void;
|
2026-04-11 21:47:33 +02:00
|
|
|
setSttServerUrl: (url: string) => void;
|
2026-05-24 05:58:22 +08:00
|
|
|
setSttApiKey: (apiKey: string) => void;
|
2026-04-11 21:47:33 +02:00
|
|
|
setSttModel: (model: string) => void;
|
2026-07-04 02:48:07 +03:00
|
|
|
setSttLocalModel: (model: string) => void;
|
2026-04-11 21:47:33 +02:00
|
|
|
setSttLanguage: (lang: string) => void;
|
2026-02-09 13:55:10 -08:00
|
|
|
setShowMessageTTSButtons: (show: boolean) => void;
|
2026-07-05 23:19:10 +03:00
|
|
|
setTtsInputMode: (mode: 'sanitized' | 'raw' | 'summarized') => void;
|
2026-02-09 13:55:10 -08:00
|
|
|
setSummarizeMessageTTS: (enabled: boolean) => void;
|
|
|
|
|
setSummarizeVoiceConversation: (enabled: boolean) => void;
|
|
|
|
|
setSummarizeCharacterThreshold: (threshold: number) => void;
|
|
|
|
|
setSummarizeMaxLength: (maxLength: number) => void;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
activateDirectory: (directory: string | null | undefined) => Promise<void>;
|
|
|
|
|
|
2026-06-05 15:09:24 +03:00
|
|
|
loadProviders: (options?: { directory?: string | null; source?: string }) => Promise<void>;
|
|
|
|
|
loadAgents: (options?: { directory?: string | null; source?: string }) => Promise<boolean>;
|
2026-04-30 02:25:42 -07:00
|
|
|
invalidateModelMetadataCache: () => void;
|
2026-06-08 14:54:02 +03:00
|
|
|
invalidateProviderCache: (directory?: string | null) => void;
|
2025-12-07 19:32:53 +02:00
|
|
|
setProvider: (providerId: string) => void;
|
|
|
|
|
setModel: (modelId: string) => void;
|
2026-01-08 14:48:06 +02:00
|
|
|
setCurrentVariant: (variant: string | undefined) => void;
|
|
|
|
|
cycleCurrentVariant: () => void;
|
|
|
|
|
getCurrentModelVariants: () => string[];
|
2025-12-07 19:32:53 +02:00
|
|
|
setAgent: (agentName: string | undefined) => void;
|
2026-08-22 20:31:20 +03:00
|
|
|
applyDefaultModelAgentSelection: (options?: { projectDefaultModel?: string; projectDefaultVariant?: string }) => void;
|
2026-06-17 11:21:43 +03:00
|
|
|
applyOpenCodeConfigDefaults: (directory?: string | null, source?: string, config?: Config) => void;
|
2025-12-27 02:22:59 +02:00
|
|
|
setSelectedProvider: (providerId: string) => void;
|
2025-12-26 04:38:38 +02:00
|
|
|
setSettingsDefaultModel: (model: string | undefined) => void;
|
2026-01-08 16:08:51 +02:00
|
|
|
setSettingsDefaultVariant: (variant: string | undefined) => void;
|
2025-12-26 04:38:38 +02:00
|
|
|
setSettingsDefaultAgent: (agent: string | undefined) => void;
|
2026-01-07 23:37:32 +02:00
|
|
|
setSettingsAutoCreateWorktree: (enabled: boolean) => void;
|
2026-01-17 01:09:21 -08:00
|
|
|
setSettingsGitmojiEnabled: (enabled: boolean) => void;
|
2026-04-23 07:14:14 -06:00
|
|
|
setSettingsDefaultFileViewerPreview: (enabled: boolean) => void;
|
2026-02-12 19:37:28 -08:00
|
|
|
setSettingsZenModel: (model: string | undefined) => void;
|
2026-04-17 16:07:26 +08:00
|
|
|
setSettingsMessageStreamTransport: (transport: 'auto' | 'ws' | 'sse') => void;
|
2026-02-24 05:23:57 -03:00
|
|
|
getResolvedGitGenerationModel: () => { providerId: string; modelId: string } | null;
|
2025-12-07 19:32:53 +02:00
|
|
|
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void;
|
|
|
|
|
getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null;
|
2026-04-25 16:56:37 +03:00
|
|
|
probeConnection: (options?: { timeoutMs?: number }) => Promise<boolean>;
|
2025-12-07 19:32:53 +02:00
|
|
|
checkConnection: () => Promise<boolean>;
|
|
|
|
|
initializeApp: () => Promise<void>;
|
2026-06-16 19:23:38 +03:00
|
|
|
prewarmProjectConfigs: (initialDirectory?: string | null) => Promise<void>;
|
2025-12-07 19:32:53 +02:00
|
|
|
getCurrentProvider: () => ProviderWithModelList | undefined;
|
|
|
|
|
getCurrentModel: () => ProviderModel | undefined;
|
|
|
|
|
getCurrentAgent: () => Agent | undefined;
|
|
|
|
|
getModelMetadata: (providerId: string, modelId: string) => ModelMetadata | undefined;
|
2025-12-18 19:02:42 +02:00
|
|
|
// Returns only visible agents (excludes hidden internal agents like title, compaction, summary)
|
|
|
|
|
getVisibleAgents: () => Agent[];
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
declare global {
|
|
|
|
|
interface Window {
|
|
|
|
|
__zustand_config_store__?: UseBoundStore<StoreApi<ConfigStore>>;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
// In-flight dedup: prevent concurrent duplicate loadProviders/loadAgents calls for the same directory
|
|
|
|
|
const _inFlightProviders = new Map<string, Promise<void>>();
|
|
|
|
|
const _inFlightAgents = new Map<string, Promise<boolean>>();
|
2026-05-06 02:43:13 +03:00
|
|
|
let _initializeAppInFlight: Promise<void> | null = null;
|
2026-03-31 18:47:00 +03:00
|
|
|
|
2026-08-22 19:50:16 +03:00
|
|
|
/**
|
|
|
|
|
* Providers of one project. Returns a stored array, so components can select it
|
|
|
|
|
* directly and re-render only when that project's list is replaced.
|
|
|
|
|
*
|
|
|
|
|
* Settings pages browse a project the app is not on; everything else wants the
|
|
|
|
|
* active one, which is what an omitted directory resolves to.
|
|
|
|
|
*/
|
|
|
|
|
export const selectProvidersForDirectory = (
|
|
|
|
|
state: Pick<ConfigStore, "providers" | "directoryScoped" | "activeDirectoryKey">,
|
|
|
|
|
directory?: string | null,
|
|
|
|
|
): ProviderWithModelList[] => {
|
|
|
|
|
const directoryKey = toConfigDirectoryKey(directory);
|
|
|
|
|
if (directoryKey === state.activeDirectoryKey) {
|
|
|
|
|
return state.providers;
|
|
|
|
|
}
|
|
|
|
|
return state.directoryScoped[directoryKey]?.providers ?? EMPTY_PROVIDERS;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const EMPTY_PROVIDERS: ProviderWithModelList[] = [];
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
export const useConfigStore = create<ConfigStore>()(
|
|
|
|
|
devtools(
|
|
|
|
|
persist(
|
|
|
|
|
(set, get) => ({
|
|
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
activeDirectoryKey: resolveInitialDirectoryKey(),
|
|
|
|
|
directoryScoped: {},
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
providers: [],
|
|
|
|
|
agents: [],
|
|
|
|
|
currentProviderId: "",
|
|
|
|
|
currentModelId: "",
|
2026-01-08 14:48:06 +02:00
|
|
|
currentVariant: undefined,
|
2025-12-07 19:32:53 +02:00
|
|
|
currentAgentName: undefined,
|
2025-12-27 02:22:59 +02:00
|
|
|
selectedProviderId: "",
|
2025-12-07 19:32:53 +02:00
|
|
|
agentModelSelections: {},
|
|
|
|
|
defaultProviders: {},
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "auto",
|
2025-12-07 19:32:53 +02:00
|
|
|
isConnected: false,
|
2026-04-22 21:03:02 +03:00
|
|
|
hasEverConnected: false,
|
|
|
|
|
connectionPhase: "connecting",
|
2026-04-22 00:33:21 +03:00
|
|
|
lastDisconnectReason: null,
|
2025-12-07 19:32:53 +02:00
|
|
|
isInitialized: false,
|
|
|
|
|
modelsMetadata: new Map<string, ModelMetadata>(),
|
2025-12-26 04:38:38 +02:00
|
|
|
settingsDefaultModel: undefined,
|
2026-01-08 16:08:51 +02:00
|
|
|
settingsDefaultVariant: undefined,
|
2025-12-26 04:38:38 +02:00
|
|
|
settingsDefaultAgent: undefined,
|
2026-06-14 21:36:05 +03:00
|
|
|
opencodeDefaultAgent: undefined,
|
|
|
|
|
opencodeDefaultModel: undefined,
|
2026-01-07 23:37:32 +02:00
|
|
|
settingsAutoCreateWorktree: false,
|
2026-01-17 01:09:21 -08:00
|
|
|
settingsGitmojiEnabled: false,
|
2026-04-23 07:14:14 -06:00
|
|
|
settingsDefaultFileViewerPreview: false,
|
2026-02-12 19:37:28 -08:00
|
|
|
settingsZenModel: undefined,
|
2026-04-17 16:07:26 +08:00
|
|
|
settingsMessageStreamTransport: 'auto',
|
2026-02-09 13:55:10 -08:00
|
|
|
// Voice provider preference - load from localStorage or default to 'browser'
|
|
|
|
|
voiceProvider: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('voiceProvider');
|
2026-07-04 02:48:07 +03:00
|
|
|
if (saved === 'openai' || saved === 'browser' || saved === 'local' || saved === 'say' || saved === 'openai-compatible') return saved;
|
2026-02-09 13:55:10 -08:00
|
|
|
}
|
|
|
|
|
return 'browser';
|
|
|
|
|
})(),
|
|
|
|
|
// TTS settings - load from localStorage with defaults
|
|
|
|
|
speechRate: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('speechRate');
|
|
|
|
|
if (saved) {
|
|
|
|
|
const parsed = parseFloat(saved);
|
|
|
|
|
if (!isNaN(parsed) && parsed >= 0.5 && parsed <= 2) return parsed;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 1;
|
|
|
|
|
})(),
|
|
|
|
|
speechPitch: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('speechPitch');
|
|
|
|
|
if (saved) {
|
|
|
|
|
const parsed = parseFloat(saved);
|
|
|
|
|
if (!isNaN(parsed) && parsed >= 0.5 && parsed <= 2) return parsed;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 1;
|
|
|
|
|
})(),
|
|
|
|
|
speechVolume: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('speechVolume');
|
|
|
|
|
if (saved) {
|
|
|
|
|
const parsed = parseFloat(saved);
|
|
|
|
|
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) return parsed;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 1;
|
|
|
|
|
})(),
|
|
|
|
|
// macOS Say voice - load from localStorage or default to 'Samantha'
|
|
|
|
|
sayVoice: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('sayVoice');
|
|
|
|
|
if (saved) return saved;
|
|
|
|
|
}
|
|
|
|
|
return 'Samantha';
|
|
|
|
|
})(),
|
2026-07-04 02:48:07 +03:00
|
|
|
// Local (Kokoro) TTS speaker id - load from localStorage or default to 0
|
|
|
|
|
localTtsVoiceId: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('localTtsVoiceId');
|
|
|
|
|
if (saved !== null) {
|
|
|
|
|
const parsed = Number.parseInt(saved, 10);
|
|
|
|
|
if (Number.isInteger(parsed) && parsed >= 0) return parsed;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
})(),
|
2026-02-09 13:55:10 -08:00
|
|
|
// Browser voice - load from localStorage or default to empty (auto-select)
|
|
|
|
|
browserVoice: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('browserVoice');
|
|
|
|
|
if (saved) return saved;
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
})(),
|
|
|
|
|
// OpenAI voice - load from localStorage or default to 'nova'
|
|
|
|
|
openaiVoice: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('openaiVoice');
|
|
|
|
|
if (saved) return saved;
|
|
|
|
|
}
|
|
|
|
|
return 'nova';
|
|
|
|
|
})(),
|
|
|
|
|
// OpenAI API key for TTS - load from localStorage or default to empty
|
|
|
|
|
openaiApiKey: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('openaiApiKey');
|
|
|
|
|
if (saved) return saved;
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
})(),
|
2026-04-11 21:47:33 +02:00
|
|
|
// OpenAI-compatible custom server URL
|
|
|
|
|
openaiCompatibleUrl: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('openaiCompatibleUrl');
|
|
|
|
|
if (saved) return saved;
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
})(),
|
2026-05-24 05:58:22 +08:00
|
|
|
// OpenAI-compatible custom server API key
|
|
|
|
|
openaiCompatibleApiKey: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('openaiCompatibleApiKey');
|
|
|
|
|
if (saved) return saved;
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
})(),
|
2026-04-11 21:47:33 +02:00
|
|
|
// OpenAI-compatible custom server voice
|
|
|
|
|
openaiCompatibleVoice: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('openaiCompatibleVoice');
|
|
|
|
|
if (saved) return saved;
|
|
|
|
|
}
|
|
|
|
|
return 'af_sky';
|
|
|
|
|
})(),
|
2026-04-12 09:33:15 +02:00
|
|
|
// OpenAI-compatible custom server TTS model
|
|
|
|
|
openaiCompatibleTtsModel: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('openaiCompatibleTtsModel');
|
|
|
|
|
if (saved && saved !== 'speaches-ai/Kokoro-82M-v1.0-ONNX') return saved;
|
|
|
|
|
}
|
|
|
|
|
return 'kokoro';
|
|
|
|
|
})(),
|
2026-07-04 02:48:07 +03:00
|
|
|
// Voice input (dictation) master toggle - default enabled
|
|
|
|
|
dictationEnabled: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('dictationEnabled');
|
|
|
|
|
if (saved === 'false') return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
})(),
|
|
|
|
|
// STT provider: 'local' (server-side sherpa-onnx) or 'openai-compatible'
|
2026-04-11 21:47:33 +02:00
|
|
|
sttProvider: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('sttProvider');
|
2026-07-04 02:48:07 +03:00
|
|
|
if (saved === 'local' || saved === 'openai-compatible') return saved;
|
|
|
|
|
// Migrate legacy providers: 'server' used an OpenAI-compatible
|
|
|
|
|
// endpoint; 'browser' and 'wasm' map to the local default.
|
|
|
|
|
if (saved === 'server') return 'openai-compatible' as const;
|
2026-04-11 21:47:33 +02:00
|
|
|
}
|
2026-07-04 02:48:07 +03:00
|
|
|
return 'local' as const;
|
2026-04-11 21:47:33 +02:00
|
|
|
})(),
|
|
|
|
|
sttServerUrl: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('sttServerUrl');
|
|
|
|
|
if (saved) return saved;
|
|
|
|
|
}
|
|
|
|
|
return 'http://localhost:8001/v1';
|
|
|
|
|
})(),
|
2026-05-24 05:58:22 +08:00
|
|
|
sttApiKey: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('sttApiKey');
|
|
|
|
|
if (saved) return saved;
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
})(),
|
2026-04-11 21:47:33 +02:00
|
|
|
sttModel: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('sttModel');
|
|
|
|
|
if (saved) return saved;
|
|
|
|
|
}
|
|
|
|
|
return 'deepdml/faster-whisper-large-v3-turbo-ct2';
|
|
|
|
|
})(),
|
2026-07-04 02:48:07 +03:00
|
|
|
sttLocalModel: (() => {
|
2026-05-14 01:19:52 +03:00
|
|
|
if (typeof window !== 'undefined') {
|
2026-07-04 02:48:07 +03:00
|
|
|
const saved = localStorage.getItem('sttLocalModel');
|
2026-05-14 01:19:52 +03:00
|
|
|
if (saved) return saved;
|
|
|
|
|
}
|
2026-07-04 02:48:07 +03:00
|
|
|
return 'parakeet-tdt-0.6b-v2-int8';
|
2026-05-14 01:19:52 +03:00
|
|
|
})(),
|
2026-04-11 21:47:33 +02:00
|
|
|
sttLanguage: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('sttLanguage');
|
|
|
|
|
if (saved !== null) return saved;
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
})(),
|
2026-02-10 13:00:16 +02:00
|
|
|
// Show TTS buttons on messages - disabled by default until user enables it
|
2026-02-09 13:55:10 -08:00
|
|
|
showMessageTTSButtons: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('showMessageTTSButtons');
|
2026-02-10 13:00:16 +02:00
|
|
|
if (saved === 'true') return true;
|
2026-02-09 13:55:10 -08:00
|
|
|
}
|
2026-02-10 13:00:16 +02:00
|
|
|
return false;
|
2026-02-09 13:55:10 -08:00
|
|
|
})(),
|
2026-06-09 00:31:21 +08:00
|
|
|
ttsInputMode: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('ttsInputMode');
|
|
|
|
|
if (saved === 'raw') return 'raw' as const;
|
2026-07-05 23:19:10 +03:00
|
|
|
if (saved === 'summarized') return 'summarized' as const;
|
2026-06-09 00:31:21 +08:00
|
|
|
}
|
|
|
|
|
return 'sanitized' as const;
|
|
|
|
|
})(),
|
2026-02-09 13:55:10 -08:00
|
|
|
// Summarization settings
|
|
|
|
|
summarizeMessageTTS: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('summarizeMessageTTS');
|
|
|
|
|
if (saved === 'true') return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
})(),
|
|
|
|
|
summarizeVoiceConversation: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('summarizeVoiceConversation');
|
|
|
|
|
if (saved === 'true') return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
})(),
|
|
|
|
|
summarizeCharacterThreshold: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('summarizeCharacterThreshold');
|
|
|
|
|
if (saved) {
|
|
|
|
|
const parsed = parseInt(saved, 10);
|
|
|
|
|
if (!isNaN(parsed) && parsed >= 50 && parsed <= 2000) return parsed;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 200;
|
|
|
|
|
})(),
|
|
|
|
|
summarizeMaxLength: (() => {
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const saved = localStorage.getItem('summarizeMaxLength');
|
|
|
|
|
if (saved) {
|
|
|
|
|
const parsed = parseInt(saved, 10);
|
|
|
|
|
if (!isNaN(parsed) && parsed >= 50 && parsed <= 2000) return parsed;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 500;
|
|
|
|
|
})(),
|
2026-01-06 21:31:04 +02:00
|
|
|
activateDirectory: async (directory) => {
|
2026-06-15 03:16:34 +03:00
|
|
|
// Resolve the worktree to its owning project up-front so the
|
|
|
|
|
// active key + snapshot key always match and stay project-scoped.
|
|
|
|
|
// Everything below operates on this key unchanged; the OpenCode
|
|
|
|
|
// working directory (opencodeClient.getDirectory()) is separate.
|
2026-06-16 19:23:38 +03:00
|
|
|
const configDirectory = resolveConfigDirectory(directory);
|
|
|
|
|
if (!configDirectory) {
|
|
|
|
|
markStartupTrace('activateDirectory:skippedUnknownDirectory', { directory });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const directoryKey = toDirectoryKey(configDirectory);
|
2026-06-05 15:09:24 +03:00
|
|
|
let snapshotHadProviders = false;
|
|
|
|
|
let snapshotHadAgents = false;
|
2026-01-06 21:31:04 +02:00
|
|
|
|
|
|
|
|
set((state) => {
|
|
|
|
|
const snapshot = state.directoryScoped[directoryKey];
|
|
|
|
|
if (snapshot) {
|
2026-06-05 15:09:24 +03:00
|
|
|
snapshotHadProviders = snapshot.providers.length > 0;
|
|
|
|
|
snapshotHadAgents = snapshot.agents.length > 0;
|
2026-01-06 21:31:04 +02:00
|
|
|
return {
|
|
|
|
|
activeDirectoryKey: directoryKey,
|
|
|
|
|
providers: snapshot.providers,
|
|
|
|
|
agents: snapshot.agents,
|
|
|
|
|
currentProviderId: snapshot.currentProviderId,
|
|
|
|
|
currentModelId: snapshot.currentModelId,
|
2026-01-08 16:08:51 +02:00
|
|
|
currentVariant: snapshot.currentVariant,
|
2026-01-06 21:31:04 +02:00
|
|
|
currentAgentName: snapshot.currentAgentName,
|
|
|
|
|
selectedProviderId: snapshot.selectedProviderId,
|
|
|
|
|
agentModelSelections: snapshot.agentModelSelections,
|
|
|
|
|
defaultProviders: snapshot.defaultProviders,
|
2026-06-17 11:21:43 +03:00
|
|
|
opencodeDefaultAgent: snapshot.opencodeDefaultAgent,
|
|
|
|
|
opencodeDefaultModel: snapshot.opencodeDefaultModel,
|
|
|
|
|
selectionSource: snapshot.selectionSource ?? "auto",
|
2026-01-06 21:31:04 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
activeDirectoryKey: directoryKey,
|
|
|
|
|
providers: [],
|
|
|
|
|
agents: [],
|
|
|
|
|
currentProviderId: "",
|
|
|
|
|
currentModelId: "",
|
|
|
|
|
currentAgentName: undefined,
|
|
|
|
|
selectedProviderId: "",
|
|
|
|
|
agentModelSelections: {},
|
|
|
|
|
defaultProviders: {},
|
2026-06-17 11:21:43 +03:00
|
|
|
opencodeDefaultAgent: undefined,
|
|
|
|
|
opencodeDefaultModel: undefined,
|
|
|
|
|
selectionSource: "auto",
|
2026-01-06 21:31:04 +02:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!get().isConnected) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 03:16:34 +03:00
|
|
|
// Stale-while-revalidate: when a cached snapshot already
|
|
|
|
|
// populated the pickers, refresh in the background so the UI
|
|
|
|
|
// stays instant but never shows stale provider/agent data for
|
|
|
|
|
// longer than one fetch. Only block when there is nothing to show.
|
2026-06-05 15:09:24 +03:00
|
|
|
if (snapshotHadProviders) {
|
2026-06-15 03:16:34 +03:00
|
|
|
if (isConfigFresh(_providersLoadedAt, directoryKey)) {
|
|
|
|
|
markStartupTrace('activateDirectory:providersFresh', { directoryKey });
|
|
|
|
|
} else {
|
|
|
|
|
markStartupTrace('activateDirectory:refreshProvidersBackground', { directoryKey });
|
|
|
|
|
void get().loadProviders({ directory: fromDirectoryKey(directoryKey), source: 'activateDirectory:refresh' });
|
|
|
|
|
}
|
2026-06-05 15:09:24 +03:00
|
|
|
} else {
|
|
|
|
|
await get().loadProviders({ directory: fromDirectoryKey(directoryKey), source: 'activateDirectory' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (snapshotHadAgents) {
|
2026-06-15 03:16:34 +03:00
|
|
|
if (isConfigFresh(_agentsLoadedAt, directoryKey)) {
|
|
|
|
|
markStartupTrace('activateDirectory:agentsFresh', { directoryKey });
|
|
|
|
|
} else {
|
|
|
|
|
markStartupTrace('activateDirectory:refreshAgentsBackground', { directoryKey });
|
|
|
|
|
void get().loadAgents({ directory: fromDirectoryKey(directoryKey), source: 'activateDirectory:refresh' });
|
|
|
|
|
}
|
2026-06-05 15:09:24 +03:00
|
|
|
} else {
|
|
|
|
|
await get().loadAgents({ directory: fromDirectoryKey(directoryKey), source: 'activateDirectory' });
|
|
|
|
|
}
|
2026-01-06 21:31:04 +02:00
|
|
|
},
|
|
|
|
|
|
2026-06-08 14:54:02 +03:00
|
|
|
invalidateProviderCache: (directory) => {
|
|
|
|
|
const targetDirectoryKey = directory === undefined ? null : toDirectoryKey(directory);
|
|
|
|
|
|
|
|
|
|
set((state) => {
|
|
|
|
|
const nextState: Partial<ConfigStore> = {};
|
|
|
|
|
let scopedChanged = false;
|
|
|
|
|
const nextDirectoryScoped: Record<string, DirectoryScopedConfig> = {
|
|
|
|
|
...state.directoryScoped,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const clearSnapshot = (snapshot: DirectoryScopedConfig): DirectoryScopedConfig => {
|
|
|
|
|
if (snapshot.providers.length === 0 && Object.keys(snapshot.defaultProviders).length === 0) {
|
|
|
|
|
return snapshot;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scopedChanged = true;
|
|
|
|
|
return {
|
|
|
|
|
...snapshot,
|
|
|
|
|
providers: [],
|
|
|
|
|
defaultProviders: {},
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (targetDirectoryKey) {
|
|
|
|
|
const snapshot = state.directoryScoped[targetDirectoryKey];
|
|
|
|
|
if (snapshot) {
|
|
|
|
|
nextDirectoryScoped[targetDirectoryKey] = clearSnapshot(snapshot);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
for (const [directoryKey, snapshot] of Object.entries(state.directoryScoped)) {
|
|
|
|
|
nextDirectoryScoped[directoryKey] = clearSnapshot(snapshot);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (scopedChanged) {
|
|
|
|
|
nextState.directoryScoped = nextDirectoryScoped;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (targetDirectoryKey === null || targetDirectoryKey === state.activeDirectoryKey) {
|
|
|
|
|
if (state.providers.length > 0) {
|
|
|
|
|
nextState.providers = [];
|
|
|
|
|
}
|
|
|
|
|
if (Object.keys(state.defaultProviders).length > 0) {
|
|
|
|
|
nextState.defaultProviders = {};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Object.keys(nextState).length > 0 ? nextState : state;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
loadProviders: async (options) => {
|
2026-06-05 15:09:24 +03:00
|
|
|
const requestedDirectory = options?.directory ?? fromDirectoryKey(get().activeDirectoryKey);
|
2026-06-15 03:16:34 +03:00
|
|
|
// Providers are project-scoped: resolve a worktree to its project
|
|
|
|
|
// so it reuses one shared snapshot instead of its own.
|
|
|
|
|
const configDirectory = resolveConfigDirectory(requestedDirectory);
|
2026-06-16 19:23:38 +03:00
|
|
|
if (!configDirectory) {
|
|
|
|
|
markStartupTrace('loadProviders:skippedUnknownDirectory', { requestedDirectory, source: options?.source ?? 'unknown' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-15 03:16:34 +03:00
|
|
|
const effectiveDirectory = configDirectory ?? opencodeClient.getDirectory() ?? null;
|
|
|
|
|
const directoryKey = toDirectoryKey(configDirectory);
|
2026-06-05 15:09:24 +03:00
|
|
|
const source = options?.source ?? 'unknown';
|
|
|
|
|
markStartupTrace('loadProviders:called', { directoryKey, source, requestedDirectory, effectiveDirectory });
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
// Dedup: if a load is already in-flight for this directory, reuse it
|
|
|
|
|
const existing = _inFlightProviders.get(directoryKey);
|
2026-06-05 15:09:24 +03:00
|
|
|
if (existing) {
|
|
|
|
|
markStartupTrace('loadProviders:deduped', { directoryKey, source, requestedDirectory, effectiveDirectory });
|
|
|
|
|
return existing;
|
|
|
|
|
}
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
const promise = (async () => {
|
2026-06-05 15:09:24 +03:00
|
|
|
const loaderStarted = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
|
|
|
markStartupTrace('loadProviders:start', { directoryKey, source, requestedDirectory, effectiveDirectory });
|
2026-01-06 21:31:04 +02:00
|
|
|
const existingSnapshot = get().directoryScoped[directoryKey];
|
|
|
|
|
const previousProviders = existingSnapshot?.providers ?? (get().activeDirectoryKey === directoryKey ? get().providers : []);
|
|
|
|
|
const previousDefaults = existingSnapshot?.defaultProviders ?? (get().activeDirectoryKey === directoryKey ? get().defaultProviders : {});
|
2025-12-25 23:57:03 +02:00
|
|
|
let lastError: unknown = null;
|
|
|
|
|
|
|
|
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
|
|
|
try {
|
2026-01-06 21:31:04 +02:00
|
|
|
ensureModelsMetadataFetch(
|
|
|
|
|
() => get().modelsMetadata,
|
|
|
|
|
(metadata) => set({ modelsMetadata: metadata }),
|
|
|
|
|
);
|
2026-06-05 15:09:24 +03:00
|
|
|
const apiResult = await measureStartupTrace(
|
|
|
|
|
'loadProviders:api',
|
2026-06-16 19:23:38 +03:00
|
|
|
() => opencodeClient.getProvidersForConfig(fromDirectoryKey(directoryKey)),
|
2026-06-05 15:09:24 +03:00
|
|
|
{ directoryKey, source, requestedDirectory, effectiveDirectory, attempt: attempt + 1 },
|
2026-01-06 21:31:04 +02:00
|
|
|
);
|
2025-12-25 23:57:03 +02:00
|
|
|
const providers = Array.isArray(apiResult?.providers) ? apiResult.providers : [];
|
|
|
|
|
const defaults = apiResult?.default || {};
|
|
|
|
|
|
|
|
|
|
const processedProviders: ProviderWithModelList[] = providers.map((provider) => {
|
|
|
|
|
const modelRecord = provider.models ?? {};
|
|
|
|
|
const models: ProviderModel[] = Object.keys(modelRecord).map((modelId) => modelRecord[modelId]);
|
|
|
|
|
return {
|
|
|
|
|
...provider,
|
|
|
|
|
models,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
set((state) => {
|
|
|
|
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
|
|
|
|
providers: [],
|
|
|
|
|
agents: [],
|
|
|
|
|
currentProviderId: "",
|
|
|
|
|
currentModelId: "",
|
|
|
|
|
currentAgentName: undefined,
|
|
|
|
|
selectedProviderId: "",
|
|
|
|
|
agentModelSelections: {},
|
|
|
|
|
defaultProviders: {},
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-08 14:54:02 +03:00
|
|
|
const currentProviderId = state.activeDirectoryKey === directoryKey
|
|
|
|
|
? state.currentProviderId
|
|
|
|
|
: baseSnapshot.currentProviderId;
|
|
|
|
|
const currentModelId = state.activeDirectoryKey === directoryKey
|
|
|
|
|
? state.currentModelId
|
|
|
|
|
: baseSnapshot.currentModelId;
|
|
|
|
|
const currentVariant = state.activeDirectoryKey === directoryKey
|
|
|
|
|
? state.currentVariant
|
|
|
|
|
: baseSnapshot.currentVariant;
|
|
|
|
|
const resolvedModel = resolveProviderModelSelection({
|
|
|
|
|
providers: processedProviders,
|
|
|
|
|
currentProviderId,
|
|
|
|
|
currentModelId,
|
|
|
|
|
currentVariant,
|
|
|
|
|
settingsDefaultModel: state.settingsDefaultModel,
|
|
|
|
|
settingsDefaultVariant: state.settingsDefaultVariant,
|
|
|
|
|
});
|
|
|
|
|
const currentSelectedProviderId = state.activeDirectoryKey === directoryKey
|
|
|
|
|
? state.selectedProviderId
|
|
|
|
|
: baseSnapshot.selectedProviderId;
|
2026-08-22 17:24:05 +03:00
|
|
|
// The Providers settings selection belongs to the user, not to this
|
|
|
|
|
// loader. A refresh may report a different provider set — an OpenCode
|
|
|
|
|
// restart drops plugin-registered providers until they re-register —
|
|
|
|
|
// and re-deriving a selection here yanked the open provider away
|
|
|
|
|
// mid-edit. Keep whatever is selected; only fill in an empty one.
|
|
|
|
|
// The add-provider sentinel is kept for the same reason (issue #1765).
|
|
|
|
|
const selectedProviderId = currentSelectedProviderId
|
2026-06-08 14:54:02 +03:00
|
|
|
? currentSelectedProviderId
|
|
|
|
|
: (resolvedModel?.providerId ?? processedProviders[0]?.id ?? "");
|
|
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
const nextSnapshot: DirectoryScopedConfig = {
|
|
|
|
|
...baseSnapshot,
|
|
|
|
|
providers: processedProviders,
|
|
|
|
|
defaultProviders: defaults,
|
2026-06-08 14:54:02 +03:00
|
|
|
currentProviderId: resolvedModel?.providerId ?? "",
|
|
|
|
|
currentModelId: resolvedModel?.modelId ?? "",
|
|
|
|
|
currentVariant: resolvedModel?.variant,
|
|
|
|
|
selectedProviderId,
|
2026-01-06 21:31:04 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const nextState: Partial<ConfigStore> = {
|
|
|
|
|
directoryScoped: {
|
|
|
|
|
...state.directoryScoped,
|
|
|
|
|
[directoryKey]: nextSnapshot,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (state.activeDirectoryKey === directoryKey) {
|
|
|
|
|
nextState.providers = processedProviders;
|
|
|
|
|
nextState.defaultProviders = defaults;
|
2026-06-08 14:54:02 +03:00
|
|
|
nextState.currentProviderId = nextSnapshot.currentProviderId;
|
|
|
|
|
nextState.currentModelId = nextSnapshot.currentModelId;
|
|
|
|
|
nextState.currentVariant = nextSnapshot.currentVariant;
|
|
|
|
|
nextState.selectedProviderId = selectedProviderId;
|
2026-01-06 21:31:04 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nextState;
|
2025-12-25 23:57:03 +02:00
|
|
|
});
|
|
|
|
|
|
2026-06-05 15:09:24 +03:00
|
|
|
const loaderEnded = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
|
|
|
markStartupTrace('loadProviders:end', {
|
|
|
|
|
directoryKey,
|
|
|
|
|
source,
|
|
|
|
|
requestedDirectory,
|
|
|
|
|
effectiveDirectory,
|
|
|
|
|
durationMs: Math.round(loaderEnded - loaderStarted),
|
|
|
|
|
providers: processedProviders.length,
|
|
|
|
|
models: processedProviders.reduce((count, provider) => count + provider.models.length, 0),
|
|
|
|
|
});
|
2026-06-15 03:16:34 +03:00
|
|
|
_providersLoadedAt.set(directoryKey, Date.now());
|
2025-12-25 23:57:03 +02:00
|
|
|
return;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
lastError = error;
|
2026-06-05 15:09:24 +03:00
|
|
|
markStartupTrace('loadProviders:attemptError', {
|
|
|
|
|
directoryKey,
|
|
|
|
|
source,
|
|
|
|
|
requestedDirectory,
|
|
|
|
|
effectiveDirectory,
|
|
|
|
|
attempt: attempt + 1,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
});
|
2025-12-25 23:57:03 +02:00
|
|
|
const waitMs = 200 * (attempt + 1);
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
2025-12-25 23:57:03 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.error("Failed to load providers:", lastError);
|
2026-06-05 15:09:24 +03:00
|
|
|
markStartupTrace('loadProviders:error', {
|
|
|
|
|
directoryKey,
|
|
|
|
|
source,
|
|
|
|
|
requestedDirectory,
|
|
|
|
|
effectiveDirectory,
|
|
|
|
|
error: lastError instanceof Error ? lastError.message : String(lastError),
|
|
|
|
|
});
|
2026-01-06 21:31:04 +02:00
|
|
|
|
|
|
|
|
set((state) => {
|
|
|
|
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
|
|
|
|
providers: [],
|
|
|
|
|
agents: [],
|
|
|
|
|
currentProviderId: "",
|
|
|
|
|
currentModelId: "",
|
|
|
|
|
currentAgentName: undefined,
|
|
|
|
|
selectedProviderId: "",
|
|
|
|
|
agentModelSelections: {},
|
|
|
|
|
defaultProviders: {},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const nextSnapshot: DirectoryScopedConfig = {
|
|
|
|
|
...baseSnapshot,
|
|
|
|
|
providers: previousProviders,
|
|
|
|
|
defaultProviders: previousDefaults,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const nextState: Partial<ConfigStore> = {
|
|
|
|
|
directoryScoped: {
|
|
|
|
|
...state.directoryScoped,
|
|
|
|
|
[directoryKey]: nextSnapshot,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (state.activeDirectoryKey === directoryKey) {
|
|
|
|
|
nextState.providers = previousProviders;
|
|
|
|
|
nextState.defaultProviders = previousDefaults;
|
2026-01-12 21:39:42 +08:00
|
|
|
|
|
|
|
|
if (!state.currentProviderId && !state.currentModelId && state.settingsDefaultModel) {
|
|
|
|
|
const parsed = parseModelString(state.settingsDefaultModel);
|
|
|
|
|
if (parsed) {
|
|
|
|
|
const settingsProvider = previousProviders.find((p) => p.id === parsed.providerId);
|
|
|
|
|
if (settingsProvider?.models.some((m) => m.id === parsed.modelId)) {
|
|
|
|
|
const model = settingsProvider.models.find((m) => m.id === parsed.modelId);
|
|
|
|
|
const currentVariant = state.settingsDefaultVariant && (model as { variants?: Record<string, unknown> } | undefined)?.variants?.[state.settingsDefaultVariant]
|
|
|
|
|
? state.settingsDefaultVariant
|
|
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
nextState.currentProviderId = parsed.providerId;
|
|
|
|
|
nextState.currentModelId = parsed.modelId;
|
|
|
|
|
nextState.currentVariant = currentVariant;
|
|
|
|
|
|
|
|
|
|
nextSnapshot.currentProviderId = parsed.providerId;
|
|
|
|
|
nextSnapshot.currentModelId = parsed.modelId;
|
|
|
|
|
nextSnapshot.currentVariant = currentVariant;
|
2026-08-22 17:24:05 +03:00
|
|
|
|
|
|
|
|
// Only adopt this as the settings selection when the user has
|
|
|
|
|
// none; a failed refresh must not move an existing one.
|
|
|
|
|
if (!state.selectedProviderId) {
|
|
|
|
|
nextState.selectedProviderId = parsed.providerId;
|
|
|
|
|
nextSnapshot.selectedProviderId = parsed.providerId;
|
|
|
|
|
}
|
2026-01-12 21:39:42 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-06 21:31:04 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nextState;
|
2025-12-25 23:57:03 +02:00
|
|
|
});
|
2026-03-31 18:47:00 +03:00
|
|
|
})().finally(() => _inFlightProviders.delete(directoryKey));
|
|
|
|
|
|
|
|
|
|
_inFlightProviders.set(directoryKey, promise);
|
|
|
|
|
return promise;
|
2025-12-25 23:57:03 +02:00
|
|
|
},
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
setProvider: (providerId: string) => {
|
|
|
|
|
const { providers } = get();
|
|
|
|
|
const provider = providers.find((p) => p.id === providerId);
|
2026-01-08 16:08:51 +02:00
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
if (!provider) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-08 16:08:51 +02:00
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
const firstModel = provider.models[0];
|
|
|
|
|
const newModelId = firstModel?.id || "";
|
2026-01-08 16:08:51 +02:00
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
set((state) => {
|
|
|
|
|
const directoryKey = state.activeDirectoryKey;
|
|
|
|
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
|
|
|
|
providers: state.providers,
|
|
|
|
|
agents: state.agents,
|
|
|
|
|
currentProviderId: state.currentProviderId,
|
|
|
|
|
currentModelId: state.currentModelId,
|
2026-01-08 16:08:51 +02:00
|
|
|
currentVariant: state.currentVariant,
|
2026-01-06 21:31:04 +02:00
|
|
|
currentAgentName: state.currentAgentName,
|
|
|
|
|
selectedProviderId: state.selectedProviderId,
|
|
|
|
|
agentModelSelections: state.agentModelSelections,
|
|
|
|
|
defaultProviders: state.defaultProviders,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const nextSnapshot: DirectoryScopedConfig = {
|
|
|
|
|
...baseSnapshot,
|
|
|
|
|
currentProviderId: providerId,
|
|
|
|
|
currentModelId: newModelId,
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "manual",
|
2026-01-06 21:31:04 +02:00
|
|
|
};
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
return {
|
2025-12-07 19:32:53 +02:00
|
|
|
currentProviderId: providerId,
|
|
|
|
|
currentModelId: newModelId,
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "manual",
|
2026-01-06 21:31:04 +02:00
|
|
|
directoryScoped: {
|
|
|
|
|
...state.directoryScoped,
|
|
|
|
|
[directoryKey]: nextSnapshot,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
2025-12-07 19:32:53 +02:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setModel: (modelId: string) => {
|
2026-01-06 21:31:04 +02:00
|
|
|
set((state) => {
|
|
|
|
|
const directoryKey = state.activeDirectoryKey;
|
|
|
|
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
|
|
|
|
providers: state.providers,
|
|
|
|
|
agents: state.agents,
|
|
|
|
|
currentProviderId: state.currentProviderId,
|
|
|
|
|
currentModelId: state.currentModelId,
|
2026-01-08 16:08:51 +02:00
|
|
|
currentVariant: state.currentVariant,
|
2026-01-06 21:31:04 +02:00
|
|
|
currentAgentName: state.currentAgentName,
|
|
|
|
|
selectedProviderId: state.selectedProviderId,
|
|
|
|
|
agentModelSelections: state.agentModelSelections,
|
|
|
|
|
defaultProviders: state.defaultProviders,
|
|
|
|
|
};
|
2026-01-08 14:48:06 +02:00
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
const nextSnapshot: DirectoryScopedConfig = {
|
|
|
|
|
...baseSnapshot,
|
|
|
|
|
currentModelId: modelId,
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "manual",
|
2026-01-06 21:31:04 +02:00
|
|
|
};
|
2026-01-08 14:48:06 +02:00
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
return {
|
|
|
|
|
currentModelId: modelId,
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "manual",
|
2026-01-06 21:31:04 +02:00
|
|
|
directoryScoped: {
|
|
|
|
|
...state.directoryScoped,
|
|
|
|
|
[directoryKey]: nextSnapshot,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
2025-12-07 19:32:53 +02:00
|
|
|
},
|
|
|
|
|
|
2026-01-08 14:48:06 +02:00
|
|
|
setCurrentVariant: (variant: string | undefined) => {
|
|
|
|
|
set((state) => {
|
|
|
|
|
if (state.currentVariant === variant) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
2026-01-08 16:08:51 +02:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
currentVariant: variant,
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "manual",
|
2026-01-08 16:08:51 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
currentVariant: variant,
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "manual",
|
2026-01-08 16:08:51 +02:00
|
|
|
directoryScoped: {
|
|
|
|
|
...state.directoryScoped,
|
|
|
|
|
[directoryKey]: nextSnapshot,
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-01-08 14:48:06 +02:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getCurrentModelVariants: () => {
|
|
|
|
|
const model = get().getCurrentModel();
|
|
|
|
|
const variants = (model as { variants?: Record<string, unknown> } | undefined)?.variants;
|
|
|
|
|
if (!variants) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
return Object.keys(variants);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
cycleCurrentVariant: () => {
|
|
|
|
|
const variantKeys = get().getCurrentModelVariants();
|
|
|
|
|
if (variantKeys.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const current = get().currentVariant;
|
|
|
|
|
if (!current) {
|
2026-01-08 16:08:51 +02:00
|
|
|
get().setCurrentVariant(variantKeys[0]);
|
2026-01-08 14:48:06 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-01-08 16:08:51 +02:00
|
|
|
|
2026-01-08 14:48:06 +02:00
|
|
|
const index = variantKeys.indexOf(current);
|
|
|
|
|
if (index === -1 || index === variantKeys.length - 1) {
|
2026-01-08 16:08:51 +02:00
|
|
|
get().setCurrentVariant(undefined);
|
2026-01-08 14:48:06 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-08 16:08:51 +02:00
|
|
|
get().setCurrentVariant(variantKeys[index + 1]);
|
2026-01-08 14:48:06 +02:00
|
|
|
},
|
|
|
|
|
|
2025-12-27 02:22:59 +02:00
|
|
|
setSelectedProvider: (providerId: string) => {
|
2026-01-06 21:31:04 +02:00
|
|
|
set((state) => {
|
|
|
|
|
const directoryKey = state.activeDirectoryKey;
|
|
|
|
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
|
|
|
|
providers: state.providers,
|
|
|
|
|
agents: state.agents,
|
|
|
|
|
currentProviderId: state.currentProviderId,
|
|
|
|
|
currentModelId: state.currentModelId,
|
|
|
|
|
currentAgentName: state.currentAgentName,
|
|
|
|
|
selectedProviderId: state.selectedProviderId,
|
|
|
|
|
agentModelSelections: state.agentModelSelections,
|
|
|
|
|
defaultProviders: state.defaultProviders,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const nextSnapshot: DirectoryScopedConfig = {
|
|
|
|
|
...baseSnapshot,
|
|
|
|
|
selectedProviderId: providerId,
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "manual",
|
2026-01-06 21:31:04 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
selectedProviderId: providerId,
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "manual",
|
2026-01-06 21:31:04 +02:00
|
|
|
directoryScoped: {
|
|
|
|
|
...state.directoryScoped,
|
|
|
|
|
[directoryKey]: nextSnapshot,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
2025-12-27 02:22:59 +02:00
|
|
|
},
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => {
|
2026-01-06 21:31:04 +02:00
|
|
|
set((state) => {
|
|
|
|
|
const directoryKey = state.activeDirectoryKey;
|
|
|
|
|
const nextSelections = {
|
2025-12-07 19:32:53 +02:00
|
|
|
...state.agentModelSelections,
|
|
|
|
|
[agentName]: { providerId, modelId },
|
2026-01-06 21:31:04 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
|
|
|
|
providers: state.providers,
|
|
|
|
|
agents: state.agents,
|
|
|
|
|
currentProviderId: state.currentProviderId,
|
|
|
|
|
currentModelId: state.currentModelId,
|
|
|
|
|
currentAgentName: state.currentAgentName,
|
|
|
|
|
selectedProviderId: state.selectedProviderId,
|
|
|
|
|
agentModelSelections: state.agentModelSelections,
|
|
|
|
|
defaultProviders: state.defaultProviders,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const nextSnapshot: DirectoryScopedConfig = {
|
|
|
|
|
...baseSnapshot,
|
|
|
|
|
agentModelSelections: nextSelections,
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "manual",
|
2026-01-06 21:31:04 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
agentModelSelections: nextSelections,
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "manual",
|
2026-01-06 21:31:04 +02:00
|
|
|
directoryScoped: {
|
|
|
|
|
...state.directoryScoped,
|
|
|
|
|
[directoryKey]: nextSnapshot,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
2025-12-07 19:32:53 +02:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getAgentModelSelection: (agentName: string) => {
|
|
|
|
|
const { agentModelSelections } = get();
|
|
|
|
|
return agentModelSelections[agentName] || null;
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
loadAgents: async (options) => {
|
2026-06-05 15:09:24 +03:00
|
|
|
const requestedDirectory = options?.directory ?? fromDirectoryKey(get().activeDirectoryKey);
|
2026-06-15 03:16:34 +03:00
|
|
|
// Agents are project-scoped: resolve a worktree to its project
|
|
|
|
|
// so it reuses one shared snapshot instead of its own.
|
|
|
|
|
const configDirectory = resolveConfigDirectory(requestedDirectory);
|
2026-06-16 19:23:38 +03:00
|
|
|
if (!configDirectory) {
|
|
|
|
|
markStartupTrace('loadAgents:skippedUnknownDirectory', { requestedDirectory, source: options?.source ?? 'unknown' });
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-06-15 03:16:34 +03:00
|
|
|
const effectiveDirectory = configDirectory ?? opencodeClient.getDirectory() ?? null;
|
|
|
|
|
const directoryKey = toDirectoryKey(configDirectory);
|
2026-06-05 15:09:24 +03:00
|
|
|
const source = options?.source ?? 'unknown';
|
|
|
|
|
markStartupTrace('loadAgents:called', { directoryKey, source, requestedDirectory, effectiveDirectory });
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
// Dedup: if a load is already in-flight for this directory, reuse it
|
|
|
|
|
const existing = _inFlightAgents.get(directoryKey);
|
2026-06-05 15:09:24 +03:00
|
|
|
if (existing) {
|
|
|
|
|
markStartupTrace('loadAgents:deduped', { directoryKey, source, requestedDirectory, effectiveDirectory });
|
|
|
|
|
return existing;
|
|
|
|
|
}
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
const promise = (async (): Promise<boolean> => {
|
2026-06-05 15:09:24 +03:00
|
|
|
const loaderStarted = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
|
|
|
markStartupTrace('loadAgents:start', { directoryKey, source, requestedDirectory, effectiveDirectory });
|
2026-01-06 21:31:04 +02:00
|
|
|
const existingSnapshot = get().directoryScoped[directoryKey];
|
|
|
|
|
const previousAgents = existingSnapshot?.agents ?? (get().activeDirectoryKey === directoryKey ? get().agents : []);
|
2025-12-07 19:32:53 +02:00
|
|
|
let lastError: unknown = null;
|
|
|
|
|
|
|
|
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
|
|
|
try {
|
2026-06-17 11:21:43 +03:00
|
|
|
// Fetch agents and OpenChamber settings in parallel. OpenCode config
|
|
|
|
|
// comes from sync state if it is already available; it must not block
|
|
|
|
|
// the agent refresh path.
|
|
|
|
|
const configDirectoryPath = fromDirectoryKey(directoryKey);
|
|
|
|
|
const initialSyncedOpencodeConfig = getSyncConfig(requestedDirectory ?? undefined)
|
|
|
|
|
?? getSyncConfig(configDirectoryPath ?? undefined);
|
|
|
|
|
if (initialSyncedOpencodeConfig) {
|
|
|
|
|
markStartupTrace('loadAgents:syncConfigHit', { directoryKey, source });
|
|
|
|
|
}
|
|
|
|
|
const [agents, openChamberDefaults] = await Promise.all([
|
2026-06-05 15:09:24 +03:00
|
|
|
measureStartupTrace(
|
|
|
|
|
'loadAgents:api',
|
2026-06-17 11:21:43 +03:00
|
|
|
() => opencodeClient.listAgents(configDirectoryPath),
|
2026-06-05 15:09:24 +03:00
|
|
|
{ directoryKey, source, requestedDirectory, effectiveDirectory, attempt: attempt + 1 },
|
|
|
|
|
),
|
2025-12-26 04:38:38 +02:00
|
|
|
fetchOpenChamberDefaults(),
|
|
|
|
|
]);
|
2026-01-06 21:31:04 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const safeAgents = Array.isArray(agents) ? agents : [];
|
|
|
|
|
|
2026-06-05 15:09:24 +03:00
|
|
|
const providerLoad = _inFlightProviders.get(directoryKey);
|
|
|
|
|
if (providerLoad) {
|
|
|
|
|
markStartupTrace('loadAgents:awaitProviders', { directoryKey, source });
|
|
|
|
|
await providerLoad;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 11:21:43 +03:00
|
|
|
const latestSyncedOpencodeConfig = getSyncConfig(requestedDirectory ?? undefined)
|
|
|
|
|
?? getSyncConfig(configDirectoryPath ?? undefined);
|
|
|
|
|
const hasLatestSyncedOpencodeConfig = latestSyncedOpencodeConfig !== undefined;
|
|
|
|
|
const latestSyncedOpencodeDefaultAgent = hasLatestSyncedOpencodeConfig
|
|
|
|
|
? normalizeOptionalString(latestSyncedOpencodeConfig.default_agent)
|
|
|
|
|
: undefined;
|
|
|
|
|
const latestSyncedOpencodeDefaultModel = hasLatestSyncedOpencodeConfig
|
|
|
|
|
? normalizeOptionalString(latestSyncedOpencodeConfig.model)
|
|
|
|
|
: undefined;
|
|
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
const providers = get().activeDirectoryKey === directoryKey
|
|
|
|
|
? get().providers
|
|
|
|
|
: (get().directoryScoped[directoryKey]?.providers ?? []);
|
|
|
|
|
|
2026-02-24 05:23:57 -03:00
|
|
|
const existingZenModel = normalizeOptionalString(get().settingsZenModel);
|
|
|
|
|
|
|
|
|
|
const defaultZenModel = normalizeOptionalString(openChamberDefaults.zenModel);
|
|
|
|
|
|
|
|
|
|
const resolvedExistingGitSelection = resolveGitGenerationModelSelection({
|
|
|
|
|
providers,
|
|
|
|
|
settingsZenModel: existingZenModel,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const resolvedDefaultGitSelection = resolveGitGenerationModelSelection({
|
|
|
|
|
providers,
|
|
|
|
|
settingsZenModel: defaultZenModel,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const resolvedGitSelection = resolvedExistingGitSelection || resolvedDefaultGitSelection;
|
|
|
|
|
const resolvedGitModelId = resolvedGitSelection?.modelId;
|
2026-02-24 16:19:39 +02:00
|
|
|
const resolvedZenModel = resolvedGitModelId || defaultZenModel || existingZenModel;
|
2026-02-24 05:23:57 -03:00
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
set((state) => {
|
|
|
|
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
|
|
|
|
providers,
|
|
|
|
|
agents: previousAgents,
|
|
|
|
|
currentProviderId: "",
|
|
|
|
|
currentModelId: "",
|
|
|
|
|
currentAgentName: undefined,
|
|
|
|
|
selectedProviderId: "",
|
|
|
|
|
agentModelSelections: {},
|
|
|
|
|
defaultProviders: {},
|
|
|
|
|
};
|
2026-06-17 11:21:43 +03:00
|
|
|
const opencodeDefaultAgent = hasLatestSyncedOpencodeConfig
|
|
|
|
|
? latestSyncedOpencodeDefaultAgent
|
|
|
|
|
: baseSnapshot.opencodeDefaultAgent ?? (state.activeDirectoryKey === directoryKey ? state.opencodeDefaultAgent : undefined);
|
|
|
|
|
const opencodeDefaultModel = hasLatestSyncedOpencodeConfig
|
|
|
|
|
? latestSyncedOpencodeDefaultModel
|
|
|
|
|
: baseSnapshot.opencodeDefaultModel ?? (state.activeDirectoryKey === directoryKey ? state.opencodeDefaultModel : undefined);
|
2026-01-06 21:31:04 +02:00
|
|
|
|
|
|
|
|
const nextSnapshot: DirectoryScopedConfig = {
|
|
|
|
|
...baseSnapshot,
|
|
|
|
|
providers,
|
|
|
|
|
agents: safeAgents,
|
2026-06-17 11:21:43 +03:00
|
|
|
opencodeDefaultAgent,
|
|
|
|
|
opencodeDefaultModel,
|
2026-01-06 21:31:04 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const nextState: Partial<ConfigStore> = {
|
2026-01-17 01:09:21 -08:00
|
|
|
settingsDefaultModel: openChamberDefaults.defaultModel,
|
|
|
|
|
settingsDefaultVariant: openChamberDefaults.defaultVariant,
|
|
|
|
|
settingsDefaultAgent: openChamberDefaults.defaultAgent,
|
|
|
|
|
settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false,
|
|
|
|
|
settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false,
|
2026-04-23 07:14:14 -06:00
|
|
|
settingsDefaultFileViewerPreview: openChamberDefaults.defaultFileViewerPreview ?? false,
|
2026-02-24 05:23:57 -03:00
|
|
|
settingsZenModel: resolvedZenModel,
|
2026-04-17 16:07:26 +08:00
|
|
|
settingsMessageStreamTransport: openChamberDefaults.messageStreamTransport ?? state.settingsMessageStreamTransport ?? 'auto',
|
2026-05-13 19:45:03 +07:00
|
|
|
sttProvider: openChamberDefaults.sttProvider ?? state.sttProvider,
|
|
|
|
|
sttServerUrl: openChamberDefaults.sttServerUrl ?? state.sttServerUrl,
|
|
|
|
|
sttModel: openChamberDefaults.sttModel ?? state.sttModel,
|
2026-07-04 02:48:07 +03:00
|
|
|
sttLocalModel: openChamberDefaults.sttLocalModel ?? state.sttLocalModel,
|
2026-05-13 19:45:03 +07:00
|
|
|
sttLanguage: openChamberDefaults.sttLanguage ?? state.sttLanguage,
|
2026-01-17 01:09:21 -08:00
|
|
|
directoryScoped: {
|
2026-01-06 21:31:04 +02:00
|
|
|
...state.directoryScoped,
|
|
|
|
|
[directoryKey]: nextSnapshot,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (state.activeDirectoryKey === directoryKey) {
|
|
|
|
|
nextState.agents = safeAgents;
|
2026-06-17 11:21:43 +03:00
|
|
|
nextState.opencodeDefaultAgent = opencodeDefaultAgent;
|
|
|
|
|
nextState.opencodeDefaultModel = opencodeDefaultModel;
|
2026-01-06 21:31:04 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nextState;
|
|
|
|
|
});
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-06-17 11:21:43 +03:00
|
|
|
const latestConfigState = get();
|
|
|
|
|
const latestSnapshot = latestConfigState.directoryScoped[directoryKey];
|
|
|
|
|
const opencodeDefaultAgent = latestSnapshot?.opencodeDefaultAgent
|
|
|
|
|
?? (latestConfigState.activeDirectoryKey === directoryKey ? latestConfigState.opencodeDefaultAgent : undefined);
|
|
|
|
|
const opencodeDefaultModel = latestSnapshot?.opencodeDefaultModel
|
|
|
|
|
?? (latestConfigState.activeDirectoryKey === directoryKey ? latestConfigState.opencodeDefaultModel : undefined);
|
|
|
|
|
|
2026-02-24 16:19:39 +02:00
|
|
|
const shouldPersistResolvedZenModel =
|
|
|
|
|
!!resolvedZenModel &&
|
|
|
|
|
resolvedZenModel !== defaultZenModel;
|
2026-02-24 05:23:57 -03:00
|
|
|
|
2026-02-24 16:19:39 +02:00
|
|
|
if (shouldPersistResolvedZenModel && resolvedZenModel) {
|
|
|
|
|
updateDesktopSettings({
|
|
|
|
|
zenModel: resolvedZenModel,
|
|
|
|
|
gitProviderId: '',
|
|
|
|
|
gitModelId: '',
|
|
|
|
|
}).catch(() => {
|
2026-02-24 05:23:57 -03:00
|
|
|
// Ignore errors - best effort cleanup
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
if (safeAgents.length === 0) {
|
2026-01-06 21:31:04 +02:00
|
|
|
set((state) => {
|
|
|
|
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
|
|
|
|
providers,
|
|
|
|
|
agents: [],
|
2026-01-08 16:08:51 +02:00
|
|
|
currentProviderId: "",
|
|
|
|
|
currentModelId: "",
|
|
|
|
|
currentVariant: undefined,
|
|
|
|
|
currentAgentName: undefined,
|
2026-01-06 21:31:04 +02:00
|
|
|
selectedProviderId: "",
|
|
|
|
|
agentModelSelections: {},
|
|
|
|
|
defaultProviders: {},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const nextSnapshot: DirectoryScopedConfig = {
|
|
|
|
|
...baseSnapshot,
|
|
|
|
|
providers,
|
|
|
|
|
agents: [],
|
|
|
|
|
currentAgentName: undefined,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const nextState: Partial<ConfigStore> = {
|
|
|
|
|
directoryScoped: {
|
|
|
|
|
...state.directoryScoped,
|
|
|
|
|
[directoryKey]: nextSnapshot,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (state.activeDirectoryKey === directoryKey) {
|
|
|
|
|
nextState.currentAgentName = undefined;
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
return nextState;
|
|
|
|
|
});
|
2025-12-26 04:38:38 +02:00
|
|
|
|
2026-06-05 15:09:24 +03:00
|
|
|
const loaderEnded = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
|
|
|
markStartupTrace('loadAgents:end', {
|
|
|
|
|
directoryKey,
|
|
|
|
|
source,
|
|
|
|
|
requestedDirectory,
|
|
|
|
|
effectiveDirectory,
|
|
|
|
|
durationMs: Math.round(loaderEnded - loaderStarted),
|
|
|
|
|
agents: safeAgents.length,
|
|
|
|
|
});
|
2026-06-15 03:16:34 +03:00
|
|
|
_agentsLoadedAt.set(directoryKey, Date.now());
|
2026-01-06 21:31:04 +02:00
|
|
|
return true;
|
|
|
|
|
}
|
2025-12-26 04:38:38 +02:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-14 21:36:05 +03:00
|
|
|
// 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 = '';
|
2025-12-26 04:38:38 +02:00
|
|
|
}
|
2026-06-14 21:36:05 +03:00
|
|
|
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<string, unknown> } | undefined;
|
|
|
|
|
const variants = model?.variants;
|
|
|
|
|
if (!(variants && Object.prototype.hasOwnProperty.call(variants, openChamberDefaults.defaultVariant))) {
|
|
|
|
|
invalidSettings.defaultVariant = '';
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 21:36:05 +03:00
|
|
|
// 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;
|
|
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
set((state) => {
|
|
|
|
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
|
|
|
|
providers,
|
|
|
|
|
agents: safeAgents,
|
|
|
|
|
currentProviderId: "",
|
|
|
|
|
currentModelId: "",
|
|
|
|
|
currentAgentName: undefined,
|
|
|
|
|
selectedProviderId: "",
|
|
|
|
|
agentModelSelections: {},
|
|
|
|
|
defaultProviders: {},
|
|
|
|
|
};
|
2026-06-17 11:21:43 +03:00
|
|
|
const isActive = state.activeDirectoryKey === directoryKey;
|
|
|
|
|
const currentAgentName = isActive ? state.currentAgentName : baseSnapshot.currentAgentName;
|
|
|
|
|
const currentProviderId = isActive ? state.currentProviderId : baseSnapshot.currentProviderId;
|
|
|
|
|
const currentModelId = isActive ? state.currentModelId : baseSnapshot.currentModelId;
|
|
|
|
|
const currentVariant = isActive ? state.currentVariant : baseSnapshot.currentVariant;
|
|
|
|
|
const selectionSource = isActive ? state.selectionSource : (baseSnapshot.selectionSource ?? "auto");
|
|
|
|
|
const nextSelection = resolveSelectionWithManualGuard({
|
|
|
|
|
agents: safeAgents,
|
|
|
|
|
providers,
|
|
|
|
|
currentAgentName,
|
|
|
|
|
currentProviderId,
|
|
|
|
|
currentModelId,
|
|
|
|
|
currentVariant,
|
|
|
|
|
selectionSource,
|
|
|
|
|
resolvedAgentName,
|
|
|
|
|
resolvedProviderId,
|
|
|
|
|
resolvedModelId,
|
|
|
|
|
resolvedVariant,
|
|
|
|
|
});
|
2026-01-06 21:31:04 +02:00
|
|
|
|
|
|
|
|
const nextSnapshot: DirectoryScopedConfig = {
|
|
|
|
|
...baseSnapshot,
|
|
|
|
|
providers,
|
|
|
|
|
agents: safeAgents,
|
2026-06-17 11:21:43 +03:00
|
|
|
currentAgentName: nextSelection.agentName,
|
|
|
|
|
currentProviderId: nextSelection.providerId ?? baseSnapshot.currentProviderId,
|
|
|
|
|
currentModelId: nextSelection.modelId ?? baseSnapshot.currentModelId,
|
|
|
|
|
currentVariant: nextSelection.variant,
|
|
|
|
|
opencodeDefaultAgent,
|
|
|
|
|
opencodeDefaultModel,
|
|
|
|
|
selectionSource: nextSelection.selectionSource,
|
2026-01-06 21:31:04 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const nextState: Partial<ConfigStore> = {
|
|
|
|
|
directoryScoped: {
|
|
|
|
|
...state.directoryScoped,
|
|
|
|
|
[directoryKey]: nextSnapshot,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-17 11:21:43 +03:00
|
|
|
if (isActive) {
|
|
|
|
|
nextState.currentAgentName = nextSelection.agentName;
|
|
|
|
|
nextState.opencodeDefaultAgent = opencodeDefaultAgent;
|
|
|
|
|
nextState.opencodeDefaultModel = opencodeDefaultModel;
|
|
|
|
|
if (nextSelection.providerId && nextSelection.modelId) {
|
|
|
|
|
nextState.currentProviderId = nextSelection.providerId;
|
|
|
|
|
nextState.currentModelId = nextSelection.modelId;
|
|
|
|
|
nextState.currentVariant = nextSelection.variant;
|
2026-01-17 01:09:21 -08:00
|
|
|
}
|
2026-06-17 11:21:43 +03:00
|
|
|
nextState.selectionSource = nextSelection.selectionSource;
|
2026-01-17 01:09:21 -08:00
|
|
|
}
|
2026-01-06 21:31:04 +02:00
|
|
|
|
|
|
|
|
return nextState;
|
|
|
|
|
});
|
2025-12-26 04:38:38 +02:00
|
|
|
|
|
|
|
|
// Clear invalid settings from storage (best-effort cleanup)
|
|
|
|
|
if (Object.keys(invalidSettings).length > 0) {
|
|
|
|
|
// Also clear from store state
|
2026-01-08 16:08:51 +02:00
|
|
|
set({
|
|
|
|
|
settingsDefaultModel: invalidSettings.defaultModel !== undefined ? undefined : get().settingsDefaultModel,
|
|
|
|
|
settingsDefaultVariant: invalidSettings.defaultVariant !== undefined ? undefined : get().settingsDefaultVariant,
|
|
|
|
|
settingsDefaultAgent: invalidSettings.defaultAgent !== undefined ? undefined : get().settingsDefaultAgent,
|
|
|
|
|
});
|
2025-12-26 04:38:38 +02:00
|
|
|
updateDesktopSettings(invalidSettings).catch(() => {
|
|
|
|
|
// Ignore errors - best effort cleanup
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 15:09:24 +03:00
|
|
|
const loaderEnded = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
|
|
|
markStartupTrace('loadAgents:end', {
|
|
|
|
|
directoryKey,
|
|
|
|
|
source,
|
|
|
|
|
requestedDirectory,
|
|
|
|
|
effectiveDirectory,
|
|
|
|
|
durationMs: Math.round(loaderEnded - loaderStarted),
|
|
|
|
|
agents: safeAgents.length,
|
|
|
|
|
});
|
2026-06-15 03:16:34 +03:00
|
|
|
_agentsLoadedAt.set(directoryKey, Date.now());
|
2025-12-07 19:32:53 +02:00
|
|
|
return true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
lastError = error;
|
2026-06-05 15:09:24 +03:00
|
|
|
markStartupTrace('loadAgents:attemptError', {
|
|
|
|
|
directoryKey,
|
|
|
|
|
source,
|
|
|
|
|
requestedDirectory,
|
|
|
|
|
effectiveDirectory,
|
|
|
|
|
attempt: attempt + 1,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
});
|
2025-12-07 19:32:53 +02:00
|
|
|
const waitMs = 200 * (attempt + 1);
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.error("Failed to load agents:", lastError);
|
2026-06-05 15:09:24 +03:00
|
|
|
markStartupTrace('loadAgents:error', {
|
|
|
|
|
directoryKey,
|
|
|
|
|
source,
|
|
|
|
|
requestedDirectory,
|
|
|
|
|
effectiveDirectory,
|
|
|
|
|
error: lastError instanceof Error ? lastError.message : String(lastError),
|
|
|
|
|
});
|
2026-01-06 21:31:04 +02:00
|
|
|
|
|
|
|
|
set((state) => {
|
|
|
|
|
const providers = state.activeDirectoryKey === directoryKey
|
|
|
|
|
? state.providers
|
|
|
|
|
: (state.directoryScoped[directoryKey]?.providers ?? []);
|
|
|
|
|
|
|
|
|
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
|
|
|
|
providers,
|
|
|
|
|
agents: [],
|
|
|
|
|
currentProviderId: "",
|
|
|
|
|
currentModelId: "",
|
|
|
|
|
currentAgentName: undefined,
|
|
|
|
|
selectedProviderId: "",
|
|
|
|
|
agentModelSelections: {},
|
|
|
|
|
defaultProviders: {},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const nextSnapshot: DirectoryScopedConfig = {
|
|
|
|
|
...baseSnapshot,
|
|
|
|
|
providers,
|
|
|
|
|
agents: previousAgents,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const nextState: Partial<ConfigStore> = {
|
|
|
|
|
directoryScoped: {
|
|
|
|
|
...state.directoryScoped,
|
|
|
|
|
[directoryKey]: nextSnapshot,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (state.activeDirectoryKey === directoryKey) {
|
|
|
|
|
nextState.agents = previousAgents;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nextState;
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
return false;
|
2026-03-31 18:47:00 +03:00
|
|
|
})().finally(() => _inFlightAgents.delete(directoryKey));
|
|
|
|
|
|
|
|
|
|
_inFlightAgents.set(directoryKey, promise);
|
|
|
|
|
return promise;
|
2025-12-07 19:32:53 +02:00
|
|
|
},
|
|
|
|
|
|
2026-04-30 02:25:42 -07:00
|
|
|
invalidateModelMetadataCache: () => {
|
|
|
|
|
modelsMetadataInFlight = null;
|
|
|
|
|
set({ modelsMetadata: new Map<string, ModelMetadata>() });
|
|
|
|
|
},
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
setAgent: (agentName: string | undefined) => {
|
2026-04-27 04:28:51 -06:00
|
|
|
const {
|
|
|
|
|
agents,
|
|
|
|
|
providers,
|
|
|
|
|
settingsDefaultModel,
|
|
|
|
|
settingsDefaultVariant,
|
|
|
|
|
currentProviderId,
|
|
|
|
|
currentModelId,
|
|
|
|
|
} = get();
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
set((state) => {
|
|
|
|
|
const directoryKey = state.activeDirectoryKey;
|
|
|
|
|
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
|
|
|
|
providers: state.providers,
|
|
|
|
|
agents: state.agents,
|
|
|
|
|
currentProviderId: state.currentProviderId,
|
|
|
|
|
currentModelId: state.currentModelId,
|
|
|
|
|
currentAgentName: state.currentAgentName,
|
|
|
|
|
selectedProviderId: state.selectedProviderId,
|
|
|
|
|
agentModelSelections: state.agentModelSelections,
|
|
|
|
|
defaultProviders: state.defaultProviders,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const nextSnapshot: DirectoryScopedConfig = {
|
|
|
|
|
...baseSnapshot,
|
|
|
|
|
currentAgentName: agentName,
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "manual",
|
2026-01-06 21:31:04 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
currentAgentName: agentName,
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "manual",
|
2026-01-06 21:31:04 +02:00
|
|
|
directoryScoped: {
|
|
|
|
|
...state.directoryScoped,
|
|
|
|
|
[directoryKey]: nextSnapshot,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
if (agentName) {
|
|
|
|
|
const { currentSessionId } = useSessionUIStore.getState();
|
|
|
|
|
const selState = useSelectionStore.getState();
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
if (currentSessionId) {
|
|
|
|
|
selState.saveSessionAgentSelection(currentSessionId, agentName);
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
if (currentSessionId && useSessionUIStore.getState().isOpenChamberCreatedSession(currentSessionId)) {
|
|
|
|
|
const existingAgentModel = selState.getAgentModelForSession(currentSessionId, agentName);
|
|
|
|
|
if (!existingAgentModel) {
|
|
|
|
|
useSessionUIStore.getState().initializeNewOpenChamberSession(currentSessionId, agents);
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
if (agentName) {
|
|
|
|
|
const { currentSessionId } = useSessionUIStore.getState();
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-04-27 04:28:51 -06:00
|
|
|
const applyResolvedModelSelection = (providerId: string, modelId: string, variant?: string) => {
|
|
|
|
|
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,
|
|
|
|
|
currentProviderId: providerId,
|
|
|
|
|
currentModelId: modelId,
|
|
|
|
|
currentVariant: variant,
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "manual",
|
2026-04-27 04:28:51 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
currentProviderId: providerId,
|
|
|
|
|
currentModelId: modelId,
|
|
|
|
|
currentVariant: variant,
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "manual",
|
2026-04-27 04:28:51 -06:00
|
|
|
directoryScoped: {
|
|
|
|
|
...state.directoryScoped,
|
|
|
|
|
[directoryKey]: nextSnapshot,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-23 18:35:36 +08:00
|
|
|
const resolveVariantForModel = (
|
|
|
|
|
providerId: string,
|
|
|
|
|
modelId: string,
|
|
|
|
|
agentVariant?: string,
|
|
|
|
|
): string | undefined => {
|
|
|
|
|
const model = providers
|
|
|
|
|
.find((provider) => provider.id === providerId)
|
|
|
|
|
?.models.find((candidate) => candidate.id === modelId) as { variants?: Record<string, unknown> } | undefined;
|
|
|
|
|
const variants = model?.variants;
|
|
|
|
|
if (!variants) return undefined;
|
|
|
|
|
|
|
|
|
|
const savedVariant = currentSessionId
|
|
|
|
|
? useSelectionStore.getState().getAgentModelVariantForSession(
|
|
|
|
|
currentSessionId,
|
|
|
|
|
agentName,
|
|
|
|
|
providerId,
|
|
|
|
|
modelId,
|
|
|
|
|
)
|
|
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
for (const candidate of [savedVariant, agentVariant, settingsDefaultVariant]) {
|
|
|
|
|
if (candidate && Object.prototype.hasOwnProperty.call(variants, candidate)) {
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return undefined;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-26 01:19:54 +02:00
|
|
|
const agent = agents.find((candidate) => candidate.name === agentName);
|
|
|
|
|
|
2026-08-04 12:46:48 +00:00
|
|
|
// Prefer a session-level manual override for this agent over the
|
|
|
|
|
// agent's configured default. Re-applying setAgent after subtask
|
|
|
|
|
// completion / rematerialization must not clobber the override
|
|
|
|
|
// (issue #2404). Explicit agent-picker switches still force the
|
|
|
|
|
// agent default via ModelControls' shouldPreferAgentModel path.
|
2026-03-31 18:47:00 +03:00
|
|
|
if (currentSessionId) {
|
|
|
|
|
const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName);
|
2026-04-27 04:28:51 -06:00
|
|
|
if (existingAgentModel && hasProviderModel(providers, existingAgentModel.providerId, existingAgentModel.modelId)) {
|
2026-06-23 18:35:36 +08:00
|
|
|
const resolvedVariant = resolveVariantForModel(existingAgentModel.providerId, existingAgentModel.modelId, agent?.variant);
|
2026-04-27 04:28:51 -06:00
|
|
|
if (
|
|
|
|
|
currentProviderId !== existingAgentModel.providerId
|
|
|
|
|
|| currentModelId !== existingAgentModel.modelId
|
2026-06-23 18:35:36 +08:00
|
|
|
|| get().currentVariant !== resolvedVariant
|
2026-04-27 04:28:51 -06:00
|
|
|
) {
|
2026-06-23 18:35:36 +08:00
|
|
|
applyResolvedModelSelection(existingAgentModel.providerId, existingAgentModel.modelId, resolvedVariant);
|
2026-04-27 04:28:51 -06:00
|
|
|
}
|
2026-03-31 18:47:00 +03:00
|
|
|
return;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 12:46:48 +00:00
|
|
|
// No session override — use the agent's configured/pinned model.
|
|
|
|
|
const agentModelSelection = agent?.model;
|
|
|
|
|
if (agentModelSelection?.providerID && agentModelSelection?.modelID) {
|
|
|
|
|
const { providerID, modelID } = agentModelSelection;
|
|
|
|
|
const agentProvider = providers.find((provider) => provider.id === providerID);
|
|
|
|
|
const agentModel = agentProvider?.models.find((model) => model.id === modelID);
|
|
|
|
|
|
|
|
|
|
if (agentModel) {
|
|
|
|
|
applyResolvedModelSelection(providerID, modelID, resolveVariantForModel(providerID, modelID, agent?.variant));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 01:19:54 +02:00
|
|
|
// If the agent has no preferred model, use settings default.
|
2025-12-26 04:38:38 +02:00
|
|
|
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)) {
|
2026-06-23 18:35:36 +08:00
|
|
|
applyResolvedModelSelection(parsed.providerId, parsed.modelId, resolveVariantForModel(parsed.providerId, parsed.modelId, agent?.variant));
|
2025-12-26 04:38:38 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 01:19:54 +02:00
|
|
|
// Otherwise keep the current valid model selection unchanged.
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-14 21:36:05 +03:00
|
|
|
// Re-applies the same priority cascade used at app startup (see loadAgents):
|
|
|
|
|
// agent: settings.defaultAgent → build → first primary → first agent
|
2026-07-09 13:54:05 +03:00
|
|
|
// model: project.defaultModel → settings.defaultModel → agent's preferred model → opencode/big-pickle → first
|
2026-06-14 21:36:05 +03:00
|
|
|
// Used when entering a fresh draft session so model/agent reset to defaults
|
|
|
|
|
// instead of sticking to the previously open session's selection.
|
2026-07-09 13:54:05 +03:00
|
|
|
applyDefaultModelAgentSelection: (options) => {
|
2026-06-14 21:36:05 +03:00
|
|
|
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,
|
2026-07-09 13:54:05 +03:00
|
|
|
projectDefaultModel: options?.projectDefaultModel,
|
2026-08-22 20:31:20 +03:00
|
|
|
projectDefaultVariant: options?.projectDefaultVariant,
|
2026-06-14 21:36:05 +03:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
: {}),
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "auto",
|
2026-06-14 21:36:05 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const nextState: Partial<ConfigStore> = {
|
|
|
|
|
currentAgentName: resolvedAgentName,
|
2026-06-17 11:21:43 +03:00
|
|
|
selectionSource: "auto",
|
2026-06-14 21:36:05 +03:00
|
|
|
directoryScoped: {
|
|
|
|
|
...state.directoryScoped,
|
|
|
|
|
[directoryKey]: nextSnapshot,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (resolvedProviderId && resolvedModelId) {
|
|
|
|
|
nextState.currentProviderId = resolvedProviderId;
|
|
|
|
|
nextState.currentModelId = resolvedModelId;
|
|
|
|
|
nextState.currentVariant = resolvedVariant;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nextState;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-17 11:21:43 +03:00
|
|
|
applyOpenCodeConfigDefaults: (directory, source = "syncConfig", config) => {
|
|
|
|
|
const eventDirectory = directory ?? fromDirectoryKey(get().activeDirectoryKey);
|
|
|
|
|
const directoryKey = toConfigDirectoryKey(eventDirectory);
|
|
|
|
|
const configDirectory = fromDirectoryKey(directoryKey);
|
|
|
|
|
const syncedConfig = config
|
|
|
|
|
?? getSyncConfig(eventDirectory ?? undefined)
|
|
|
|
|
?? getSyncConfig(configDirectory ?? undefined);
|
|
|
|
|
if (!syncedConfig) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const opencodeDefaultAgent = normalizeOptionalString(syncedConfig.default_agent);
|
|
|
|
|
const opencodeDefaultModel = normalizeOptionalString(syncedConfig.model);
|
|
|
|
|
|
|
|
|
|
set((state) => {
|
|
|
|
|
const snapshot = state.directoryScoped[directoryKey];
|
|
|
|
|
const isActive = state.activeDirectoryKey === directoryKey;
|
|
|
|
|
const providers = isActive ? state.providers : (snapshot?.providers ?? []);
|
|
|
|
|
const agents = isActive ? state.agents : (snapshot?.agents ?? []);
|
|
|
|
|
const baseSnapshot: DirectoryScopedConfig = snapshot ?? createEmptyDirectoryScopedConfig(providers, agents);
|
|
|
|
|
const defaultsChanged = baseSnapshot.opencodeDefaultAgent !== opencodeDefaultAgent
|
|
|
|
|
|| baseSnapshot.opencodeDefaultModel !== opencodeDefaultModel
|
|
|
|
|
|| (isActive && (
|
|
|
|
|
state.opencodeDefaultAgent !== opencodeDefaultAgent
|
|
|
|
|
|| state.opencodeDefaultModel !== opencodeDefaultModel
|
|
|
|
|
));
|
|
|
|
|
const defaultsSnapshot: DirectoryScopedConfig = {
|
|
|
|
|
...baseSnapshot,
|
|
|
|
|
providers,
|
|
|
|
|
agents,
|
|
|
|
|
opencodeDefaultAgent,
|
|
|
|
|
opencodeDefaultModel,
|
|
|
|
|
};
|
|
|
|
|
const nextState: Partial<ConfigStore> = {
|
|
|
|
|
directoryScoped: {
|
|
|
|
|
...state.directoryScoped,
|
|
|
|
|
[directoryKey]: defaultsSnapshot,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (isActive) {
|
|
|
|
|
nextState.opencodeDefaultAgent = opencodeDefaultAgent;
|
|
|
|
|
nextState.opencodeDefaultModel = opencodeDefaultModel;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const selectionSource = isActive ? state.selectionSource : (snapshot?.selectionSource ?? "auto");
|
|
|
|
|
|
|
|
|
|
if (providers.length === 0 || agents.length === 0) {
|
|
|
|
|
if (!defaultsChanged) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
return nextState;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resolved = resolveDefaultAgentModelSelection({
|
|
|
|
|
agents,
|
|
|
|
|
providers,
|
|
|
|
|
settingsDefaultAgent: state.settingsDefaultAgent,
|
|
|
|
|
settingsDefaultModel: state.settingsDefaultModel,
|
|
|
|
|
settingsDefaultVariant: state.settingsDefaultVariant,
|
|
|
|
|
opencodeDefaultAgent,
|
|
|
|
|
opencodeDefaultModel,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!resolved.agentName) {
|
|
|
|
|
if (!defaultsChanged) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
return nextState;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentAgentName = isActive ? state.currentAgentName : baseSnapshot.currentAgentName;
|
|
|
|
|
const currentProviderId = isActive ? state.currentProviderId : baseSnapshot.currentProviderId;
|
|
|
|
|
const currentModelId = isActive ? state.currentModelId : baseSnapshot.currentModelId;
|
|
|
|
|
const currentVariant = isActive ? state.currentVariant : baseSnapshot.currentVariant;
|
|
|
|
|
const nextSelection = resolveSelectionWithManualGuard({
|
|
|
|
|
agents,
|
|
|
|
|
providers,
|
|
|
|
|
currentAgentName,
|
|
|
|
|
currentProviderId,
|
|
|
|
|
currentModelId,
|
|
|
|
|
currentVariant,
|
|
|
|
|
selectionSource,
|
|
|
|
|
resolvedAgentName: resolved.agentName,
|
|
|
|
|
resolvedProviderId: resolved.providerId,
|
|
|
|
|
resolvedModelId: resolved.modelId,
|
|
|
|
|
resolvedVariant: resolved.variant,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const nextSnapshot: DirectoryScopedConfig = {
|
|
|
|
|
...defaultsSnapshot,
|
|
|
|
|
providers,
|
|
|
|
|
agents,
|
|
|
|
|
currentAgentName: nextSelection.agentName,
|
|
|
|
|
...(nextSelection.providerId && nextSelection.modelId
|
|
|
|
|
? {
|
|
|
|
|
currentProviderId: nextSelection.providerId,
|
|
|
|
|
currentModelId: nextSelection.modelId,
|
|
|
|
|
currentVariant: nextSelection.variant,
|
|
|
|
|
}
|
|
|
|
|
: {}),
|
|
|
|
|
selectionSource: nextSelection.selectionSource,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const selectionChanged = baseSnapshot.currentAgentName !== nextSnapshot.currentAgentName
|
|
|
|
|
|| baseSnapshot.currentProviderId !== nextSnapshot.currentProviderId
|
|
|
|
|
|| baseSnapshot.currentModelId !== nextSnapshot.currentModelId
|
|
|
|
|
|| baseSnapshot.currentVariant !== nextSnapshot.currentVariant
|
|
|
|
|
|| baseSnapshot.selectedProviderId !== nextSnapshot.selectedProviderId
|
|
|
|
|
|| (baseSnapshot.selectionSource ?? "auto") !== nextSnapshot.selectionSource
|
|
|
|
|
|| (isActive && (
|
|
|
|
|
state.currentAgentName !== nextSelection.agentName
|
|
|
|
|
|| state.selectionSource !== nextSelection.selectionSource
|
|
|
|
|
|| (nextSelection.providerId !== undefined && nextSelection.modelId !== undefined && (
|
|
|
|
|
state.currentProviderId !== nextSelection.providerId
|
|
|
|
|
|| state.currentModelId !== nextSelection.modelId
|
|
|
|
|
|| state.currentVariant !== nextSelection.variant
|
|
|
|
|
))
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
if (!defaultsChanged && !selectionChanged) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
nextState.directoryScoped = {
|
|
|
|
|
...state.directoryScoped,
|
|
|
|
|
[directoryKey]: nextSnapshot,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (isActive) {
|
|
|
|
|
nextState.currentAgentName = nextSelection.agentName;
|
|
|
|
|
nextState.selectionSource = nextSelection.selectionSource;
|
|
|
|
|
if (nextSelection.providerId && nextSelection.modelId) {
|
|
|
|
|
nextState.currentProviderId = nextSelection.providerId;
|
|
|
|
|
nextState.currentModelId = nextSelection.modelId;
|
|
|
|
|
nextState.currentVariant = nextSelection.variant;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
markStartupTrace('loadAgents:opencodeConfigDefaultsApplied', { directoryKey, eventDirectory, source });
|
|
|
|
|
return nextState;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-08 16:08:51 +02:00
|
|
|
setSettingsDefaultModel: (model: string | undefined) => {
|
|
|
|
|
set({ settingsDefaultModel: model });
|
|
|
|
|
},
|
2025-12-26 04:38:38 +02:00
|
|
|
|
2026-01-08 16:08:51 +02:00
|
|
|
setSettingsDefaultVariant: (variant: string | undefined) => {
|
|
|
|
|
set({ settingsDefaultVariant: variant });
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setSettingsDefaultAgent: (agent: string | undefined) => {
|
|
|
|
|
set({ settingsDefaultAgent: agent });
|
|
|
|
|
},
|
2025-12-26 04:38:38 +02:00
|
|
|
|
2026-01-07 23:37:32 +02:00
|
|
|
setSettingsAutoCreateWorktree: (enabled: boolean) => {
|
|
|
|
|
set({ settingsAutoCreateWorktree: enabled });
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-17 01:09:21 -08:00
|
|
|
setSettingsGitmojiEnabled: (enabled: boolean) => {
|
|
|
|
|
set({ settingsGitmojiEnabled: enabled });
|
|
|
|
|
},
|
|
|
|
|
|
2026-04-23 07:14:14 -06:00
|
|
|
setSettingsDefaultFileViewerPreview: (enabled: boolean) => {
|
|
|
|
|
set({ settingsDefaultFileViewerPreview: enabled });
|
|
|
|
|
},
|
|
|
|
|
|
2026-02-12 19:37:28 -08:00
|
|
|
setSettingsZenModel: (model: string | undefined) => {
|
|
|
|
|
set({ settingsZenModel: model });
|
|
|
|
|
},
|
|
|
|
|
|
2026-04-17 16:07:26 +08:00
|
|
|
setSettingsMessageStreamTransport: (transport: 'auto' | 'ws' | 'sse') => {
|
|
|
|
|
set({ settingsMessageStreamTransport: transport });
|
|
|
|
|
},
|
|
|
|
|
|
2026-02-24 05:23:57 -03:00
|
|
|
getResolvedGitGenerationModel: () => {
|
|
|
|
|
const state = get();
|
|
|
|
|
return resolveGitGenerationModelSelection({
|
|
|
|
|
providers: state.providers,
|
|
|
|
|
settingsZenModel: state.settingsZenModel,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-07-04 02:48:07 +03:00
|
|
|
setVoiceProvider: (provider: 'browser' | 'local' | 'openai' | 'openai-compatible' | 'say') => {
|
2026-02-09 13:55:10 -08:00
|
|
|
set({ voiceProvider: provider });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('voiceProvider', provider);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setSpeechRate: (rate: number) => {
|
|
|
|
|
const clampedRate = Math.max(0.5, Math.min(2, rate));
|
|
|
|
|
set({ speechRate: clampedRate });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('speechRate', String(clampedRate));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setSpeechPitch: (pitch: number) => {
|
|
|
|
|
const clampedPitch = Math.max(0.5, Math.min(2, pitch));
|
|
|
|
|
set({ speechPitch: clampedPitch });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('speechPitch', String(clampedPitch));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setSpeechVolume: (volume: number) => {
|
|
|
|
|
const clampedVolume = Math.max(0, Math.min(1, volume));
|
|
|
|
|
set({ speechVolume: clampedVolume });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('speechVolume', String(clampedVolume));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setSayVoice: (voice: string) => {
|
|
|
|
|
set({ sayVoice: voice });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('sayVoice', voice);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-07-04 02:48:07 +03:00
|
|
|
setLocalTtsVoiceId: (voiceId: number) => {
|
|
|
|
|
set({ localTtsVoiceId: voiceId });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('localTtsVoiceId', String(voiceId));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-02-09 13:55:10 -08:00
|
|
|
setBrowserVoice: (voice: string) => {
|
|
|
|
|
set({ browserVoice: voice });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('browserVoice', voice);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setOpenaiVoice: (voice: string) => {
|
|
|
|
|
set({ openaiVoice: voice });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('openaiVoice', voice);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setOpenaiApiKey: (apiKey: string) => {
|
|
|
|
|
set({ openaiApiKey: apiKey });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('openaiApiKey', apiKey);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-04-11 21:47:33 +02:00
|
|
|
setOpenaiCompatibleUrl: (url: string) => {
|
|
|
|
|
set({ openaiCompatibleUrl: url });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('openaiCompatibleUrl', url);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-05-24 05:58:22 +08:00
|
|
|
setOpenaiCompatibleApiKey: (apiKey: string) => {
|
|
|
|
|
set({ openaiCompatibleApiKey: apiKey });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('openaiCompatibleApiKey', apiKey);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-04-11 21:47:33 +02:00
|
|
|
setOpenaiCompatibleVoice: (voice: string) => {
|
|
|
|
|
set({ openaiCompatibleVoice: voice });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('openaiCompatibleVoice', voice);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-04-12 09:33:15 +02:00
|
|
|
setOpenaiCompatibleTtsModel: (model: string) => {
|
|
|
|
|
set({ openaiCompatibleTtsModel: model });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('openaiCompatibleTtsModel', model);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-07-04 02:48:07 +03:00
|
|
|
setDictationEnabled: (enabled: boolean) => {
|
|
|
|
|
set({ dictationEnabled: enabled });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('dictationEnabled', String(enabled));
|
|
|
|
|
}
|
|
|
|
|
updateDesktopSettings({ dictationEnabled: enabled }).catch(() => {});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setSttProvider: (provider: 'local' | 'openai-compatible') => {
|
2026-04-11 21:47:33 +02:00
|
|
|
set({ sttProvider: provider });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('sttProvider', provider);
|
|
|
|
|
}
|
2026-05-13 19:45:03 +07:00
|
|
|
updateDesktopSettings({ sttProvider: provider }).catch(() => {});
|
2026-04-11 21:47:33 +02:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setSttServerUrl: (url: string) => {
|
|
|
|
|
set({ sttServerUrl: url });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('sttServerUrl', url);
|
|
|
|
|
}
|
2026-05-13 19:45:03 +07:00
|
|
|
updateDesktopSettings({ sttServerUrl: url }).catch(() => {});
|
2026-04-11 21:47:33 +02:00
|
|
|
},
|
|
|
|
|
|
2026-05-24 05:58:22 +08:00
|
|
|
setSttApiKey: (apiKey: string) => {
|
|
|
|
|
set({ sttApiKey: apiKey });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('sttApiKey', apiKey);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-04-11 21:47:33 +02:00
|
|
|
setSttModel: (model: string) => {
|
|
|
|
|
set({ sttModel: model });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('sttModel', model);
|
|
|
|
|
}
|
2026-05-13 19:45:03 +07:00
|
|
|
updateDesktopSettings({ sttModel: model }).catch(() => {});
|
2026-04-11 21:47:33 +02:00
|
|
|
},
|
|
|
|
|
|
2026-07-04 02:48:07 +03:00
|
|
|
setSttLocalModel: (model: string) => {
|
|
|
|
|
set({ sttLocalModel: model });
|
2026-05-14 01:19:52 +03:00
|
|
|
if (typeof window !== 'undefined') {
|
2026-07-04 02:48:07 +03:00
|
|
|
localStorage.setItem('sttLocalModel', model);
|
2026-05-14 01:19:52 +03:00
|
|
|
}
|
2026-07-04 02:48:07 +03:00
|
|
|
updateDesktopSettings({ sttLocalModel: model }).catch(() => {});
|
2026-05-14 01:19:52 +03:00
|
|
|
},
|
|
|
|
|
|
2026-04-11 21:47:33 +02:00
|
|
|
setSttLanguage: (lang: string) => {
|
|
|
|
|
set({ sttLanguage: lang });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('sttLanguage', lang);
|
|
|
|
|
}
|
2026-05-13 19:45:03 +07:00
|
|
|
updateDesktopSettings({ sttLanguage: lang }).catch(() => {});
|
2026-04-11 21:47:33 +02:00
|
|
|
},
|
|
|
|
|
|
2026-02-09 13:55:10 -08:00
|
|
|
setShowMessageTTSButtons: (show: boolean) => {
|
|
|
|
|
set({ showMessageTTSButtons: show });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('showMessageTTSButtons', String(show));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-07-05 23:19:10 +03:00
|
|
|
setTtsInputMode: (mode: 'sanitized' | 'raw' | 'summarized') => {
|
2026-06-09 00:31:21 +08:00
|
|
|
set({ ttsInputMode: mode });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('ttsInputMode', mode);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-02-09 13:55:10 -08:00
|
|
|
setSummarizeMessageTTS: (enabled: boolean) => {
|
|
|
|
|
set({ summarizeMessageTTS: enabled });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('summarizeMessageTTS', String(enabled));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setSummarizeVoiceConversation: (enabled: boolean) => {
|
|
|
|
|
set({ summarizeVoiceConversation: enabled });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('summarizeVoiceConversation', String(enabled));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setSummarizeCharacterThreshold: (threshold: number) => {
|
|
|
|
|
const clamped = Math.max(50, Math.min(2000, threshold));
|
|
|
|
|
set({ summarizeCharacterThreshold: clamped });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('summarizeCharacterThreshold', String(clamped));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setSummarizeMaxLength: (maxLength: number) => {
|
|
|
|
|
const clamped = Math.max(50, Math.min(2000, maxLength));
|
|
|
|
|
set({ summarizeMaxLength: clamped });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
localStorage.setItem('summarizeMaxLength', String(clamped));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-04-25 16:56:37 +03:00
|
|
|
probeConnection: async (options?: { timeoutMs?: number }) => {
|
|
|
|
|
const isHealthy = await probeOpenCodeHealth(options?.timeoutMs);
|
|
|
|
|
if (isHealthy) {
|
|
|
|
|
set({ isConnected: true, hasEverConnected: true, connectionPhase: "connected" });
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const state = get();
|
|
|
|
|
if (state.isConnected) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set({
|
|
|
|
|
isConnected: false,
|
|
|
|
|
connectionPhase: state.hasEverConnected ? "reconnecting" : "connecting",
|
|
|
|
|
lastDisconnectReason: 'health_probe_unhealthy',
|
|
|
|
|
});
|
|
|
|
|
return false;
|
|
|
|
|
},
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
checkConnection: async () => {
|
2026-06-05 15:09:24 +03:00
|
|
|
markStartupTrace('checkConnection:start');
|
2025-12-07 19:32:53 +02:00
|
|
|
const maxAttempts = 5;
|
|
|
|
|
let attempt = 0;
|
|
|
|
|
let lastError: unknown = null;
|
|
|
|
|
|
|
|
|
|
while (attempt < maxAttempts) {
|
|
|
|
|
try {
|
2026-06-05 15:09:24 +03:00
|
|
|
markStartupTrace('checkConnection:attempt', { attempt: attempt + 1 });
|
|
|
|
|
const isHealthy = await measureStartupTrace(
|
|
|
|
|
'checkConnection:health',
|
|
|
|
|
() => opencodeClient.checkHealth(),
|
|
|
|
|
{ attempt: attempt + 1 },
|
|
|
|
|
);
|
|
|
|
|
if (!isHealthy && attempt < maxAttempts - 1) {
|
|
|
|
|
const hasEverConnected = get().hasEverConnected;
|
|
|
|
|
set({
|
|
|
|
|
isConnected: false,
|
|
|
|
|
connectionPhase: hasEverConnected ? "reconnecting" : "connecting",
|
|
|
|
|
lastDisconnectReason: 'health_check_unhealthy',
|
|
|
|
|
});
|
|
|
|
|
attempt += 1;
|
|
|
|
|
await sleep(400 * attempt);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 21:03:02 +03:00
|
|
|
const hasEverConnected = get().hasEverConnected;
|
|
|
|
|
set(isHealthy
|
|
|
|
|
? { isConnected: true, hasEverConnected: true, connectionPhase: "connected" }
|
|
|
|
|
: {
|
|
|
|
|
isConnected: false,
|
|
|
|
|
connectionPhase: hasEverConnected ? "reconnecting" : "connecting",
|
|
|
|
|
lastDisconnectReason: 'health_check_unhealthy',
|
|
|
|
|
});
|
2026-06-05 15:09:24 +03:00
|
|
|
markStartupTrace('checkConnection:end', { healthy: isHealthy, attempts: attempt + 1 });
|
2025-12-07 19:32:53 +02:00
|
|
|
return isHealthy;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
lastError = error;
|
|
|
|
|
attempt += 1;
|
|
|
|
|
const delay = 400 * attempt;
|
|
|
|
|
await sleep(delay);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (lastError) {
|
|
|
|
|
console.warn("[ConfigStore] Failed to reach OpenCode after retrying:", lastError);
|
|
|
|
|
}
|
2026-04-22 21:03:02 +03:00
|
|
|
set({
|
|
|
|
|
isConnected: false,
|
|
|
|
|
connectionPhase: get().hasEverConnected ? "reconnecting" : "connecting",
|
|
|
|
|
lastDisconnectReason: 'health_check_failed',
|
|
|
|
|
});
|
2026-06-05 15:09:24 +03:00
|
|
|
markStartupTrace('checkConnection:end', { healthy: false, attempts: maxAttempts });
|
2025-12-07 19:32:53 +02:00
|
|
|
return false;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
initializeApp: async () => {
|
2026-05-06 02:43:13 +03:00
|
|
|
if (_initializeAppInFlight) {
|
2026-06-05 15:09:24 +03:00
|
|
|
markStartupTrace('initializeApp:deduped');
|
2026-05-06 02:43:13 +03:00
|
|
|
return _initializeAppInFlight;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const run = (async () => {
|
2026-06-05 15:09:24 +03:00
|
|
|
const initStarted = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
|
|
|
markStartupTrace('initializeApp:start');
|
2026-05-06 02:43:13 +03:00
|
|
|
try {
|
|
|
|
|
const debug = streamDebugEnabled();
|
|
|
|
|
if (debug) console.log("Starting app initialization...");
|
|
|
|
|
|
|
|
|
|
const isConnected = await get().checkConnection();
|
|
|
|
|
if (debug) console.log("Connection check result:", isConnected);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-05-06 02:43:13 +03:00
|
|
|
if (!isConnected) {
|
|
|
|
|
if (debug) console.log("Server not connected");
|
|
|
|
|
// checkConnection already set lastDisconnectReason; do not overwrite.
|
|
|
|
|
set({
|
|
|
|
|
isConnected: false,
|
|
|
|
|
connectionPhase: get().hasEverConnected ? "reconnecting" : "connecting",
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-05-06 02:43:13 +03:00
|
|
|
if (debug) console.log("Initializing app...");
|
2026-06-05 15:09:24 +03:00
|
|
|
markStartupTrace('initApp:skipped', { reason: 'checkConnection already verified health' });
|
2026-05-06 02:43:13 +03:00
|
|
|
|
2026-06-15 03:16:34 +03:00
|
|
|
// Stale-while-revalidate: do NOT invalidate the hydrated
|
|
|
|
|
// provider snapshot here. The pickers keep showing the
|
|
|
|
|
// last-known providers/agents while loadProviders/loadAgents
|
|
|
|
|
// below fetch fresh data and overwrite on success. Clearing
|
|
|
|
|
// first would blank the UI for the duration of the fetch.
|
2026-06-08 14:54:02 +03:00
|
|
|
|
2026-06-14 22:04:32 +03:00
|
|
|
// Config (providers/agents/defaults) lives at the PROJECT level. If the
|
|
|
|
|
// app starts on a worktree directory, load config under the owning
|
|
|
|
|
// project's key so the initial draft — which activates the project — finds
|
|
|
|
|
// a ready snapshot instead of triggering a second provider/agent load.
|
|
|
|
|
const initialDirectory = opencodeClient.getDirectory()
|
|
|
|
|
?? useDirectoryStore.getState().currentDirectory
|
|
|
|
|
?? fromDirectoryKey(get().activeDirectoryKey);
|
|
|
|
|
const resolvedProject = resolveProjectForSessionDirectory(
|
|
|
|
|
useProjectsStore.getState().projects,
|
|
|
|
|
useSessionUIStore.getState().availableWorktreesByProject,
|
|
|
|
|
initialDirectory ?? null,
|
|
|
|
|
);
|
2026-06-16 19:23:38 +03:00
|
|
|
const resolvedInitialDirectory = resolveConfigDirectory(resolvedProject?.path ?? initialDirectory ?? null);
|
|
|
|
|
const configDirectory = resolvedInitialDirectory ?? getFallbackProjectDirectory();
|
|
|
|
|
if (!configDirectory) {
|
|
|
|
|
markStartupTrace('initializeApp:noProjectConfigDirectory');
|
|
|
|
|
set({ isInitialized: true, isConnected: true, hasEverConnected: true, connectionPhase: "connected" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!resolvedInitialDirectory && initialDirectory !== configDirectory) {
|
|
|
|
|
markStartupTrace('initializeApp:normalizedUnknownDirectoryToProject', {
|
|
|
|
|
initialDirectory,
|
|
|
|
|
configDirectory,
|
|
|
|
|
});
|
|
|
|
|
opencodeClient.setDirectory(configDirectory);
|
|
|
|
|
useDirectoryStore.getState().setDirectory(configDirectory, { showOverlay: false });
|
|
|
|
|
}
|
2026-06-14 22:04:32 +03:00
|
|
|
const configDirectoryKey = toDirectoryKey(configDirectory);
|
|
|
|
|
if (get().activeDirectoryKey !== configDirectoryKey) {
|
|
|
|
|
set({ activeDirectoryKey: configDirectoryKey });
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 15:09:24 +03:00
|
|
|
if (debug) console.log("Loading providers and agents...");
|
|
|
|
|
await Promise.all([
|
2026-06-14 22:04:32 +03:00
|
|
|
get().loadProviders({ directory: configDirectory, source: 'initializeApp' }),
|
|
|
|
|
get().loadAgents({ directory: configDirectory, source: 'initializeApp' }),
|
2026-06-05 15:09:24 +03:00
|
|
|
]);
|
2026-05-06 02:43:13 +03:00
|
|
|
|
|
|
|
|
set({ isInitialized: true, isConnected: true, hasEverConnected: true, connectionPhase: "connected" });
|
2026-06-16 19:23:38 +03:00
|
|
|
void get().prewarmProjectConfigs(configDirectory);
|
2026-06-05 15:09:24 +03:00
|
|
|
const initEnded = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
|
|
|
markStartupTrace('initializeApp:end', {
|
|
|
|
|
durationMs: Math.round(initEnded - initStarted),
|
|
|
|
|
providers: get().providers.length,
|
|
|
|
|
agents: get().agents.length,
|
|
|
|
|
});
|
2026-05-06 02:43:13 +03:00
|
|
|
if (debug) console.log("App initialized successfully");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Failed to initialize app:", error);
|
2026-04-22 21:03:02 +03:00
|
|
|
set({
|
2026-05-06 02:43:13 +03:00
|
|
|
isInitialized: false,
|
2026-04-22 21:03:02 +03:00
|
|
|
isConnected: false,
|
|
|
|
|
connectionPhase: get().hasEverConnected ? "reconnecting" : "connecting",
|
2026-05-06 02:43:13 +03:00
|
|
|
lastDisconnectReason: 'init_error',
|
2026-04-22 21:03:02 +03:00
|
|
|
});
|
2026-06-05 15:09:24 +03:00
|
|
|
markStartupTrace('initializeApp:error', { error: error instanceof Error ? error.message : String(error) });
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
2026-05-06 02:43:13 +03:00
|
|
|
})().finally(() => {
|
|
|
|
|
_initializeAppInFlight = null;
|
|
|
|
|
});
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-05-06 02:43:13 +03:00
|
|
|
_initializeAppInFlight = run;
|
|
|
|
|
return run;
|
2025-12-07 19:32:53 +02:00
|
|
|
},
|
|
|
|
|
|
2026-06-16 19:23:38 +03:00
|
|
|
prewarmProjectConfigs: async (initialDirectory?: string | null) => {
|
|
|
|
|
if (!get().isConnected) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const initialKey = toConfigDirectoryKey(initialDirectory ?? fromDirectoryKey(get().activeDirectoryKey));
|
|
|
|
|
const projectDirectories = useProjectsStore.getState().projects
|
|
|
|
|
.map((project) => project.path)
|
|
|
|
|
.filter((path): path is string => typeof path === 'string' && path.trim().length > 0);
|
|
|
|
|
const seen = new Set<string>([initialKey]);
|
|
|
|
|
const queuedDirectories: string[] = [];
|
|
|
|
|
|
|
|
|
|
for (const directory of projectDirectories) {
|
|
|
|
|
const directoryKey = toConfigDirectoryKey(directory);
|
|
|
|
|
if (seen.has(directoryKey)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
seen.add(directoryKey);
|
|
|
|
|
|
|
|
|
|
const snapshot = get().directoryScoped[directoryKey];
|
|
|
|
|
if (snapshot?.providers.length && snapshot.agents.length) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const scopedDirectory = fromDirectoryKey(directoryKey);
|
|
|
|
|
if (scopedDirectory) {
|
|
|
|
|
queuedDirectories.push(scopedDirectory);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const directory of queuedDirectories) {
|
|
|
|
|
await sleep(PROJECT_CONFIG_PREWARM_DELAY_MS);
|
|
|
|
|
if (!get().isConnected) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const directoryKey = toConfigDirectoryKey(directory);
|
|
|
|
|
const snapshot = get().directoryScoped[directoryKey];
|
|
|
|
|
const tasks: Promise<unknown>[] = [];
|
|
|
|
|
if (!snapshot?.providers.length) {
|
|
|
|
|
tasks.push(get().loadProviders({ directory, source: 'projectConfigPrewarm' }));
|
|
|
|
|
}
|
|
|
|
|
if (!snapshot?.agents.length) {
|
|
|
|
|
tasks.push(get().loadAgents({ directory, source: 'projectConfigPrewarm' }));
|
|
|
|
|
}
|
|
|
|
|
if (tasks.length > 0) {
|
|
|
|
|
await Promise.allSettled(tasks);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
getCurrentProvider: () => {
|
|
|
|
|
const { providers, currentProviderId } = get();
|
|
|
|
|
return providers.find((p) => p.id === currentProviderId);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getCurrentModel: () => {
|
|
|
|
|
const provider = get().getCurrentProvider();
|
|
|
|
|
const { currentModelId } = get();
|
|
|
|
|
if (!provider) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
return provider.models.find((model) => model.id === currentModelId);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
getCurrentAgent: () => {
|
|
|
|
|
const { agents, currentAgentName } = get();
|
|
|
|
|
if (!currentAgentName) return undefined;
|
|
|
|
|
return agents.find((a) => a.name === currentAgentName);
|
|
|
|
|
},
|
|
|
|
|
getModelMetadata: (providerId: string, modelId: string) => {
|
|
|
|
|
const key = buildModelMetadataKey(providerId, modelId);
|
|
|
|
|
if (!key) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
2026-03-23 20:02:27 +08:00
|
|
|
const { modelsMetadata, providers } = get();
|
|
|
|
|
const cached = modelsMetadata.get(key);
|
|
|
|
|
if (cached) {
|
|
|
|
|
return cached;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: derive metadata from provider model data (covers custom providers not in models.dev)
|
|
|
|
|
const provider = providers.find((p) => p.id === providerId);
|
|
|
|
|
if (!provider) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
const model = provider.models.find((m) => m.id === modelId);
|
|
|
|
|
if (!model) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
return deriveModelMetadata(providerId, model);
|
2025-12-07 19:32:53 +02:00
|
|
|
},
|
2025-12-18 19:02:42 +02:00
|
|
|
getVisibleAgents: () => {
|
|
|
|
|
const { agents } = get();
|
|
|
|
|
return filterVisibleAgents(agents);
|
|
|
|
|
},
|
2025-12-07 19:32:53 +02:00
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
name: "config-store",
|
2026-06-30 04:47:52 -04:00
|
|
|
storage: createDeferredSafeJSONStorage(),
|
2026-06-15 03:16:34 +03:00
|
|
|
merge: (persistedState, currentState) =>
|
|
|
|
|
hydrateActiveDirectorySnapshot({
|
|
|
|
|
...currentState,
|
|
|
|
|
...(persistedState && typeof persistedState === 'object'
|
|
|
|
|
? (persistedState as Partial<ConfigStore>)
|
|
|
|
|
: {}),
|
|
|
|
|
}),
|
|
|
|
|
// Stale-while-revalidate: persist the last-known provider/agent
|
|
|
|
|
// snapshots so the model/agent pickers paint instantly on cold
|
|
|
|
|
// start. Freshness is guaranteed by the background refresh in
|
|
|
|
|
// initializeApp() / activateDirectory() (which overwrite these on
|
|
|
|
|
// success) and by the provider/agent config-change subscriptions.
|
2026-01-12 21:39:42 +08:00
|
|
|
partialize: (state) => ({
|
|
|
|
|
activeDirectoryKey: state.activeDirectoryKey,
|
2026-06-28 13:05:00 +03:00
|
|
|
directoryScoped: Object.fromEntries(
|
|
|
|
|
Object.entries(state.directoryScoped).map(([directoryKey, snapshot]) => [
|
|
|
|
|
directoryKey,
|
|
|
|
|
{
|
|
|
|
|
...snapshot,
|
|
|
|
|
selectedProviderId: sanitizePersistedSelectedProviderId(snapshot.selectedProviderId),
|
|
|
|
|
},
|
|
|
|
|
]),
|
|
|
|
|
),
|
2026-06-15 03:16:34 +03:00
|
|
|
providers: state.providers,
|
|
|
|
|
agents: state.agents,
|
2026-01-12 21:39:42 +08:00
|
|
|
currentProviderId: state.currentProviderId,
|
|
|
|
|
currentModelId: state.currentModelId,
|
|
|
|
|
currentVariant: state.currentVariant,
|
|
|
|
|
currentAgentName: state.currentAgentName,
|
2026-06-28 13:05:00 +03:00
|
|
|
selectedProviderId: sanitizePersistedSelectedProviderId(state.selectedProviderId),
|
2026-01-12 21:39:42 +08:00
|
|
|
agentModelSelections: state.agentModelSelections,
|
2026-06-15 03:16:34 +03:00
|
|
|
defaultProviders: state.defaultProviders,
|
2026-01-12 21:39:42 +08:00
|
|
|
settingsDefaultModel: state.settingsDefaultModel,
|
|
|
|
|
settingsDefaultVariant: state.settingsDefaultVariant,
|
|
|
|
|
settingsDefaultAgent: state.settingsDefaultAgent,
|
|
|
|
|
settingsAutoCreateWorktree: state.settingsAutoCreateWorktree,
|
2026-01-17 01:09:21 -08:00
|
|
|
settingsGitmojiEnabled: state.settingsGitmojiEnabled,
|
2026-04-23 07:14:14 -06:00
|
|
|
settingsDefaultFileViewerPreview: state.settingsDefaultFileViewerPreview,
|
2026-02-12 19:37:28 -08:00
|
|
|
settingsZenModel: state.settingsZenModel,
|
2026-04-17 16:07:26 +08:00
|
|
|
settingsMessageStreamTransport: state.settingsMessageStreamTransport,
|
2026-02-09 13:55:10 -08:00
|
|
|
speechRate: state.speechRate,
|
|
|
|
|
speechPitch: state.speechPitch,
|
|
|
|
|
speechVolume: state.speechVolume,
|
2026-01-17 01:09:21 -08:00
|
|
|
}),
|
2026-01-12 21:39:42 +08:00
|
|
|
},
|
|
|
|
|
),
|
2025-12-07 19:32:53 +02:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (typeof window !== "undefined") {
|
|
|
|
|
window.__zustand_config_store__ = useConfigStore;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 14:54:02 +03:00
|
|
|
const refreshKnownProviderDirectories = async (source: string): Promise<void> => {
|
|
|
|
|
const state = useConfigStore.getState();
|
|
|
|
|
const directoryKeys = Array.from(new Set([
|
|
|
|
|
state.activeDirectoryKey,
|
|
|
|
|
...Object.keys(state.directoryScoped),
|
|
|
|
|
])).filter((key) => key.length > 0);
|
|
|
|
|
|
|
|
|
|
state.invalidateProviderCache();
|
|
|
|
|
|
|
|
|
|
let nextIndex = 0;
|
|
|
|
|
const workerCount = Math.min(PROVIDER_CONFIG_REFRESH_CONCURRENCY, directoryKeys.length);
|
|
|
|
|
const workers = Array.from({ length: workerCount }, async () => {
|
|
|
|
|
while (nextIndex < directoryKeys.length) {
|
|
|
|
|
const directoryKey = directoryKeys[nextIndex];
|
|
|
|
|
nextIndex += 1;
|
|
|
|
|
await useConfigStore.getState().loadProviders({
|
|
|
|
|
directory: fromDirectoryKey(directoryKey),
|
|
|
|
|
source,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await Promise.all(workers);
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
let unsubscribeConfigStoreChanges: (() => void) | null = null;
|
|
|
|
|
|
|
|
|
|
if (!unsubscribeConfigStoreChanges) {
|
|
|
|
|
unsubscribeConfigStoreChanges = subscribeToConfigChanges(async (event) => {
|
2026-06-05 15:09:24 +03:00
|
|
|
const tasks: Promise<void>[] = [];
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-06-17 11:21:43 +03:00
|
|
|
opencodeClient.clearConfigCache();
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
if (scopeMatches(event, "agents")) {
|
|
|
|
|
const { loadAgents } = useConfigStore.getState();
|
2026-06-05 15:09:24 +03:00
|
|
|
tasks.push(loadAgents({ source: 'configChange:agents' }).then(() => {}));
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (scopeMatches(event, "providers")) {
|
2026-06-08 14:54:02 +03:00
|
|
|
tasks.push(refreshKnownProviderDirectories('configChange:providers'));
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (tasks.length > 0) {
|
|
|
|
|
await Promise.all(tasks);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-01-06 21:31:04 +02:00
|
|
|
|
|
|
|
|
let unsubscribeConfigStoreDirectoryChanges: (() => void) | null = null;
|
|
|
|
|
|
2026-06-17 11:21:43 +03:00
|
|
|
let unsubscribeConfigStoreSyncConfigChanges: (() => void) | null = null;
|
|
|
|
|
|
|
|
|
|
if (!unsubscribeConfigStoreSyncConfigChanges) {
|
|
|
|
|
unsubscribeConfigStoreSyncConfigChanges = subscribeToSyncConfigChanges((directory, config) => {
|
|
|
|
|
useConfigStore.getState().applyOpenCodeConfigDefaults(directory, 'syncConfig', config);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
if (typeof window !== "undefined" && !unsubscribeConfigStoreDirectoryChanges) {
|
|
|
|
|
unsubscribeConfigStoreDirectoryChanges = useDirectoryStore.subscribe((state, prevState) => {
|
|
|
|
|
const nextKey = toDirectoryKey(state.currentDirectory);
|
|
|
|
|
const prevKey = toDirectoryKey(prevState.currentDirectory);
|
|
|
|
|
if (nextKey === prevKey) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 15:09:24 +03:00
|
|
|
markStartupTrace('directoryStore:changed', { previous: prevKey, next: nextKey });
|
2026-01-06 21:31:04 +02:00
|
|
|
void useConfigStore.getState().activateDirectory(state.currentDirectory);
|
|
|
|
|
});
|
|
|
|
|
}
|