From 077a766f94337e0cc80b66e809d5ba3a60663ae5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 17 Jun 2026 11:21:43 +0300 Subject: [PATCH] perf: make OpenCode config defaults non-blocking Removes startup blocking on OpenCode config defaults Preserves manual and directory-specific model selections Adds regression coverage for config races --- packages/ui/src/lib/opencode/client.test.ts | 74 +++ packages/ui/src/lib/opencode/client.ts | 60 ++- packages/ui/src/stores/useConfigStore.test.ts | 437 +++++++++++++++++- packages/ui/src/stores/useConfigStore.ts | 368 ++++++++++++++- packages/ui/src/sync/bootstrap.ts | 11 +- packages/ui/src/sync/sync-refs.ts | 23 +- 6 files changed, 940 insertions(+), 33 deletions(-) create mode 100644 packages/ui/src/lib/opencode/client.test.ts diff --git a/packages/ui/src/lib/opencode/client.test.ts b/packages/ui/src/lib/opencode/client.test.ts new file mode 100644 index 00000000..0e794aa0 --- /dev/null +++ b/packages/ui/src/lib/opencode/client.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, mock, test } from 'bun:test'; + +type ConfigResponse = { data: Record }; + +(mock as unknown as { restore?: () => void }).restore?.(); + +const configResolvers: Array<(response: ConfigResponse) => void> = []; +let configCalls = 0; + +mock.module('@opencode-ai/sdk/v2', () => ({ + createOpencodeClient: mock(() => ({ + config: { + get: mock(() => { + configCalls += 1; + return new Promise((resolve) => { + configResolvers.push(resolve); + }); + }), + }, + })), +})); + +mock.module('@/contexts/runtimeAPIRegistry', () => ({ + getRegisteredRuntimeAPIs: mock(() => null), +})); + +mock.module('@/lib/runtime-url', () => ({ + getRuntimeUrlResolver: mock(() => ({ + api: (path: string) => path, + })), +})); + +mock.module('@/lib/runtime-switch', () => ({ + getRuntimeApiBaseUrl: mock(() => ''), + getRuntimeKey: mock(() => 'test-runtime'), +})); + +mock.module('@/lib/runtime-fetch', () => ({ + runtimeFetch: mock(async () => new Response(JSON.stringify([]), { + headers: { 'Content-Type': 'application/json' }, + })), +})); + +mock.module('@/lib/startupTrace', () => ({ + markStartupTrace: mock(() => undefined), +})); + +const { opencodeClient } = await import(`./client?cache-test=${Date.now()}`); + +describe('opencodeClient getConfig cache', () => { + test('cleared stale in-flight requests do not repopulate cache or delete newer in-flight requests', async () => { + const first = opencodeClient.getConfig('/workspace/project'); + expect(configCalls).toBe(1); + + opencodeClient.clearConfigCache(); + + const second = opencodeClient.getConfig('/workspace/project'); + expect(configCalls).toBe(2); + + configResolvers[0]?.({ data: { model: 'old/model' } }); + expect(await first).toEqual({ model: 'old/model' }); + + const third = opencodeClient.getConfig('/workspace/project'); + expect(configCalls).toBe(2); + + configResolvers[1]?.({ data: { model: 'new/model' } }); + expect(await second).toEqual({ model: 'new/model' }); + expect(await third).toEqual({ model: 'new/model' }); + + const cached = await opencodeClient.getConfig('/workspace/project'); + expect(cached).toEqual({ model: 'new/model' }); + expect(configCalls).toBe(2); + }); +}); diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 3ba0984d..0a6163c8 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -30,6 +30,7 @@ import { // Use relative path by default (works with both dev and nginx proxy server) // Can be overridden with VITE_OPENCODE_URL for absolute URLs in special deployments const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || "/api"; +const CONFIG_CACHE_TTL_MS = 10_000; /** * Render an SDK error payload into a short string for Error messages. @@ -231,6 +232,9 @@ class OpencodeService { private listDirectoryInFlight: Map> = new Map(); private configProvidersInFlight: Map> = new Map(); private listAgentsInFlight: Map> = new Map(); + private configInFlight: Map> = new Map(); + private configCache: Map = new Map(); + private configCacheGeneration = 0; private listDirectoryCache: Map = new Map(); constructor(baseUrl: string = DEFAULT_BASE_URL) { @@ -256,6 +260,7 @@ class OpencodeService { this.listDirectoryInFlight.clear(); this.configProvidersInFlight.clear(); this.listAgentsInFlight.clear(); + this.clearConfigCache(); this.listDirectoryCache.clear(); } @@ -1232,17 +1237,62 @@ class OpencodeService { } // Configuration - async getConfig(): Promise { - const response = await this.client.config.get(); - if (!response.data) throw new Error('Failed to get config'); - return response.data; + clearConfigCache(): void { + this.configCacheGeneration += 1; + this.configInFlight.clear(); + this.configCache.clear(); + } + + async getConfig(directory?: string | null): Promise { + const effectiveDirectory = this.normalizeCandidatePath(directory) ?? directory ?? this.currentDirectory ?? undefined; + const key = effectiveDirectory ?? ''; + const cached = this.configCache.get(key); + if (cached && cached.expiresAt > Date.now()) { + markStartupTrace('opencodeClient.getConfig:cacheHit', { directory: effectiveDirectory ?? null }); + return cached.config; + } + + const existing = this.configInFlight.get(key); + if (existing) { + markStartupTrace('opencodeClient.getConfig:deduped', { directory: effectiveDirectory ?? null }); + return existing; + } + + const generation = this.configCacheGeneration; + const request = (async () => { + markStartupTrace('opencodeClient.getConfig:start', { directory: effectiveDirectory ?? null }); + const started = typeof performance !== 'undefined' ? performance.now() : Date.now(); + const scopedClient = effectiveDirectory ? this.getScopedApiClient(effectiveDirectory) : this.client; + const response = await scopedClient.config.get(); + if (!response.data) throw new Error('Failed to get config'); + const ended = typeof performance !== 'undefined' ? performance.now() : Date.now(); + markStartupTrace('opencodeClient.getConfig:end', { + directory: effectiveDirectory ?? null, + durationMs: Math.round(ended - started), + }); + if (generation === this.configCacheGeneration) { + this.configCache.set(key, { config: response.data, expiresAt: Date.now() + CONFIG_CACHE_TTL_MS }); + } + return response.data; + })(); + + this.configInFlight.set(key, request); + try { + return await request; + } finally { + if (this.configInFlight.get(key) === request) { + this.configInFlight.delete(key); + } + } } async updateConfig(config: Record): Promise { // IMPORTANT: Do NOT pass directory parameter for config updates // The config should be global, not directory-specific const response = await this.client.config.update({ config: config as Config }); - return unwrapSdkData(response, 'global.config.update'); + const data = unwrapSdkData(response, 'global.config.update'); + this.clearConfigCache(); + return data; } /** diff --git a/packages/ui/src/stores/useConfigStore.test.ts b/packages/ui/src/stores/useConfigStore.test.ts index 7bb5ade3..b424c515 100644 --- a/packages/ui/src/stores/useConfigStore.test.ts +++ b/packages/ui/src/stores/useConfigStore.test.ts @@ -1,14 +1,20 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { Agent } from '@opencode-ai/sdk/v2'; const DIRECTORY = '/workspace/project'; const OTHER_DIRECTORY = '/workspace/other'; const STORAGE_KEY = 'config-store'; +type TestAgent = { name: string; mode?: string; hidden?: boolean; model?: { providerID?: string; modelID?: string }; variant?: string }; let storage = new Map(); let liveProviderId = 'live'; let liveProviderIdsByDirectory = new Map(); let liveProviderVariants: Record> | undefined; let getProvidersCalls = 0; +let getConfigCalls = 0; +let listAgentsCalls = 0; +let liveAgents: TestAgent[] = []; +let listAgentsImpl: ((directory?: string | null) => Promise) | null = null; let withDirectoryCalls: Array = []; let currentFetchDirectory: string | null = DIRECTORY; let configListener: ((event: { scopes: string[]; source?: string; timestamp: number }) => void | Promise) | null = null; @@ -102,6 +108,26 @@ const providerResponse = (id: string, modelId = `${id}-model`, variants?: Record }, }); +const testAgent = (name: string, options?: Partial): Agent => ({ + name, + mode: options?.mode ?? 'primary', + hidden: options?.hidden, + model: options?.model, + variant: options?.variant, + permission: {}, + options: {}, +}) as Agent; + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + mock.module('@/stores/utils/safeStorage', () => ({ getSafeStorage: () => makeStorage(), })); @@ -143,7 +169,16 @@ mock.module('@/lib/opencode/client', () => ({ const id = liveProviderIdsByDirectory.get(directory ?? '') ?? liveProviderId; return { providers: [providerResponse(id, `${id}-model`, liveProviderVariants)], default: { default: id } }; }), - listAgents: mock(async () => []), + listAgents: mock(async (directory?: string | null) => { + listAgentsCalls += 1; + const impl = listAgentsImpl as ((directory?: string | null) => Promise) | null; + return impl ? impl(directory) : liveAgents; + }), + getConfig: mock(async () => { + getConfigCalls += 1; + return {}; + }), + clearConfigCache: mock(() => undefined), }, })); @@ -180,16 +215,26 @@ mock.module('@/lib/configSync', () => ({ })); const { useConfigStore } = await import('./useConfigStore'); +const { emitSyncConfigChanged, setSyncRefs } = await import('@/sync/sync-refs'); describe('useConfigStore provider persistence', () => { beforeEach(() => { storage = new Map(); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: makeStorage(), + }); liveProviderId = 'live'; liveProviderIdsByDirectory = new Map(); liveProviderVariants = undefined; getProvidersCalls = 0; + getConfigCalls = 0; + listAgentsCalls = 0; + liveAgents = []; + listAgentsImpl = null; withDirectoryCalls = []; currentFetchDirectory = DIRECTORY; + setSyncRefs({} as never, { children: new Map(), getState: () => undefined } as never, DIRECTORY); useConfigStore.setState({ activeDirectoryKey: DIRECTORY, directoryScoped: {}, @@ -199,6 +244,12 @@ describe('useConfigStore provider persistence', () => { currentModelId: '', currentVariant: undefined, selectedProviderId: '', + currentAgentName: undefined, + agents: [], + agentModelSelections: {}, + opencodeDefaultAgent: undefined, + opencodeDefaultModel: undefined, + selectionSource: 'auto', isConnected: true, isInitialized: false, }); @@ -328,4 +379,388 @@ describe('useConfigStore provider persistence', () => { expect(state.currentModelId).toBe('live-model'); expect(state.currentVariant).toBe('fast'); }); + + test('loadAgents does not fetch OpenCode config directly', async () => { + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [provider('openai', 'gpt-5.5')], + currentProviderId: 'openai', + currentModelId: 'gpt-5.5', + directoryScoped: { + [DIRECTORY]: { + providers: [provider('openai', 'gpt-5.5')], + agents: [], + currentProviderId: 'openai', + currentModelId: 'gpt-5.5', + currentAgentName: undefined, + selectedProviderId: 'openai', + agentModelSelections: {}, + defaultProviders: {}, + selectionSource: 'auto', + }, + }, + }); + liveAgents = [testAgent('build')]; + + await useConfigStore.getState().loadAgents({ directory: DIRECTORY, source: 'test:noConfigFetch' }); + + expect(listAgentsCalls).toBe(1); + expect(getConfigCalls).toBe(0); + }); + + test('manual selection survives an in-flight loadAgents refresh', async () => { + const pendingAgents = deferred(); + listAgentsImpl = async () => pendingAgents.promise; + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [provider('manual'), provider('default')], + agents: [testAgent('build')], + currentProviderId: 'default', + currentModelId: 'default-model', + currentAgentName: 'build', + selectedProviderId: 'default', + selectionSource: 'auto', + directoryScoped: { + [DIRECTORY]: { + providers: [provider('manual'), provider('default')], + agents: [testAgent('build')], + currentProviderId: 'default', + currentModelId: 'default-model', + currentAgentName: 'build', + selectedProviderId: 'default', + agentModelSelections: {}, + defaultProviders: {}, + selectionSource: 'auto', + }, + }, + }); + + const load = useConfigStore.getState().loadAgents({ directory: DIRECTORY, source: 'test:manualRace' }); + useConfigStore.setState((state) => ({ + currentProviderId: 'manual', + currentModelId: 'manual-model', + currentAgentName: 'manual-agent', + selectedProviderId: 'manual', + selectionSource: 'manual', + directoryScoped: { + ...state.directoryScoped, + [DIRECTORY]: { + ...state.directoryScoped[DIRECTORY], + currentProviderId: 'manual', + currentModelId: 'manual-model', + currentAgentName: 'manual-agent', + selectedProviderId: 'manual', + selectionSource: 'manual', + }, + }, + })); + pendingAgents.resolve([ + testAgent('build', { model: { providerID: 'default', modelID: 'default-model' } }), + testAgent('manual-agent'), + ]); + await load; + + const state = useConfigStore.getState(); + expect(state.currentAgentName).toBe('manual-agent'); + expect(state.currentProviderId).toBe('manual'); + expect(state.currentModelId).toBe('manual-model'); + expect(state.selectionSource).toBe('manual'); + }); + + test('worktree sync config applies to the project-scoped snapshot', () => { + const worktree = '/workspace/project-worktree'; + storage.set('oc.worktreeProjectMap', JSON.stringify({ [worktree]: DIRECTORY })); + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [provider('openai', 'gpt-5.5')], + agents: [testAgent('build'), testAgent('review')], + currentProviderId: 'openai', + currentModelId: 'gpt-5.5', + currentAgentName: 'build', + selectedProviderId: 'openai', + selectionSource: 'auto', + directoryScoped: { + [DIRECTORY]: { + providers: [provider('openai', 'gpt-5.5')], + agents: [testAgent('build'), testAgent('review')], + currentProviderId: 'openai', + currentModelId: 'gpt-5.5', + currentAgentName: 'build', + selectedProviderId: 'openai', + agentModelSelections: {}, + defaultProviders: {}, + selectionSource: 'auto', + }, + }, + }); + + emitSyncConfigChanged(worktree, { default_agent: 'review', model: 'openai/gpt-5.5' }); + + const state = useConfigStore.getState(); + expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultAgent).toBe('review'); + expect(state.directoryScoped[worktree]).toBe(undefined); + expect(state.currentAgentName).toBe('review'); + }); + + test('duplicate sync config event is a no-op when defaults and selection are unchanged', () => { + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [provider('openai', 'gpt-5.5')], + agents: [testAgent('build'), testAgent('review')], + currentProviderId: 'openai', + currentModelId: 'gpt-5.5', + currentAgentName: 'review', + selectedProviderId: 'openai', + opencodeDefaultAgent: 'review', + opencodeDefaultModel: 'openai/gpt-5.5', + selectionSource: 'auto', + directoryScoped: { + [DIRECTORY]: { + providers: [provider('openai', 'gpt-5.5')], + agents: [testAgent('build'), testAgent('review')], + currentProviderId: 'openai', + currentModelId: 'gpt-5.5', + currentAgentName: 'review', + selectedProviderId: 'openai', + agentModelSelections: {}, + defaultProviders: {}, + opencodeDefaultAgent: 'review', + opencodeDefaultModel: 'openai/gpt-5.5', + selectionSource: 'auto', + }, + }, + }); + + let updates = 0; + const unsubscribe = useConfigStore.subscribe(() => { + updates += 1; + }); + emitSyncConfigChanged(DIRECTORY, { default_agent: 'review', model: 'openai/gpt-5.5' }); + unsubscribe(); + + expect(updates).toBe(0); + }); + + test('project loadAgents preserves defaults previously applied from a worktree config event', async () => { + const worktree = '/workspace/project-worktree'; + storage.set('oc.worktreeProjectMap', JSON.stringify({ [worktree]: DIRECTORY })); + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [provider('openai', 'gpt-5.5')], + agents: [testAgent('build'), testAgent('review')], + currentProviderId: 'openai', + currentModelId: 'gpt-5.5', + currentAgentName: 'build', + selectedProviderId: 'openai', + selectionSource: 'auto', + directoryScoped: { + [DIRECTORY]: { + providers: [provider('openai', 'gpt-5.5')], + agents: [testAgent('build'), testAgent('review')], + currentProviderId: 'openai', + currentModelId: 'gpt-5.5', + currentAgentName: 'build', + selectedProviderId: 'openai', + agentModelSelections: {}, + defaultProviders: {}, + selectionSource: 'auto', + }, + }, + }); + liveAgents = [testAgent('build'), testAgent('review')]; + + emitSyncConfigChanged(worktree, { default_agent: 'review', model: 'openai/gpt-5.5' }); + await useConfigStore.getState().loadAgents({ directory: DIRECTORY, source: 'test:preserveWorktreeDefaults' }); + + const state = useConfigStore.getState(); + expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultAgent).toBe('review'); + expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultModel).toBe('openai/gpt-5.5'); + expect(state.opencodeDefaultAgent).toBe('review'); + expect(state.opencodeDefaultModel).toBe('openai/gpt-5.5'); + }); + + test('in-flight loadAgents does not restore defaults cleared by a sync config event', async () => { + const pendingAgents = deferred(); + listAgentsImpl = async () => pendingAgents.promise; + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [provider('openai', 'gpt-5.5')], + agents: [testAgent('build'), testAgent('review')], + currentProviderId: 'openai', + currentModelId: 'gpt-5.5', + currentAgentName: 'review', + selectedProviderId: 'openai', + selectionSource: 'auto', + opencodeDefaultAgent: 'review', + opencodeDefaultModel: 'openai/gpt-5.5', + directoryScoped: { + [DIRECTORY]: { + providers: [provider('openai', 'gpt-5.5')], + agents: [testAgent('build'), testAgent('review')], + currentProviderId: 'openai', + currentModelId: 'gpt-5.5', + currentAgentName: 'review', + selectedProviderId: 'openai', + agentModelSelections: {}, + defaultProviders: {}, + opencodeDefaultAgent: 'review', + opencodeDefaultModel: 'openai/gpt-5.5', + selectionSource: 'auto', + }, + }, + }); + + const load = useConfigStore.getState().loadAgents({ directory: DIRECTORY, source: 'test:staleDefaultsRace' }); + emitSyncConfigChanged(DIRECTORY, {}); + pendingAgents.resolve([testAgent('build'), testAgent('review')]); + await load; + + const state = useConfigStore.getState(); + expect(state.opencodeDefaultAgent).toBe(undefined); + expect(state.opencodeDefaultModel).toBe(undefined); + expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultAgent).toBe(undefined); + expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultModel).toBe(undefined); + }); + + test('in-flight loadAgents does not restore pre-await sync config defaults after a clearing event', async () => { + const pendingAgents = deferred(); + const syncConfigs = new Map>([ + [DIRECTORY, { default_agent: 'review', model: 'openai/gpt-5.5' }], + ]); + setSyncRefs( + {} as never, + { + children: new Map(), + getState: (directory: string) => ({ config: syncConfigs.get(directory) ?? {} }), + } as never, + DIRECTORY, + ); + listAgentsImpl = async () => pendingAgents.promise; + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [provider('openai', 'gpt-5.5')], + agents: [testAgent('build'), testAgent('review')], + currentProviderId: 'openai', + currentModelId: 'gpt-5.5', + currentAgentName: 'review', + selectedProviderId: 'openai', + selectionSource: 'auto', + opencodeDefaultAgent: 'review', + opencodeDefaultModel: 'openai/gpt-5.5', + directoryScoped: { + [DIRECTORY]: { + providers: [provider('openai', 'gpt-5.5')], + agents: [testAgent('build'), testAgent('review')], + currentProviderId: 'openai', + currentModelId: 'gpt-5.5', + currentAgentName: 'review', + selectedProviderId: 'openai', + agentModelSelections: {}, + defaultProviders: {}, + opencodeDefaultAgent: 'review', + opencodeDefaultModel: 'openai/gpt-5.5', + selectionSource: 'auto', + }, + }, + }); + + const load = useConfigStore.getState().loadAgents({ directory: DIRECTORY, source: 'test:preAwaitSyncConfigRace' }); + syncConfigs.set(DIRECTORY, {}); + emitSyncConfigChanged(DIRECTORY, {}); + pendingAgents.resolve([testAgent('build'), testAgent('review')]); + await load; + + const state = useConfigStore.getState(); + expect(state.opencodeDefaultAgent).toBe(undefined); + expect(state.opencodeDefaultModel).toBe(undefined); + expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultAgent).toBe(undefined); + expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultModel).toBe(undefined); + }); + + test('directory activation isolates selection source and OpenCode defaults', async () => { + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + selectionSource: 'manual', + opencodeDefaultAgent: 'active-default', + opencodeDefaultModel: 'active/model', + directoryScoped: { + [DIRECTORY]: { + providers: [provider('active')], + agents: [testAgent('active-agent')], + currentProviderId: 'active', + currentModelId: 'active-model', + currentAgentName: 'active-agent', + selectedProviderId: 'active', + agentModelSelections: {}, + defaultProviders: {}, + opencodeDefaultAgent: 'active-default', + opencodeDefaultModel: 'active/model', + selectionSource: 'manual', + }, + [OTHER_DIRECTORY]: { + providers: [provider('other')], + agents: [testAgent('other-agent')], + currentProviderId: 'other', + currentModelId: 'other-model', + currentAgentName: 'other-agent', + selectedProviderId: 'other', + agentModelSelections: {}, + defaultProviders: {}, + opencodeDefaultAgent: 'other-default', + opencodeDefaultModel: 'other/model', + selectionSource: 'auto', + }, + }, + isConnected: false, + }); + + await useConfigStore.getState().activateDirectory(OTHER_DIRECTORY); + + const state = useConfigStore.getState(); + expect(state.activeDirectoryKey).toBe(OTHER_DIRECTORY); + expect(state.selectionSource).toBe('auto'); + expect(state.opencodeDefaultAgent).toBe('other-default'); + expect(state.opencodeDefaultModel).toBe('other/model'); + }); + + test('sync config without defaults clears stored OpenCode defaults without changing manual selection', () => { + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [provider('manual')], + agents: [testAgent('manual-agent')], + currentProviderId: 'manual', + currentModelId: 'manual-model', + currentAgentName: 'manual-agent', + selectedProviderId: 'manual', + selectionSource: 'manual', + opencodeDefaultAgent: 'old-agent', + opencodeDefaultModel: 'old/model', + directoryScoped: { + [DIRECTORY]: { + providers: [provider('manual')], + agents: [testAgent('manual-agent')], + currentProviderId: 'manual', + currentModelId: 'manual-model', + currentAgentName: 'manual-agent', + selectedProviderId: 'manual', + agentModelSelections: {}, + defaultProviders: {}, + opencodeDefaultAgent: 'old-agent', + opencodeDefaultModel: 'old/model', + selectionSource: 'manual', + }, + }, + }); + + emitSyncConfigChanged(DIRECTORY, {}); + + const state = useConfigStore.getState(); + expect(state.opencodeDefaultAgent).toBe(undefined); + expect(state.opencodeDefaultModel).toBe(undefined); + expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultAgent).toBe(undefined); + expect(state.directoryScoped[DIRECTORY]?.opencodeDefaultModel).toBe(undefined); + expect(state.currentAgentName).toBe('manual-agent'); + expect(state.currentProviderId).toBe('manual'); + expect(state.selectionSource).toBe('manual'); + }); }); diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index cffafd0b..0eae144f 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -1,7 +1,7 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; import { devtools, persist, createJSONStorage } from "zustand/middleware"; -import type { Provider, Agent } from "@opencode-ai/sdk/v2"; +import type { Provider, Agent, Config } from "@opencode-ai/sdk/v2"; import { opencodeClient } from "@/lib/opencode/client"; import { scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; import type { ModelMetadata } from "@/types"; @@ -18,6 +18,7 @@ import { streamDebugEnabled } from "@/stores/utils/streamDebug"; import { parseModelIdentifier } from "@/lib/modelIdentifier"; import { runtimeFetch } from "@/lib/runtime-fetch"; import { markStartupTrace, measureStartupTrace } from "@/lib/startupTrace"; +import { getSyncConfig, subscribeToSyncConfigChanges } from "@/sync/sync-refs"; const MODELS_DEV_API_URL = "https://models.dev/api.json"; const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata"; @@ -824,6 +825,9 @@ interface DirectoryScopedConfig { selectedProviderId: string; agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } }; defaultProviders: { [key: string]: string }; + opencodeDefaultAgent?: string; + opencodeDefaultModel?: string; + selectionSource?: "auto" | "manual"; } /** @@ -851,9 +855,92 @@ const hydrateActiveDirectorySnapshot = >(merged: next.defaultProviders = snapshot.defaultProviders; } } + if (snapshot.opencodeDefaultAgent !== undefined) { + next.opencodeDefaultAgent = snapshot.opencodeDefaultAgent; + } + if (snapshot.opencodeDefaultModel !== undefined) { + next.opencodeDefaultModel = snapshot.opencodeDefaultModel; + } + if (snapshot.selectionSource) { + next.selectionSource = snapshot.selectionSource; + } return next as T; }; +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 } | 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, + }; +}; + interface ConfigStore { activeDirectoryKey: string; @@ -868,6 +955,7 @@ interface ConfigStore { selectedProviderId: string; agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } }; defaultProviders: { [key: string]: string }; + selectionSource: "auto" | "manual"; isConnected: boolean; hasEverConnected: boolean; connectionPhase: "connecting" | "connected" | "reconnecting"; @@ -879,7 +967,7 @@ interface ConfigStore { settingsDefaultVariant: string | undefined; settingsDefaultAgent: string | undefined; // OpenCode server's own `default_agent` config field (name of a primary agent), used as a - // fallback when our own settingsDefaultAgent is unset. Sourced from opencodeClient.getConfig(). + // fallback when our own settingsDefaultAgent is unset. Sourced from sync config. 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. @@ -963,6 +1051,7 @@ interface ConfigStore { getCurrentModelVariants: () => string[]; setAgent: (agentName: string | undefined) => void; applyDefaultModelAgentSelection: () => void; + applyOpenCodeConfigDefaults: (directory?: string | null, source?: string, config?: Config) => void; setSelectedProvider: (providerId: string) => void; setSettingsDefaultModel: (model: string | undefined) => void; setSettingsDefaultVariant: (variant: string | undefined) => void; @@ -1015,6 +1104,7 @@ export const useConfigStore = create()( selectedProviderId: "", agentModelSelections: {}, defaultProviders: {}, + selectionSource: "auto", isConnected: false, hasEverConnected: false, connectionPhase: "connecting", @@ -1296,6 +1386,9 @@ export const useConfigStore = create()( selectedProviderId: snapshot.selectedProviderId, agentModelSelections: snapshot.agentModelSelections, defaultProviders: snapshot.defaultProviders, + opencodeDefaultAgent: snapshot.opencodeDefaultAgent, + opencodeDefaultModel: snapshot.opencodeDefaultModel, + selectionSource: snapshot.selectionSource ?? "auto", }; } @@ -1309,6 +1402,9 @@ export const useConfigStore = create()( selectedProviderId: "", agentModelSelections: {}, defaultProviders: {}, + opencodeDefaultAgent: undefined, + opencodeDefaultModel: undefined, + selectionSource: "auto", }; }); @@ -1638,12 +1734,14 @@ export const useConfigStore = create()( currentProviderId: providerId, currentModelId: newModelId, selectedProviderId: providerId, + selectionSource: "manual", }; return { currentProviderId: providerId, currentModelId: newModelId, selectedProviderId: providerId, + selectionSource: "manual", directoryScoped: { ...state.directoryScoped, [directoryKey]: nextSnapshot, @@ -1670,10 +1768,12 @@ export const useConfigStore = create()( const nextSnapshot: DirectoryScopedConfig = { ...baseSnapshot, currentModelId: modelId, + selectionSource: "manual", }; return { currentModelId: modelId, + selectionSource: "manual", directoryScoped: { ...state.directoryScoped, [directoryKey]: nextSnapshot, @@ -1704,10 +1804,12 @@ export const useConfigStore = create()( const nextSnapshot: DirectoryScopedConfig = { ...baseSnapshot, currentVariant: variant, + selectionSource: "manual", }; return { currentVariant: variant, + selectionSource: "manual", directoryScoped: { ...state.directoryScoped, [directoryKey]: nextSnapshot, @@ -1763,10 +1865,12 @@ export const useConfigStore = create()( const nextSnapshot: DirectoryScopedConfig = { ...baseSnapshot, selectedProviderId: providerId, + selectionSource: "manual", }; return { selectedProviderId: providerId, + selectionSource: "manual", directoryScoped: { ...state.directoryScoped, [directoryKey]: nextSnapshot, @@ -1797,10 +1901,12 @@ export const useConfigStore = create()( const nextSnapshot: DirectoryScopedConfig = { ...baseSnapshot, agentModelSelections: nextSelections, + selectionSource: "manual", }; return { agentModelSelections: nextSelections, + selectionSource: "manual", directoryScoped: { ...state.directoryScoped, [directoryKey]: nextSnapshot, @@ -1844,24 +1950,25 @@ export const useConfigStore = create()( for (let attempt = 0; attempt < 3; attempt++) { try { - // Fetch agents, OpenChamber settings, and the OpenCode config in parallel. - // The OpenCode config is best-effort: a failure should not block agent - // loading, it just means we won't honor its default_agent this round. - const [agents, openChamberDefaults, opencodeConfig] = await Promise.all([ + // 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([ measureStartupTrace( 'loadAgents:api', - () => opencodeClient.listAgents(fromDirectoryKey(directoryKey)), + () => opencodeClient.listAgents(configDirectoryPath), { directoryKey, source, requestedDirectory, effectiveDirectory, attempt: attempt + 1 }, ), fetchOpenChamberDefaults(), - opencodeClient - .withDirectory(fromDirectoryKey(directoryKey), () => opencodeClient.getConfig()) - .catch(() => null), ]); const safeAgents = Array.isArray(agents) ? agents : []; - const opencodeDefaultAgent = normalizeOptionalString(opencodeConfig?.default_agent); - const opencodeDefaultModel = normalizeOptionalString(opencodeConfig?.model); const providerLoad = _inFlightProviders.get(directoryKey); if (providerLoad) { @@ -1869,6 +1976,16 @@ export const useConfigStore = create()( await providerLoad; } + 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; + const providers = get().activeDirectoryKey === directoryKey ? get().providers : (get().directoryScoped[directoryKey]?.providers ?? []); @@ -1902,19 +2019,25 @@ export const useConfigStore = create()( agentModelSelections: {}, defaultProviders: {}, }; + const opencodeDefaultAgent = hasLatestSyncedOpencodeConfig + ? latestSyncedOpencodeDefaultAgent + : baseSnapshot.opencodeDefaultAgent ?? (state.activeDirectoryKey === directoryKey ? state.opencodeDefaultAgent : undefined); + const opencodeDefaultModel = hasLatestSyncedOpencodeConfig + ? latestSyncedOpencodeDefaultModel + : baseSnapshot.opencodeDefaultModel ?? (state.activeDirectoryKey === directoryKey ? state.opencodeDefaultModel : undefined); const nextSnapshot: DirectoryScopedConfig = { ...baseSnapshot, providers, agents: safeAgents, + opencodeDefaultAgent, + opencodeDefaultModel, }; const nextState: Partial = { settingsDefaultModel: openChamberDefaults.defaultModel, settingsDefaultVariant: openChamberDefaults.defaultVariant, settingsDefaultAgent: openChamberDefaults.defaultAgent, - opencodeDefaultAgent, - opencodeDefaultModel, settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false, settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false, settingsDefaultFileViewerPreview: openChamberDefaults.defaultFileViewerPreview ?? false, @@ -1934,11 +2057,20 @@ export const useConfigStore = create()( if (state.activeDirectoryKey === directoryKey) { nextState.agents = safeAgents; + nextState.opencodeDefaultAgent = opencodeDefaultAgent; + nextState.opencodeDefaultModel = opencodeDefaultModel; } return nextState; }); + 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); + const shouldPersistResolvedZenModel = !!resolvedZenModel && resolvedZenModel !== defaultZenModel; @@ -2058,15 +2190,37 @@ export const useConfigStore = create()( agentModelSelections: {}, defaultProviders: {}, }; + 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, + }); const nextSnapshot: DirectoryScopedConfig = { ...baseSnapshot, providers, agents: safeAgents, - currentAgentName: resolvedAgentName, - currentProviderId: resolvedProviderId ?? baseSnapshot.currentProviderId, - currentModelId: resolvedModelId ?? baseSnapshot.currentModelId, - currentVariant: resolvedVariant, + currentAgentName: nextSelection.agentName, + currentProviderId: nextSelection.providerId ?? baseSnapshot.currentProviderId, + currentModelId: nextSelection.modelId ?? baseSnapshot.currentModelId, + currentVariant: nextSelection.variant, + opencodeDefaultAgent, + opencodeDefaultModel, + selectionSource: nextSelection.selectionSource, }; const nextState: Partial = { @@ -2076,13 +2230,16 @@ export const useConfigStore = create()( }, }; - if (state.activeDirectoryKey === directoryKey) { - nextState.currentAgentName = resolvedAgentName; - if (resolvedProviderId && resolvedModelId) { - nextState.currentProviderId = resolvedProviderId; - nextState.currentModelId = resolvedModelId; - nextState.currentVariant = resolvedVariant; + 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; } + nextState.selectionSource = nextSelection.selectionSource; } return nextState; @@ -2210,10 +2367,12 @@ export const useConfigStore = create()( const nextSnapshot: DirectoryScopedConfig = { ...baseSnapshot, currentAgentName: agentName, + selectionSource: "manual", }; return { currentAgentName: agentName, + selectionSource: "manual", directoryScoped: { ...state.directoryScoped, [directoryKey]: nextSnapshot, @@ -2261,6 +2420,7 @@ export const useConfigStore = create()( currentModelId: modelId, currentVariant: variant, selectedProviderId: providerId, + selectionSource: "manual", }; return { @@ -2268,6 +2428,7 @@ export const useConfigStore = create()( currentModelId: modelId, currentVariant: variant, selectedProviderId: providerId, + selectionSource: "manual", directoryScoped: { ...state.directoryScoped, [directoryKey]: nextSnapshot, @@ -2399,10 +2560,12 @@ export const useConfigStore = create()( selectedProviderId: resolvedProviderId, } : {}), + selectionSource: "auto", }; const nextState: Partial = { currentAgentName: resolvedAgentName, + selectionSource: "auto", directoryScoped: { ...state.directoryScoped, [directoryKey]: nextSnapshot, @@ -2420,6 +2583,153 @@ export const useConfigStore = create()( }); }, + 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 = { + 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, + selectedProviderId: nextSelection.providerId, + } + : {}), + 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 + || state.selectedProviderId !== nextSelection.providerId + )) + )); + + 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; + nextState.selectedProviderId = nextSelection.providerId; + } + } + + markStartupTrace('loadAgents:opencodeConfigDefaultsApplied', { directoryKey, eventDirectory, source }); + return nextState; + }); + }, + setSettingsDefaultModel: (model: string | undefined) => { set({ settingsDefaultModel: model }); }, @@ -3020,6 +3330,8 @@ if (!unsubscribeConfigStoreChanges) { unsubscribeConfigStoreChanges = subscribeToConfigChanges(async (event) => { const tasks: Promise[] = []; + opencodeClient.clearConfigCache(); + if (scopeMatches(event, "agents")) { const { loadAgents } = useConfigStore.getState(); tasks.push(loadAgents({ source: 'configChange:agents' }).then(() => {})); @@ -3037,6 +3349,14 @@ if (!unsubscribeConfigStoreChanges) { let unsubscribeConfigStoreDirectoryChanges: (() => void) | null = null; +let unsubscribeConfigStoreSyncConfigChanges: (() => void) | null = null; + +if (!unsubscribeConfigStoreSyncConfigChanges) { + unsubscribeConfigStoreSyncConfigChanges = subscribeToSyncConfigChanges((directory, config) => { + useConfigStore.getState().applyOpenCodeConfigDefaults(directory, 'syncConfig', config); + }); +} + if (typeof window !== "undefined" && !unsubscribeConfigStoreDirectoryChanges) { unsubscribeConfigStoreDirectoryChanges = useDirectoryStore.subscribe((state, prevState) => { const nextKey = toDirectoryKey(state.currentDirectory); diff --git a/packages/ui/src/sync/bootstrap.ts b/packages/ui/src/sync/bootstrap.ts index b82022cf..bfd1a9d7 100644 --- a/packages/ui/src/sync/bootstrap.ts +++ b/packages/ui/src/sync/bootstrap.ts @@ -2,6 +2,7 @@ import type { OpencodeClient, PermissionRequest, Project, QuestionRequest } from import { retry } from "./retry" import type { GlobalState, State } from "./types" import { runtimeFetch } from "../lib/runtime-fetch" +import { emitSyncConfigChanged } from "./sync-refs" const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) @@ -135,7 +136,9 @@ export async function bootstrapDirectory(input: { const seededProject = projectID(directory, g.projects) if (seededProject) set({ project: seededProject }) if (Object.keys(state.config ?? {}).length === 0 && Object.keys(g.config ?? {}).length > 0) { - set({ config: g.config as State["config"] }) + const seededConfig = g.config as State["config"] + set({ config: seededConfig }) + emitSyncConfigChanged(directory, seededConfig) } if (loading) set({ status: "partial" }) @@ -147,7 +150,11 @@ export async function bootstrapDirectory(input: { seededProject ? Promise.resolve() : retry(() => sdk.project.current().then((x) => set({ project: unwrap(x, "project.current").id }))), - retry(() => sdk.config.get().then((x) => set({ config: unwrap(x, "config.get") }))), + retry(() => sdk.config.get().then((x) => { + const config = unwrap(x, "config.get") + set({ config }) + emitSyncConfigChanged(directory, config) + })), retry(() => sdk.path.get().then((x) => { const data = unwrap(x, "path.get") diff --git a/packages/ui/src/sync/sync-refs.ts b/packages/ui/src/sync/sync-refs.ts index 316f2a1a..344c992e 100644 --- a/packages/ui/src/sync/sync-refs.ts +++ b/packages/ui/src/sync/sync-refs.ts @@ -5,7 +5,7 @@ * session-actions) use them to read child-store domain data without hooks. */ -import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { Config, OpencodeClient } from "@opencode-ai/sdk/v2/client" import type { ChildStoreManager } from "./child-store" import { getSessionMaterializationStatus } from "./materialization" import type { State } from "./types" @@ -14,6 +14,7 @@ let _sdk: OpencodeClient | null = null let _childStores: ChildStoreManager | null = null let _directory: string = "" let _registerSessionDirectory: ((sessionID: string, directory: string) => void) | null = null +const configListeners = new Set<(directory: string, config: Config) => void>() export function setSyncRefs( sdk: OpencodeClient, @@ -59,6 +60,26 @@ export function getDirectoryState(directory?: string): State | undefined { return stores.getState(dir) } +/** Read resolved OpenCode config from a directory child store, if bootstrapped. */ +export function getSyncConfig(directory?: string): Config | undefined { + const config = getDirectoryState(directory)?.config + return config && Object.keys(config).length > 0 ? config : undefined +} + +export function subscribeToSyncConfigChanges(listener: (directory: string, config: Config) => void): () => void { + configListeners.add(listener) + return () => { + configListeners.delete(listener) + } +} + +export function emitSyncConfigChanged(directory: string, config: Config): void { + if (!directory) return + for (const listener of configListeners) { + listener(directory, config) + } +} + /** Read sessions from current directory's child store */ export function getSyncSessions(directory?: string) { return getDirectoryState(directory)?.session ?? []