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
This commit is contained in:
Bohdan Triapitsyn
2026-06-17 11:21:43 +03:00
parent 6b11211968
commit 077a766f94
6 changed files with 940 additions and 33 deletions
@@ -0,0 +1,74 @@
import { describe, expect, mock, test } from 'bun:test';
type ConfigResponse = { data: Record<string, unknown> };
(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<ConfigResponse>((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);
});
});
+55 -5
View File
@@ -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<string, Promise<FilesystemEntry[]>> = new Map();
private configProvidersInFlight: Map<string, Promise<{ providers: Provider[]; default: { [key: string]: string } }>> = new Map();
private listAgentsInFlight: Map<string, Promise<Agent[]>> = new Map();
private configInFlight: Map<string, Promise<Config>> = new Map();
private configCache: Map<string, { config: Config; expiresAt: number }> = new Map();
private configCacheGeneration = 0;
private listDirectoryCache: Map<string, { entries: FilesystemEntry[]; expiresAt: number }> = 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<Config> {
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<Config> {
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<string, unknown>): Promise<Config> {
// 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;
}
/**
+436 -1
View File
@@ -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<string, string>();
let liveProviderId = 'live';
let liveProviderIdsByDirectory = new Map<string, string>();
let liveProviderVariants: Record<string, Record<string, unknown>> | undefined;
let getProvidersCalls = 0;
let getConfigCalls = 0;
let listAgentsCalls = 0;
let liveAgents: TestAgent[] = [];
let listAgentsImpl: ((directory?: string | null) => Promise<TestAgent[]>) | null = null;
let withDirectoryCalls: Array<string | null> = [];
let currentFetchDirectory: string | null = DIRECTORY;
let configListener: ((event: { scopes: string[]; source?: string; timestamp: number }) => void | Promise<void>) | null = null;
@@ -102,6 +108,26 @@ const providerResponse = (id: string, modelId = `${id}-model`, variants?: Record
},
});
const testAgent = (name: string, options?: Partial<TestAgent>): Agent => ({
name,
mode: options?.mode ?? 'primary',
hidden: options?.hidden,
model: options?.model,
variant: options?.variant,
permission: {},
options: {},
}) as Agent;
const deferred = <T,>() => {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((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<TestAgent[]>) | 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<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: makeStorage(),
});
liveProviderId = 'live';
liveProviderIdsByDirectory = new Map<string, string>();
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<TestAgent[]>();
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<TestAgent[]>();
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<TestAgent[]>();
const syncConfigs = new Map<string, Record<string, unknown>>([
[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');
});
});
+344 -24
View File
@@ -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 = <T extends Partial<ConfigStore>>(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<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,
};
};
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<ConfigStore>()(
selectedProviderId: "",
agentModelSelections: {},
defaultProviders: {},
selectionSource: "auto",
isConnected: false,
hasEverConnected: false,
connectionPhase: "connecting",
@@ -1296,6 +1386,9 @@ export const useConfigStore = create<ConfigStore>()(
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<ConfigStore>()(
selectedProviderId: "",
agentModelSelections: {},
defaultProviders: {},
opencodeDefaultAgent: undefined,
opencodeDefaultModel: undefined,
selectionSource: "auto",
};
});
@@ -1638,12 +1734,14 @@ export const useConfigStore = create<ConfigStore>()(
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<ConfigStore>()(
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<ConfigStore>()(
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<ConfigStore>()(
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<ConfigStore>()(
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<ConfigStore>()(
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<ConfigStore>()(
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<ConfigStore>()(
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<ConfigStore> = {
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<ConfigStore>()(
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<ConfigStore>()(
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<ConfigStore> = {
@@ -2076,13 +2230,16 @@ export const useConfigStore = create<ConfigStore>()(
},
};
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<ConfigStore>()(
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<ConfigStore>()(
currentModelId: modelId,
currentVariant: variant,
selectedProviderId: providerId,
selectionSource: "manual",
};
return {
@@ -2268,6 +2428,7 @@ export const useConfigStore = create<ConfigStore>()(
currentModelId: modelId,
currentVariant: variant,
selectedProviderId: providerId,
selectionSource: "manual",
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
@@ -2399,10 +2560,12 @@ export const useConfigStore = create<ConfigStore>()(
selectedProviderId: resolvedProviderId,
}
: {}),
selectionSource: "auto",
};
const nextState: Partial<ConfigStore> = {
currentAgentName: resolvedAgentName,
selectionSource: "auto",
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
@@ -2420,6 +2583,153 @@ export const useConfigStore = create<ConfigStore>()(
});
},
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,
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<void>[] = [];
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);
+9 -2
View File
@@ -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")
+22 -1
View File
@@ -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 ?? []