refactor(settings): scope the settings project selector to settings

Picking a project in Settings called setActiveProject, which relocates
the chat, the session list, the file tree and the Git surface. Reading
another project's MCP servers or agents moved the user's whole app.

It had to, because the configuration stores resolved the directory
themselves from the active project and held one flat list. Each of them
now takes an explicit directory — omitted still means the active project,
so every caller outside Settings is unchanged — and keys loaded data by
directory next to a flat mirror of the active project. Chat, autocompletes
and pickers keep reading that mirror; a load for another directory writes
only the map. A failed load restores that directory's previous list.

Settings resolves its own directory through useSettingsDirectory, backed
by a session-local settingsProjectPath that follows the active project
until the user picks something else.
This commit is contained in:
Bohdan Triapitsyn
2026-08-22 20:31:31 +03:00
parent ac0b17c9fd
commit 0079347edc
25 changed files with 703 additions and 250 deletions
+71 -27
View File
@@ -19,6 +19,19 @@ type McpMutationResult = {
restartDeferred?: boolean;
};
/**
* Directory a call operates on. Settings can browse another project without
* moving the app, so every entry point takes one; omitting it means the
* project the app is currently on.
*/
const resolveDirectory = (directory?: string | null): string | null => {
if (directory !== undefined) {
const trimmed = directory?.trim();
return trimmed ? trimmed : null;
}
return getConfigDirectory();
};
const getConfigDirectory = (): string | null => {
try {
const projectsStore = useProjectsStore.getState();
@@ -115,29 +128,48 @@ const getMcpCacheKey = (directory: string | null): string => {
// ============== STORE ==============
interface McpConfigStore {
/** Servers of the project the app is on. Chat and mobile read this one. */
mcpServers: McpServerWithScope[];
/** Every directory loaded so far, including the ambient one. */
serversByDirectory: Record<string, McpServerWithScope[]>;
selectedMcpName: string | null;
isLoading: boolean;
mcpDraft: McpDraft | null;
setSelectedMcp: (name: string | null) => void;
setMcpDraft: (draft: McpDraft | null) => void;
loadMcpConfigs: (options?: { force?: boolean }) => Promise<boolean>;
createMcp: (config: McpDraft) => Promise<McpMutationResult>;
updateMcp: (name: string, config: Partial<McpDraft>) => Promise<McpMutationResult>;
deleteMcp: (name: string) => Promise<McpMutationResult>;
getMcpByName: (name: string) => McpServerWithScope | undefined;
loadMcpConfigs: (options?: { force?: boolean; directory?: string | null }) => Promise<boolean>;
createMcp: (config: McpDraft, directory?: string | null) => Promise<McpMutationResult>;
updateMcp: (name: string, config: Partial<McpDraft>, directory?: string | null) => Promise<McpMutationResult>;
deleteMcp: (name: string, directory?: string | null) => Promise<McpMutationResult>;
getMcpByName: (name: string, directory?: string | null) => McpServerWithScope | undefined;
getMcpServersForDirectory: (directory?: string | null) => McpServerWithScope[];
}
const invalidateMcpCache = (directory: string | null) => {
mcpLastLoadedAt.delete(getMcpCacheKey(directory));
};
const EMPTY_MCP_SERVERS: McpServerWithScope[] = [];
/**
* Servers of one project. Returns a stored array so components can select it
* directly; an omitted directory means the project the app is on.
*/
export const selectMcpServersForDirectory = (
state: Pick<McpConfigStore, 'serversByDirectory'>,
directory?: string | null,
): McpServerWithScope[] => {
const cacheKey = getMcpCacheKey(resolveDirectory(directory));
return state.serversByDirectory[cacheKey] ?? EMPTY_MCP_SERVERS;
};
export const useMcpConfigStore = create<McpConfigStore>()(
devtools(
persist(
(set, get) => ({
mcpServers: [],
serversByDirectory: {},
selectedMcpName: null,
isLoading: false,
mcpDraft: null,
@@ -147,11 +179,12 @@ export const useMcpConfigStore = create<McpConfigStore>()(
setMcpDraft: (draft) => set({ mcpDraft: draft }),
loadMcpConfigs: async (options) => {
const configDirectory = getConfigDirectory();
const configDirectory = resolveDirectory(options?.directory);
const cacheKey = getMcpCacheKey(configDirectory);
const isAmbient = cacheKey === getMcpCacheKey(getConfigDirectory());
const now = Date.now();
const loadedAt = mcpLastLoadedAt.get(cacheKey) ?? 0;
const hasCachedConfigs = get().mcpServers.length > 0;
const hasCachedConfigs = (get().serversByDirectory[cacheKey] ?? (isAmbient ? get().mcpServers : [])).length > 0;
if (!options?.force && hasCachedConfigs && now - loadedAt < MCP_LOAD_CACHE_TTL_MS) {
return true;
@@ -173,7 +206,14 @@ export const useMcpConfigStore = create<McpConfigStore>()(
throw new Error('Failed to load MCP configs');
}
const data: McpServerWithScope[] = await response.json();
set({ mcpServers: data, isLoading: false });
set((state) => {
const next: Partial<McpConfigStore> = {
serversByDirectory: { ...state.serversByDirectory, [cacheKey]: data },
isLoading: false,
};
if (isAmbient) next.mcpServers = data;
return next;
});
mcpLastLoadedAt.set(cacheKey, Date.now());
return true;
} catch (error) {
@@ -191,10 +231,10 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}
},
createMcp: async (config: McpDraft) => {
createMcp: async (config: McpDraft, directory?: string | null) => {
try {
const body = buildMcpBody(config);
const configDirectory = getConfigDirectory();
const configDirectory = resolveDirectory(directory);
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await runtimeFetch(`/api/config/mcp/${encodeURIComponent(config.name)}${queryParams}`, {
method: 'POST',
@@ -213,7 +253,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
invalidateMcpCache(configDirectory);
if (payload?.requiresManualRestart) {
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
requiresManualRestart: true,
@@ -224,7 +264,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}
if (noteDeferredRestartFromPayload(payload, 'mcp', { id: config.name })) {
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
restartDeferred: true,
@@ -241,7 +281,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
scopes: ['all'],
});
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
@@ -250,7 +290,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
};
}
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
@@ -263,10 +303,10 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}
},
updateMcp: async (name: string, config: Partial<McpDraft>) => {
updateMcp: async (name: string, config: Partial<McpDraft>, directory?: string | null) => {
try {
const body = buildMcpBody(config);
const configDirectory = getConfigDirectory();
const configDirectory = resolveDirectory(directory);
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await runtimeFetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH',
@@ -285,7 +325,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
invalidateMcpCache(configDirectory);
if (payload?.requiresManualRestart) {
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
requiresManualRestart: true,
@@ -296,7 +336,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}
if (noteDeferredRestartFromPayload(payload, 'mcp', { id: name })) {
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
restartDeferred: true,
@@ -313,7 +353,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
scopes: ['all'],
});
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
@@ -322,7 +362,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
};
}
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
@@ -335,9 +375,9 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}
},
deleteMcp: async (name: string) => {
deleteMcp: async (name: string, directory?: string | null) => {
try {
const configDirectory = getConfigDirectory();
const configDirectory = resolveDirectory(directory);
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await runtimeFetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, {
method: 'DELETE',
@@ -356,7 +396,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}
if (payload?.requiresManualRestart) {
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
requiresManualRestart: true,
@@ -367,7 +407,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}
if (noteDeferredRestartFromPayload(payload, 'mcp', { id: name })) {
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
restartDeferred: true,
@@ -386,7 +426,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
});
}
await get().loadMcpConfigs({ force: true });
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
@@ -399,8 +439,12 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}
},
getMcpByName: (name: string) => {
return get().mcpServers.find((s) => s.name === name);
getMcpByName: (name: string, directory?: string | null) => {
return get().getMcpServersForDirectory(directory).find((s) => s.name === name);
},
getMcpServersForDirectory: (directory?: string | null) => {
return selectMcpServersForDirectory(get(), directory);
},
}),
{