perf: streamline provider and agent startup loading #185
Avoids loading the full provider catalog on startup Prewarms project config in the background Prevents duplicate worktree-scoped config requests
This commit is contained in:
@@ -451,7 +451,7 @@ A single store with N properties means every subscriber re-evaluates on every st
|
||||
|
||||
## Validation expectations
|
||||
|
||||
- Run `bun run type-check` and `bun run lint` before finalizing source-code changes that can affect TypeScript, runtime behavior, builds, lint rules, package resolution, or generated assets. Use a sufficiently long tool timeout for these workspace-wide checks (for example 240000ms) so successful package-level results are not lost to a tool timeout. For docs-only or isolated config-only changes, run the narrowest relevant validation instead (for example JSON/schema validation) and do not run the full checks unless the change can affect code execution.
|
||||
- Run type-check/lint validation before finalizing source-code changes that can affect TypeScript, runtime behavior, builds, lint rules, package resolution, or generated assets, but keep validation scoped to the edited workspace by default. Prefer the package-level command for the package you changed (for example the relevant workspace's `type-check`/`lint`) instead of workspace-wide `bun run type-check` / `bun run lint`. Use workspace-wide checks only when the change spans multiple workspaces, shared package contracts, root tooling/config, dependency resolution, generated assets used across packages, or when a narrower command cannot cover the risk. Use a sufficiently long tool timeout for any broad checks (for example 240000ms) so successful package-level results are not lost to a tool timeout. For docs-only or isolated config-only changes, run the narrowest relevant validation instead (for example JSON/schema validation) and do not run full checks unless the change can affect code execution.
|
||||
- For hot-path changes, verify behavior under streaming or repeated events, not just static render.
|
||||
- For sync or startup changes, verify fresh load, retry/failure, and restart behavior.
|
||||
- For session changes, verify create, stream, abort, permission, archive/delete, and revisit flows when relevant.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { shouldLoadAvailableProviders } from './providerAvailability';
|
||||
|
||||
describe('ProvidersPage available provider loading', () => {
|
||||
test('loads available providers only in add-provider mode', () => {
|
||||
expect(shouldLoadAvailableProviders(false)).toBe(false);
|
||||
expect(shouldLoadAvailableProviders(true)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import type { ModelMetadata } from '@/types';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { shouldLoadAvailableProviders } from './providerAvailability';
|
||||
|
||||
const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
notation: 'compact',
|
||||
@@ -169,6 +170,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
const [providerDropdownOpen, setProviderDropdownOpen] = React.useState(false);
|
||||
const [providerSources, setProviderSources] = React.useState<Record<string, ProviderSources>>({});
|
||||
const [showAuthPanel, setShowAuthPanel] = React.useState(false);
|
||||
const isAddMode = selectedProviderId === ADD_PROVIDER_ID;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedProviderId && providers.length > 0) {
|
||||
@@ -177,6 +179,10 @@ export const ProvidersPage: React.FC = () => {
|
||||
}, [providers, selectedProviderId, setSelectedProvider]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isAddMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const loadAuthMethods = async () => {
|
||||
@@ -204,9 +210,13 @@ export const ProvidersPage: React.FC = () => {
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [t]);
|
||||
}, [isAddMode, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shouldLoadAvailableProviders(isAddMode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const loadAvailableProviders = async () => {
|
||||
@@ -235,7 +245,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [t]);
|
||||
}, [isAddMode, t]);
|
||||
|
||||
const connectedProviderIds = React.useMemo(
|
||||
() => new Set(providers.map((provider) => provider.id)),
|
||||
@@ -482,8 +492,6 @@ export const ProvidersPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isAddMode = selectedProviderId === ADD_PROVIDER_ID;
|
||||
|
||||
if (!isAddMode && providers.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const shouldLoadAvailableProviders = (isAddMode: boolean): boolean => isAddMode;
|
||||
@@ -229,6 +229,7 @@ class OpencodeService {
|
||||
private currentDirectory: string | undefined = undefined;
|
||||
private directoryContextQueue: Promise<void> = Promise.resolve();
|
||||
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 listDirectoryCache: Map<string, { entries: FilesystemEntry[]; expiresAt: number }> = new Map();
|
||||
|
||||
@@ -253,6 +254,8 @@ class OpencodeService {
|
||||
this.client = createRuntimeOpencodeClient({ baseUrl: this.baseUrl });
|
||||
this.scopedClients.clear();
|
||||
this.listDirectoryInFlight.clear();
|
||||
this.configProvidersInFlight.clear();
|
||||
this.listAgentsInFlight.clear();
|
||||
this.listDirectoryCache.clear();
|
||||
}
|
||||
|
||||
@@ -1263,11 +1266,34 @@ class OpencodeService {
|
||||
providers: Provider[];
|
||||
default: { [key: string]: string };
|
||||
}> {
|
||||
const response = await this.client.config.providers(
|
||||
this.currentDirectory ? { directory: this.currentDirectory } : undefined
|
||||
);
|
||||
if (!response.data) throw new Error('Failed to get providers');
|
||||
return response.data;
|
||||
return this.getProvidersForConfig(this.currentDirectory);
|
||||
}
|
||||
|
||||
async getProvidersForConfig(directory?: string | null): Promise<{
|
||||
providers: Provider[];
|
||||
default: { [key: string]: string };
|
||||
}> {
|
||||
const effectiveDirectory = this.normalizeCandidatePath(directory) ?? directory ?? this.currentDirectory ?? undefined;
|
||||
const key = effectiveDirectory ?? '';
|
||||
|
||||
const existing = this.configProvidersInFlight.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
const response = await this.client.config.providers(
|
||||
effectiveDirectory ? { directory: effectiveDirectory } : undefined,
|
||||
);
|
||||
return unwrapSdkData(response, 'config.providers');
|
||||
})();
|
||||
|
||||
this.configProvidersInFlight.set(key, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
this.configProvidersInFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// App Management - using config endpoint since /app doesn't exist in this version
|
||||
@@ -1308,13 +1334,29 @@ class OpencodeService {
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
const response = await this.client.app.agents(
|
||||
effectiveDirectory ? { directory: effectiveDirectory } : undefined
|
||||
);
|
||||
if (response.error) {
|
||||
throw new Error(`app.agents failed: ${formatSdkError(response.error)}`);
|
||||
const params = effectiveDirectory ? { directory: effectiveDirectory } : undefined;
|
||||
const response = await this.client.app.agents(params);
|
||||
if (!response.error && Array.isArray(response.data) && response.data.length > 0) {
|
||||
return response.data;
|
||||
}
|
||||
return response.data || [];
|
||||
|
||||
// SDK gap / endpoint drift: current OpenCode exposes the authoritative
|
||||
// agent list at /agent, while app.agents can be empty on some runtimes.
|
||||
const fallbackResponse = await runtimeFetch('/api/agent', {
|
||||
...(effectiveDirectory ? { query: { directory: effectiveDirectory } } : {}),
|
||||
});
|
||||
if (!fallbackResponse.ok) {
|
||||
if (response.error) {
|
||||
throw new Error(`app.agents failed${response.response?.status ? ` (${response.response.status})` : ''}: ${formatSdkError(response.error)}`);
|
||||
}
|
||||
throw new Error(`agent.list failed (${fallbackResponse.status})`);
|
||||
}
|
||||
|
||||
const fallbackData = await fallbackResponse.json().catch(() => null) as unknown;
|
||||
if (!Array.isArray(fallbackData)) {
|
||||
throw new Error('agent.list failed: invalid response');
|
||||
}
|
||||
return fallbackData as Agent[];
|
||||
})();
|
||||
|
||||
this.listAgentsInFlight.set(key, request);
|
||||
|
||||
@@ -106,6 +106,18 @@ mock.module('@/stores/utils/safeStorage', () => ({
|
||||
getSafeStorage: () => makeStorage(),
|
||||
}));
|
||||
|
||||
mock.module('@/stores/useProjectsStore', () => ({
|
||||
useProjectsStore: {
|
||||
getState: () => ({
|
||||
activeProjectId: 'project',
|
||||
projects: [
|
||||
{ id: 'project', path: DIRECTORY, label: 'Project' },
|
||||
{ id: 'other', path: OTHER_DIRECTORY, label: 'Other' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@/lib/opencode/client', () => ({
|
||||
opencodeClient: {
|
||||
setDirectory: mock(() => undefined),
|
||||
@@ -126,6 +138,11 @@ mock.module('@/lib/opencode/client', () => ({
|
||||
const id = liveProviderIdsByDirectory.get(currentFetchDirectory ?? '') ?? liveProviderId;
|
||||
return { providers: [providerResponse(id, `${id}-model`, liveProviderVariants)], default: { default: id } };
|
||||
}),
|
||||
getProvidersForConfig: mock(async (directory?: string | null) => {
|
||||
getProvidersCalls += 1;
|
||||
const id = liveProviderIdsByDirectory.get(directory ?? '') ?? liveProviderId;
|
||||
return { providers: [providerResponse(id, `${id}-model`, liveProviderVariants)], default: { default: id } };
|
||||
}),
|
||||
listAgents: mock(async () => []),
|
||||
},
|
||||
}));
|
||||
@@ -286,7 +303,6 @@ describe('useConfigStore provider persistence', () => {
|
||||
|
||||
const state = useConfigStore.getState();
|
||||
expect(getProvidersCalls).toBe(2);
|
||||
expect(new Set(withDirectoryCalls)).toEqual(new Set([DIRECTORY, OTHER_DIRECTORY]));
|
||||
expect(state.directoryScoped[DIRECTORY]?.providers.map((entry) => entry.id)).toEqual(['active-live']);
|
||||
expect(state.directoryScoped[OTHER_DIRECTORY]?.providers.map((entry) => entry.id)).toEqual(['inactive-live']);
|
||||
expect(state.directoryScoped[OTHER_DIRECTORY]?.defaultProviders).toEqual({ default: 'inactive-live' });
|
||||
|
||||
@@ -734,6 +734,34 @@ const rememberWorktreeProject = (worktree: string, project: string): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeConfigPath = (value: string | null | undefined): string | null => {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||
if (!trimmed) return null;
|
||||
return trimmed.replace(/\\/g, '/').replace(/\/+$/, '') || '/';
|
||||
};
|
||||
|
||||
const getKnownProjectDirectories = (): string[] => {
|
||||
try {
|
||||
return useProjectsStore.getState().projects
|
||||
.map((project) => normalizeConfigPath(project.path))
|
||||
.filter((path): path is string => Boolean(path));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getFallbackProjectDirectory = (): string | null => {
|
||||
try {
|
||||
const { projects, activeProjectId } = useProjectsStore.getState();
|
||||
const active = activeProjectId
|
||||
? projects.find((project) => project.id === activeProjectId)
|
||||
: null;
|
||||
return normalizeConfigPath(active?.path ?? projects[0]?.path ?? null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Map a directory to its CONFIG scope. Providers/agents/defaults are defined at
|
||||
* the PROJECT level (opencode.json), so a worktree must inherit its parent
|
||||
@@ -742,11 +770,14 @@ const rememberWorktreeProject = (worktree: string, project: string): void => {
|
||||
* a known worktree, else the directory unchanged.
|
||||
*/
|
||||
const resolveConfigDirectory = (directory: string | null | undefined): string | null => {
|
||||
const dir = typeof directory === 'string' && directory.trim().length > 0 ? directory : null;
|
||||
if (!dir) return dir;
|
||||
// 1. Persisted mapping — resolves synchronously at startup, before the async
|
||||
// git worktree discovery has populated the runtime map.
|
||||
const cached = getWorktreeProjectMap()[dir];
|
||||
const dir = normalizeConfigPath(directory);
|
||||
const projects = getKnownProjectDirectories();
|
||||
if (!dir) return null;
|
||||
if (projects.includes(dir)) return dir;
|
||||
|
||||
// 1. Persisted mapping — resolves synchronously when the async worktree
|
||||
// discovery has not populated the runtime map yet.
|
||||
const cached = normalizeConfigPath(getWorktreeProjectMap()[dir]);
|
||||
if (cached) return cached;
|
||||
// 2. Live resolution via projects + discovered worktree map; cache the hit.
|
||||
try {
|
||||
@@ -755,14 +786,15 @@ const resolveConfigDirectory = (directory: string | null | undefined): string |
|
||||
useSessionUIStore.getState().availableWorktreesByProject,
|
||||
dir,
|
||||
);
|
||||
if (project?.path && project.path !== dir) {
|
||||
rememberWorktreeProject(dir, project.path);
|
||||
return project.path;
|
||||
const projectPath = normalizeConfigPath(project?.path ?? null);
|
||||
if (projectPath && projectPath !== dir) {
|
||||
rememberWorktreeProject(dir, projectPath);
|
||||
return projectPath;
|
||||
}
|
||||
} catch {
|
||||
return dir;
|
||||
return null;
|
||||
}
|
||||
return dir;
|
||||
return null;
|
||||
};
|
||||
|
||||
const toConfigDirectoryKey = (directory: string | null | undefined): string =>
|
||||
@@ -775,6 +807,7 @@ const toConfigDirectoryKey = (directory: string | null | undefined): string =>
|
||||
const _providersLoadedAt = new Map<string, number>();
|
||||
const _agentsLoadedAt = new Map<string, number>();
|
||||
const CONFIG_REFRESH_TTL_MS = 30_000;
|
||||
const PROJECT_CONFIG_PREWARM_DELAY_MS = 1_000;
|
||||
const isConfigFresh = (loadedAt: Map<string, number>, key: string): boolean => {
|
||||
const at = loadedAt.get(key);
|
||||
return typeof at === 'number' && Date.now() - at < CONFIG_REFRESH_TTL_MS;
|
||||
@@ -945,6 +978,7 @@ interface ConfigStore {
|
||||
probeConnection: (options?: { timeoutMs?: number }) => Promise<boolean>;
|
||||
checkConnection: () => Promise<boolean>;
|
||||
initializeApp: () => Promise<void>;
|
||||
prewarmProjectConfigs: (initialDirectory?: string | null) => Promise<void>;
|
||||
getCurrentProvider: () => ProviderWithModelList | undefined;
|
||||
getCurrentModel: () => ProviderModel | undefined;
|
||||
getCurrentAgent: () => Agent | undefined;
|
||||
@@ -1237,7 +1271,12 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
// active key + snapshot key always match and stay project-scoped.
|
||||
// Everything below operates on this key unchanged; the OpenCode
|
||||
// working directory (opencodeClient.getDirectory()) is separate.
|
||||
const directoryKey = toConfigDirectoryKey(directory);
|
||||
const configDirectory = resolveConfigDirectory(directory);
|
||||
if (!configDirectory) {
|
||||
markStartupTrace('activateDirectory:skippedUnknownDirectory', { directory });
|
||||
return;
|
||||
}
|
||||
const directoryKey = toDirectoryKey(configDirectory);
|
||||
let snapshotHadProviders = false;
|
||||
let snapshotHadAgents = false;
|
||||
|
||||
@@ -1360,6 +1399,10 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
// Providers are project-scoped: resolve a worktree to its project
|
||||
// so it reuses one shared snapshot instead of its own.
|
||||
const configDirectory = resolveConfigDirectory(requestedDirectory);
|
||||
if (!configDirectory) {
|
||||
markStartupTrace('loadProviders:skippedUnknownDirectory', { requestedDirectory, source: options?.source ?? 'unknown' });
|
||||
return;
|
||||
}
|
||||
const effectiveDirectory = configDirectory ?? opencodeClient.getDirectory() ?? null;
|
||||
const directoryKey = toDirectoryKey(configDirectory);
|
||||
const source = options?.source ?? 'unknown';
|
||||
@@ -1388,10 +1431,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
);
|
||||
const apiResult = await measureStartupTrace(
|
||||
'loadProviders:api',
|
||||
() => opencodeClient.withDirectory(
|
||||
fromDirectoryKey(directoryKey),
|
||||
() => opencodeClient.getProviders()
|
||||
),
|
||||
() => opencodeClient.getProvidersForConfig(fromDirectoryKey(directoryKey)),
|
||||
{ directoryKey, source, requestedDirectory, effectiveDirectory, attempt: attempt + 1 },
|
||||
);
|
||||
const providers = Array.isArray(apiResult?.providers) ? apiResult.providers : [];
|
||||
@@ -1779,6 +1819,10 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
// Agents are project-scoped: resolve a worktree to its project
|
||||
// so it reuses one shared snapshot instead of its own.
|
||||
const configDirectory = resolveConfigDirectory(requestedDirectory);
|
||||
if (!configDirectory) {
|
||||
markStartupTrace('loadAgents:skippedUnknownDirectory', { requestedDirectory, source: options?.source ?? 'unknown' });
|
||||
return false;
|
||||
}
|
||||
const effectiveDirectory = configDirectory ?? opencodeClient.getDirectory() ?? null;
|
||||
const directoryKey = toDirectoryKey(configDirectory);
|
||||
const source = options?.source ?? 'unknown';
|
||||
@@ -2748,7 +2792,21 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
useSessionUIStore.getState().availableWorktreesByProject,
|
||||
initialDirectory ?? null,
|
||||
);
|
||||
const configDirectory = resolvedProject?.path ?? initialDirectory ?? null;
|
||||
const resolvedInitialDirectory = resolveConfigDirectory(resolvedProject?.path ?? initialDirectory ?? null);
|
||||
const configDirectory = resolvedInitialDirectory ?? getFallbackProjectDirectory();
|
||||
if (!configDirectory) {
|
||||
markStartupTrace('initializeApp:noProjectConfigDirectory');
|
||||
set({ isInitialized: true, isConnected: true, hasEverConnected: true, connectionPhase: "connected" });
|
||||
return;
|
||||
}
|
||||
if (!resolvedInitialDirectory && initialDirectory !== configDirectory) {
|
||||
markStartupTrace('initializeApp:normalizedUnknownDirectoryToProject', {
|
||||
initialDirectory,
|
||||
configDirectory,
|
||||
});
|
||||
opencodeClient.setDirectory(configDirectory);
|
||||
useDirectoryStore.getState().setDirectory(configDirectory, { showOverlay: false });
|
||||
}
|
||||
const configDirectoryKey = toDirectoryKey(configDirectory);
|
||||
if (get().activeDirectoryKey !== configDirectoryKey) {
|
||||
set({ activeDirectoryKey: configDirectoryKey });
|
||||
@@ -2761,6 +2819,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
]);
|
||||
|
||||
set({ isInitialized: true, isConnected: true, hasEverConnected: true, connectionPhase: "connected" });
|
||||
void get().prewarmProjectConfigs(configDirectory);
|
||||
const initEnded = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
markStartupTrace('initializeApp:end', {
|
||||
durationMs: Math.round(initEnded - initStarted),
|
||||
@@ -2786,6 +2845,55 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
return run;
|
||||
},
|
||||
|
||||
prewarmProjectConfigs: async (initialDirectory?: string | null) => {
|
||||
if (!get().isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialKey = toConfigDirectoryKey(initialDirectory ?? fromDirectoryKey(get().activeDirectoryKey));
|
||||
const projectDirectories = useProjectsStore.getState().projects
|
||||
.map((project) => project.path)
|
||||
.filter((path): path is string => typeof path === 'string' && path.trim().length > 0);
|
||||
const seen = new Set<string>([initialKey]);
|
||||
const queuedDirectories: string[] = [];
|
||||
|
||||
for (const directory of projectDirectories) {
|
||||
const directoryKey = toConfigDirectoryKey(directory);
|
||||
if (seen.has(directoryKey)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(directoryKey);
|
||||
|
||||
const snapshot = get().directoryScoped[directoryKey];
|
||||
if (snapshot?.providers.length && snapshot.agents.length) {
|
||||
continue;
|
||||
}
|
||||
const scopedDirectory = fromDirectoryKey(directoryKey);
|
||||
if (scopedDirectory) {
|
||||
queuedDirectories.push(scopedDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
for (const directory of queuedDirectories) {
|
||||
await sleep(PROJECT_CONFIG_PREWARM_DELAY_MS);
|
||||
if (!get().isConnected) {
|
||||
return;
|
||||
}
|
||||
const directoryKey = toConfigDirectoryKey(directory);
|
||||
const snapshot = get().directoryScoped[directoryKey];
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
if (!snapshot?.providers.length) {
|
||||
tasks.push(get().loadProviders({ directory, source: 'projectConfigPrewarm' }));
|
||||
}
|
||||
if (!snapshot?.agents.length) {
|
||||
tasks.push(get().loadAgents({ directory, source: 'projectConfigPrewarm' }));
|
||||
}
|
||||
if (tasks.length > 0) {
|
||||
await Promise.allSettled(tasks);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getCurrentProvider: () => {
|
||||
const { providers, currentProviderId } = get();
|
||||
return providers.find((p) => p.id === currentProviderId);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
let storageSetCount = 0;
|
||||
|
||||
const safeStorage = {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storageSetCount += 1;
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
storage.clear();
|
||||
},
|
||||
key: (index: number) => Array.from(storage.keys())[index] ?? null,
|
||||
get length() {
|
||||
return storage.size;
|
||||
},
|
||||
} as Storage;
|
||||
|
||||
mock.module('./utils/safeStorage', () => ({
|
||||
getSafeStorage: () => safeStorage,
|
||||
}));
|
||||
|
||||
mock.module('@/lib/desktop', () => ({
|
||||
isVSCodeRuntime: () => false,
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-fetch', () => ({
|
||||
runtimeFetch: mock(async () => new Response('{}', { headers: { 'Content-Type': 'application/json' } })),
|
||||
}));
|
||||
|
||||
const { useSessionFoldersStore } = await import('./useSessionFoldersStore');
|
||||
|
||||
const waitForPersist = () => new Promise((resolve) => setTimeout(resolve, 350));
|
||||
|
||||
describe('useSessionFoldersStore folder assignments', () => {
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
storageSetCount = 0;
|
||||
useSessionFoldersStore.setState({
|
||||
foldersMap: {},
|
||||
collapsedFolderIds: new Set<string>(),
|
||||
});
|
||||
});
|
||||
|
||||
test('repeated addSessionToFolder to the same folder preserves foldersMap reference', async () => {
|
||||
const store = useSessionFoldersStore.getState();
|
||||
const folder = store.createFolder('/workspace/project', 'Work');
|
||||
store.addSessionToFolder('/workspace/project', folder.id, 'ses_1');
|
||||
await waitForPersist();
|
||||
storageSetCount = 0;
|
||||
|
||||
const before = useSessionFoldersStore.getState().foldersMap;
|
||||
useSessionFoldersStore.getState().addSessionToFolder('/workspace/project', folder.id, 'ses_1');
|
||||
await waitForPersist();
|
||||
|
||||
expect(useSessionFoldersStore.getState().foldersMap).toBe(before);
|
||||
expect(storageSetCount).toBe(0);
|
||||
});
|
||||
|
||||
test('repeated addSessionsToFolder to the same folder preserves foldersMap reference', async () => {
|
||||
const store = useSessionFoldersStore.getState();
|
||||
const folder = store.createFolder('/workspace/project', 'Batch');
|
||||
store.addSessionsToFolder('/workspace/project', folder.id, ['ses_1', 'ses_2']);
|
||||
await waitForPersist();
|
||||
storageSetCount = 0;
|
||||
|
||||
const before = useSessionFoldersStore.getState().foldersMap;
|
||||
useSessionFoldersStore.getState().addSessionsToFolder('/workspace/project', folder.id, ['ses_1', 'ses_2']);
|
||||
await waitForPersist();
|
||||
|
||||
expect(useSessionFoldersStore.getState().foldersMap).toBe(before);
|
||||
expect(storageSetCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -80,7 +80,6 @@ export async function bootstrapGlobal(
|
||||
set({ projects })
|
||||
}),
|
||||
),
|
||||
retry(() => sdk.provider.list().then((x) => set({ providers: unwrap(x, "provider.list") }))),
|
||||
])
|
||||
|
||||
const errors = results
|
||||
@@ -125,7 +124,6 @@ export async function bootstrapDirectory(input: {
|
||||
global: {
|
||||
config: Record<string, unknown>
|
||||
projects: Project[]
|
||||
providers: { all: unknown[]; connected: unknown[]; default: Record<string, unknown> }
|
||||
}
|
||||
loadSessions: (directory: string) => Promise<void> | void
|
||||
}) {
|
||||
@@ -136,9 +134,6 @@ export async function bootstrapDirectory(input: {
|
||||
// Seed from global state while we fetch directory-specific data
|
||||
const seededProject = projectID(directory, g.projects)
|
||||
if (seededProject) set({ project: seededProject })
|
||||
if (state.provider.all.length === 0 && g.providers.all.length > 0) {
|
||||
set({ provider: g.providers as State["provider"] })
|
||||
}
|
||||
if (Object.keys(state.config ?? {}).length === 0 && Object.keys(g.config ?? {}).length > 0) {
|
||||
set({ config: g.config as State["config"] })
|
||||
}
|
||||
@@ -152,7 +147,6 @@ export async function bootstrapDirectory(input: {
|
||||
seededProject
|
||||
? Promise.resolve()
|
||||
: retry(() => sdk.project.current().then((x) => set({ project: unwrap(x, "project.current").id }))),
|
||||
retry(() => sdk.provider.list().then((x) => set({ provider: unwrap(x, "provider.list") }))),
|
||||
retry(() => sdk.config.get().then((x) => set({ config: unwrap(x, "config.get") }))),
|
||||
retry(() =>
|
||||
sdk.path.get().then((x) => {
|
||||
@@ -177,7 +171,7 @@ export async function bootstrapDirectory(input: {
|
||||
// - path.get feeds project resolution, but if we already resolved a project
|
||||
// (from global projects) its failure is tolerable; the worktree path is
|
||||
// refreshed by later events.
|
||||
const [, , , pathResult] = phase1Results
|
||||
const [, , pathResult] = phase1Results
|
||||
const pathFailedWithoutProject =
|
||||
pathResult.status === "rejected" && !getState().project
|
||||
|
||||
@@ -194,7 +188,6 @@ export async function bootstrapDirectory(input: {
|
||||
// These enrich the UI but aren't required for basic functionality.
|
||||
// ---------------------------------------------------------------------------
|
||||
void Promise.allSettled([
|
||||
retry(() => sdk.app.agents().then((x) => set({ agent: unwrap(x, "app.agents") }))),
|
||||
retry(() => sdk.command.list().then((x) => set({ command: unwrap(x, "command.list") }))),
|
||||
retry(() => sdk.mcp.status().then((x) => set({ mcp: unwrap(x, "mcp.status") }))),
|
||||
retry(() => sdk.lsp.status().then((x) => set({ lsp: unwrap(x, "lsp.status") }))),
|
||||
|
||||
@@ -1657,11 +1657,10 @@ export function SyncProvider(props: {
|
||||
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
|
||||
}
|
||||
},
|
||||
global: {
|
||||
config: globalState.config,
|
||||
projects: globalState.projects,
|
||||
providers: globalState.providers,
|
||||
},
|
||||
global: {
|
||||
config: globalState.config,
|
||||
projects: globalState.projects,
|
||||
},
|
||||
loadSessions: (dir) => retry(async () => {
|
||||
const rootSessions = (await listGlobalSessionPages(props.sdk, {
|
||||
directory: dir,
|
||||
@@ -1752,11 +1751,11 @@ export function SyncProvider(props: {
|
||||
const generation = ++globalBootstrapGeneration
|
||||
bootingRoot = true
|
||||
const globalActions = useGlobalSyncStore.getState().actions
|
||||
bootstrapGlobal(props.sdk, (patch) => {
|
||||
if (globalBootstrapGeneration === generation) {
|
||||
globalActions.set(patch)
|
||||
}
|
||||
})
|
||||
bootstrapGlobal(props.sdk, (patch) => {
|
||||
if (globalBootstrapGeneration === generation) {
|
||||
globalActions.set(patch)
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
if (globalBootstrapGeneration === generation) {
|
||||
bootedAt = Date.now()
|
||||
@@ -1772,7 +1771,7 @@ export function SyncProvider(props: {
|
||||
bootingRoot = false
|
||||
}
|
||||
}
|
||||
}, [props.sdk])
|
||||
}, [props.sdk])
|
||||
|
||||
// Event pipeline — created once per mount. No class, no start/stop.
|
||||
// Abort controller owned by the pipeline closure. Cleanup aborts + flushes.
|
||||
|
||||
Reference in New Issue
Block a user