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
@@ -6,10 +6,10 @@ import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { opencodeClient } from '@/lib/opencode/client';
import {
useAgentsStore,
getConfigDirectory,
type AgentWithExtras,
} from '@/stores/useAgentsStore';
import {
@@ -105,6 +105,9 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
const [reloadToken, setReloadToken] = React.useState(0);
const agentName = agent.name;
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
// --- Load the SOURCE permission map (the agent's own config file). ---
React.useEffect(() => {
@@ -113,7 +116,7 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
setLoadFailed(false);
void (async () => {
try {
const directory = getConfigDirectory();
const directory = settingsDirectory;
const query = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(agentName)}/config${query}`, {
headers: {
@@ -136,14 +139,14 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
return () => {
cancelled = true;
};
}, [agentName, reloadToken]);
}, [agentName, reloadToken, settingsDirectory]);
// --- Known tool ids for the key list (display only). ---
React.useEffect(() => {
let cancelled = false;
void (async () => {
try {
const ids = await opencodeClient.listToolIds({ directory: getConfigDirectory() });
const ids = await opencodeClient.listToolIds({ directory: settingsDirectory });
if (!cancelled && Array.isArray(ids)) {
setToolIds(ids.filter((id) => typeof id === 'string' && !FOLDED_TOOL_IDS.has(id)));
}
@@ -154,7 +157,7 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
return () => {
cancelled = true;
};
}, [agentName]);
}, [agentName, settingsDirectory]);
// --- Effective rules from the resolved view (read-only hints). ---
const effectiveRules = React.useMemo<EffectiveRule[]>(() => {
@@ -4,7 +4,8 @@ import { Input } from '@/components/ui/input';
import { NumberInput } from '@/components/ui/number-input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useAgentsStore, type AgentConfig, type AgentMutationResult, type AgentScope } from '@/stores/useAgentsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { selectAgentsForDirectory, useAgentsStore, type AgentConfig, type AgentMutationResult, type AgentScope } from '@/stores/useAgentsStore';
import { useShallow } from 'zustand/react/shallow';
import { ModelSelector } from './ModelSelector';
import { useI18n } from '@/lib/i18n';
@@ -60,7 +61,6 @@ export const AgentsPage: React.FC = () => {
getAgentByName,
createAgent,
updateAgent,
agents,
agentDraft,
setAgentDraft,
} = useAgentsStore(useShallow((s) => ({
@@ -68,12 +68,15 @@ export const AgentsPage: React.FC = () => {
getAgentByName: s.getAgentByName,
createAgent: s.createAgent,
updateAgent: s.updateAgent,
agents: s.agents,
agentDraft: s.agentDraft,
setAgentDraft: s.setAgentDraft,
})));
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null;
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const agents = useAgentsStore((state) => selectAgentsForDirectory(state, settingsDirectory));
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName, settingsDirectory) : null;
const isNewAgent = Boolean(agentDraft && agentDraft.name === selectedAgentName && !selectedAgent);
const [draftName, setDraftName] = React.useState('');
@@ -232,12 +235,12 @@ export const AgentsPage: React.FC = () => {
let result: AgentMutationResult;
if (isNewAgent) {
result = await createAgent(config);
result = await createAgent(config, settingsDirectory);
if (result.ok) {
setAgentDraft(null); // Clear draft after successful creation
}
} else {
result = await updateAgent(agentName, config);
result = await updateAgent(agentName, config, settingsDirectory);
}
if (result.ok) {
@@ -18,7 +18,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { selectAgentsForDirectory, useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
import type { Agent } from '@opencode-ai/sdk/v2';
@@ -113,7 +114,6 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
const {
selectedAgentName,
agents,
setSelectedAgent,
setAgentDraft,
createAgent,
@@ -121,7 +121,6 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
loadAgents,
} = useAgentsStore(useShallow((s) => ({
selectedAgentName: s.selectedAgentName,
agents: s.agents,
setSelectedAgent: s.setSelectedAgent,
setAgentDraft: s.setAgentDraft,
createAgent: s.createAgent,
@@ -129,9 +128,14 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
loadAgents: s.loadAgents,
})));
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const agents = useAgentsStore((state) => selectAgentsForDirectory(state, settingsDirectory));
React.useEffect(() => {
loadAgents();
}, [loadAgents]);
void loadAgents(settingsDirectory);
}, [loadAgents, settingsDirectory]);
const bgClass = 'bg-background';
@@ -183,7 +187,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
setIsConfirmActionPending(true);
try {
const result = await deleteAgent(confirmActionAgent.name, (confirmActionAgent as Agent & { scope?: AgentScope }).scope);
const result = await deleteAgent(confirmActionAgent.name, (confirmActionAgent as Agent & { scope?: AgentScope }).scope, settingsDirectory);
if (result.ok) {
if (result.requiresManualRestart) {
@@ -291,11 +295,11 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
permission: rulesetToPermissionConfig(renameDialogAgent.permission),
disable: renameExt.disable,
scope: renameExt.scope,
});
}, settingsDirectory);
if (createResult.ok) {
// Delete old agent
const deleteResult = await deleteAgent(renameDialogAgent.name, renameExt.scope);
const deleteResult = await deleteAgent(renameDialogAgent.name, renameExt.scope, settingsDirectory);
if (deleteResult.ok) {
if (createResult.requiresManualRestart || deleteResult.requiresManualRestart) {
toast.warning(t('settings.agents.page.toast.savedManualRestart'));
@@ -3,7 +3,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { selectCommandsForDirectory, useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore';
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
import { useShallow } from 'zustand/react/shallow';
import { ModelSelector } from '../agents/ModelSelector';
@@ -33,7 +34,6 @@ export const CommandsPage: React.FC = () => {
getCommandByName,
createCommand,
updateCommand,
commands,
commandDraft,
setCommandDraft,
} = useCommandsStore(useShallow((s) => ({
@@ -41,12 +41,15 @@ export const CommandsPage: React.FC = () => {
getCommandByName: s.getCommandByName,
createCommand: s.createCommand,
updateCommand: s.updateCommand,
commands: s.commands,
commandDraft: s.commandDraft,
setCommandDraft: s.setCommandDraft,
})));
const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName) : null;
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const commands = useCommandsStore((state) => selectCommandsForDirectory(state, settingsDirectory));
const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName, settingsDirectory) : null;
const isNewCommand = Boolean(commandDraft && commandDraft.name === selectedCommandName && !selectedCommand);
const [draftName, setDraftName] = React.useState('');
@@ -162,12 +165,12 @@ export const CommandsPage: React.FC = () => {
let success: boolean;
if (isNewCommand) {
success = await createCommand(config);
success = await createCommand(config, settingsDirectory);
if (success) {
setCommandDraft(null);
}
} else {
success = await updateCommand(commandName, config);
success = await updateCommand(commandName, config, settingsDirectory);
}
if (success) {
@@ -18,8 +18,9 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { selectCommandsForDirectory, useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
import { selectSkillsForDirectory, useSkillsStore } from '@/stores/useSkillsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
@@ -43,7 +44,6 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
const {
selectedCommandName,
commands,
setSelectedCommand,
setCommandDraft,
createCommand,
@@ -51,20 +51,23 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
loadCommands,
} = useCommandsStore(useShallow((s) => ({
selectedCommandName: s.selectedCommandName,
commands: s.commands,
setSelectedCommand: s.setSelectedCommand,
setCommandDraft: s.setCommandDraft,
createCommand: s.createCommand,
deleteCommand: s.deleteCommand,
loadCommands: s.loadCommands,
})));
const skills = useSkillsStore((s) => s.skills);
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const commands = useCommandsStore((state) => selectCommandsForDirectory(state, settingsDirectory));
const skills = useSkillsStore((state) => selectSkillsForDirectory(state, settingsDirectory));
const loadSkills = useSkillsStore((s) => s.loadSkills);
React.useEffect(() => {
loadCommands();
loadSkills();
}, [loadCommands, loadSkills]);
void loadCommands(settingsDirectory);
void loadSkills(settingsDirectory);
}, [loadCommands, loadSkills, settingsDirectory]);
const skillNames = React.useMemo(() => new Set(skills.map((skill) => skill.name)), [skills]);
const commandOnlyItems = React.useMemo(
@@ -131,7 +134,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
}
setIsConfirmActionPending(true);
const success = await deleteCommand(confirmActionCommand.name);
const success = await deleteCommand(confirmActionCommand.name, settingsDirectory);
if (success) {
if (confirmActionType === 'delete') {
@@ -204,11 +207,11 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
template: renameDialogCommand.template,
agent: renameDialogCommand.agent,
model: renameDialogCommand.model,
});
}, settingsDirectory);
if (success) {
// Delete old command
const deleteSuccess = await deleteCommand(renameDialogCommand.name);
const deleteSuccess = await deleteCommand(renameDialogCommand.name, settingsDirectory);
if (deleteSuccess) {
toast.success(`Command renamed to "${sanitizedName}"`);
setSelectedCommand(sanitizedName);
@@ -7,6 +7,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import { isVSCodeRuntime } from '@/lib/desktop';
import {
selectMcpServersForDirectory,
useMcpConfigStore,
envRecordToArray,
type McpDraft,
@@ -19,7 +20,7 @@ import {
} from './mcpImport';
import { useMcpStore } from '@/stores/useMcpStore';
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { cn } from '@/lib/utils';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
@@ -556,7 +557,6 @@ export const McpPage: React.FC = () => {
);
const {
selectedMcpName,
mcpServers,
mcpDraft,
setMcpDraft,
setSelectedMcp,
@@ -566,7 +566,6 @@ export const McpPage: React.FC = () => {
deleteMcp,
} = useMcpConfigStore(useShallow((s) => ({
selectedMcpName: s.selectedMcpName,
mcpServers: s.mcpServers,
mcpDraft: s.mcpDraft,
setMcpDraft: s.setMcpDraft,
setSelectedMcp: s.setSelectedMcp,
@@ -576,10 +575,12 @@ export const McpPage: React.FC = () => {
deleteMcp: s.deleteMcp,
})));
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const currentDirectory = useSettingsDirectory();
const isVSCodeAuthRuntime = React.useMemo(() => isVSCodeRuntime(), []);
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
const mcpDiagnostics = useMcpStore((state) => state.getDiagnosticForDirectory(currentDirectory ?? null));
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory));
const mcpDiagnostics = useMcpStore((state) => state.getDiagnosticForDirectory(currentDirectory));
const refreshStatus = useMcpStore((state) => state.refresh);
const connectMcp = useMcpStore((state) => state.connect);
const disconnectMcp = useMcpStore((state) => state.disconnect);
@@ -588,7 +589,8 @@ export const McpPage: React.FC = () => {
const testConnectionMcp = useMcpStore((state) => state.testConnection);
const pendingRestartChanges = usePendingOpenCodeRestartStore((state) => state.changes);
const selectedServer = selectedMcpName ? getMcpByName(selectedMcpName) : null;
const mcpServers = useMcpConfigStore((state) => selectMcpServersForDirectory(state, currentDirectory));
const selectedServer = selectedMcpName ? getMcpByName(selectedMcpName, currentDirectory) : null;
const isNewServer = Boolean(mcpDraft && mcpDraft.name === selectedMcpName && !selectedServer);
// ── form state ──
@@ -908,7 +910,7 @@ export const McpPage: React.FC = () => {
};
setIsSaving(true);
try {
const result = isNewServer ? await createMcp(draft) : await updateMcp(name, draft);
const result = isNewServer ? await createMcp(draft, currentDirectory) : await updateMcp(name, draft, currentDirectory);
if (result.ok) {
await clearPendingMcpAuthContext(authStateKey);
resetTransientAuthState();
@@ -940,7 +942,7 @@ export const McpPage: React.FC = () => {
const handleDelete = async () => {
if (!selectedMcpName) return;
setIsDeleting(true);
const result = await deleteMcp(selectedMcpName);
const result = await deleteMcp(selectedMcpName, currentDirectory);
if (result.ok) {
await clearPendingMcpAuthContext(authStateKey);
resetTransientAuthState();
@@ -967,7 +969,7 @@ export const McpPage: React.FC = () => {
} else {
await connectMcp(selectedMcpName, currentDirectory);
await refreshStatus({ directory: currentDirectory, silent: true });
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName];
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName];
if (nextStatus?.status === 'connected') {
toast.success(t('settings.mcp.page.toast.connected'));
} else if (nextStatus?.status === 'needs_auth') {
@@ -1029,7 +1031,7 @@ export const McpPage: React.FC = () => {
const actionKey = runtimeActionKey;
let queuedStateKey: string | null = null;
try {
const currentStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName]?.status;
const currentStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName]?.status;
authPollStartsFromNeedsAuthRef.current = currentStatus === 'needs_auth' || currentStatus === 'needs_client_registration';
// One implementation for every surface that can authorise; the page
@@ -1237,7 +1239,7 @@ export const McpPage: React.FC = () => {
void (async () => {
authPollAttemptsRef.current += 1;
await refreshStatus({ directory: currentDirectory, silent: true });
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName];
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName];
if (!nextStatus) {
return;
@@ -8,10 +8,10 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
import { selectMcpServersForDirectory, useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
import { useShallow } from 'zustand/react/shallow';
import { useMcpStore } from '@/stores/useMcpStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { isMobileDeviceViaCSS } from '@/lib/device';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
@@ -65,9 +65,8 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const { t } = useI18n();
const bgClass = 'bg-background';
const { mcpServers, selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
const { selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
useMcpConfigStore(useShallow((s) => ({
mcpServers: s.mcpServers,
selectedMcpName: s.selectedMcpName,
setSelectedMcp: s.setSelectedMcp,
setMcpDraft: s.setMcpDraft,
@@ -75,8 +74,11 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
deleteMcp: s.deleteMcp,
})));
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const mcpServers = useMcpConfigStore((state) => selectMcpServersForDirectory(state, settingsDirectory));
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(settingsDirectory));
const refreshStatus = useMcpStore((state) => state.refresh);
const getErrorForDirectory = useMcpStore((state) => state.getErrorForDirectory);
@@ -96,8 +98,8 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
);
React.useEffect(() => {
void loadMcpConfigs();
}, [loadMcpConfigs]);
void loadMcpConfigs({ directory: settingsDirectory });
}, [loadMcpConfigs, settingsDirectory]);
const handleRefresh = React.useCallback(() => {
if (isRefreshingStatus) return;
@@ -106,17 +108,17 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const minSpinPromise = new Promise((resolve) => setTimeout(resolve, 500));
Promise.all([
refreshStatus({ directory: currentDirectory, silent: true }),
refreshStatus({ directory: settingsDirectory, silent: true }),
minSpinPromise,
]).then(() => {
const error = getErrorForDirectory(currentDirectory);
const error = getErrorForDirectory(settingsDirectory);
if (error) {
toast.error(error);
}
}).finally(() => {
setIsRefreshingStatus(false);
});
}, [currentDirectory, getErrorForDirectory, isRefreshingStatus, refreshStatus]);
}, [getErrorForDirectory, isRefreshingStatus, refreshStatus, settingsDirectory]);
const handleCreateNew = () => {
const baseName = 'new-mcp-server';
@@ -151,7 +153,7 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const handleDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
const result = await deleteMcp(deleteTarget.name);
const result = await deleteMcp(deleteTarget.name, settingsDirectory);
if (result.ok) {
if (result.reloadFailed) {
toast.warning(result.message || `MCP server "${deleteTarget.name}" deleted, but OpenCode reload failed`, {
@@ -4,7 +4,8 @@ import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLay
import { SettingsSection, SETTINGS_CUSTOM_TRIGGER_CLASS } from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { useConfigStore } from '@/stores/useConfigStore';
import { selectProvidersForDirectory, useConfigStore } from '@/stores/useConfigStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useUIStore } from '@/stores/useUIStore';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -144,7 +145,10 @@ const parseProvidersPayload = (payload: unknown): ProviderOption[] => {
export const ProvidersPage: React.FC = () => {
const { t } = useI18n();
const providers = useConfigStore((state) => state.providers);
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const providers = useConfigStore((state) => selectProvidersForDirectory(state, settingsDirectory));
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
@@ -341,7 +345,8 @@ export const ProvidersPage: React.FC = () => {
try {
// OpenChamber-only metadata endpoint: the SDK exposes provider data but
// not local auth/source-file provenance used by this settings UI.
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(selectedProviderId)}/source`, {
const query = settingsDirectory ? `?directory=${encodeURIComponent(settingsDirectory)}` : '';
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(selectedProviderId)}/source${query}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -370,7 +375,7 @@ export const ProvidersPage: React.FC = () => {
return () => {
cancelled = true;
};
}, [selectedProviderId, t]);
}, [selectedProviderId, settingsDirectory, t]);
const selectedProvider = providers.find((provider) => provider.id === selectedProviderId);
const selectedSources = selectedProviderId ? providerSources[selectedProviderId] : undefined;
@@ -431,7 +436,7 @@ export const ProvidersPage: React.FC = () => {
? (editingCustomScope ?? resolveProviderConfigScope(providerSources[editingCustomProviderId]))
: 'user',
});
const response = await runtimeFetch('/api/provider', {
const response = await runtimeFetch(`/api/provider${settingsDirectory ? `?directory=${encodeURIComponent(settingsDirectory)}` : ''}`, {
method: 'PUT',
headers: {
Accept: 'application/json',
@@ -484,10 +489,13 @@ export const ProvidersPage: React.FC = () => {
setAuthBusyKey(busyKey);
try {
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(providerId)}/auth?scope=all`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
const response = await runtimeFetch(
`/api/provider/${encodeURIComponent(providerId)}/auth?scope=all${settingsDirectory ? `&directory=${encodeURIComponent(settingsDirectory)}` : ''}`,
{
method: 'DELETE',
headers: { Accept: 'application/json' },
},
);
const payload = await response.json().catch(() => null);
if (!response.ok) {
@@ -2,7 +2,8 @@ import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { Button } from '@/components/ui/button';
import { useConfigStore } from '@/stores/useConfigStore';
import { selectProvidersForDirectory, useConfigStore } from '@/stores/useConfigStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { cn } from '@/lib/utils';
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
@@ -40,16 +41,28 @@ interface ProvidersSidebarProps {
export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect }) => {
const { t } = useI18n();
const providers = useConfigStore((state) => state.providers);
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const providers = useConfigStore((state) => selectProvidersForDirectory(state, settingsDirectory));
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
const [sourcesByProvider, setSourcesByProvider] = React.useState<Record<string, ProviderSources>>({});
const directory = React.useMemo(() => {
if (settingsDirectory) return settingsDirectory;
// tie refresh to active project changes (directory is stored in the client)
void activeProjectId;
return getCurrentDirectory();
}, [activeProjectId]);
}, [activeProjectId, settingsDirectory]);
// The app only loads providers for the project it is on; Settings has to ask
// for the one it is looking at.
const loadProviders = useConfigStore((state) => state.loadProviders);
React.useEffect(() => {
if (!settingsDirectory) return;
void loadProviders({ directory: settingsDirectory, source: 'settings:providers' });
}, [loadProviders, settingsDirectory]);
React.useEffect(() => {
if (providers.length === 0) {
@@ -8,6 +8,8 @@ import {
} from '@/components/ui/dropdown-menu';
import { Icon } from "@/components/icon/Icon";
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { isVSCodeRuntime } from '@/lib/desktop';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
@@ -17,8 +19,11 @@ const formatProjectLabel = (label: string): string => label.trim();
export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ className }) => {
const { t } = useI18n();
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
// Settings-only selection. Picking a project here used to call
// `setActiveProject`, which relocates the chat, the session list and the file
// tree; reading another project's configuration must not move the app.
const settingsDirectory = useSettingsDirectory();
const setSettingsProjectPath = useUIStore((state) => state.setSettingsProjectPath);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
@@ -30,8 +35,8 @@ export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ clas
if (sortedProjects.length === 0) {
return null;
}
return sortedProjects.find((p) => p.id === activeProjectId) ?? sortedProjects[0];
}, [activeProjectId, sortedProjects]);
return sortedProjects.find((p) => p.path === settingsDirectory) ?? sortedProjects[0];
}, [settingsDirectory, sortedProjects]);
if (isVSCode || sortedProjects.length === 0) {
return null;
@@ -67,7 +72,9 @@ export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ clas
value={activeProject?.id ?? ''}
onValueChange={(value) => {
if (!value) return;
setActiveProject(value);
const project = sortedProjects.find((entry) => entry.id === value);
if (!project) return;
setSettingsProjectPath(project.path);
}}
>
{sortedProjects.map((project) => {
@@ -5,7 +5,8 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { toast } from '@/components/ui';
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { selectSkillsForDirectory, useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -119,7 +120,6 @@ const SkillsInstalledPage: React.FC = () => {
getSkillDetail,
createSkill,
updateSkill,
skills,
skillDraft,
setSkillDraft,
setSelectedSkill,
@@ -129,13 +129,16 @@ const SkillsInstalledPage: React.FC = () => {
getSkillDetail: s.getSkillDetail,
createSkill: s.createSkill,
updateSkill: s.updateSkill,
skills: s.skills,
skillDraft: s.skillDraft,
setSkillDraft: s.setSkillDraft,
setSelectedSkill: s.setSelectedSkill,
})));
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null;
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const skills = useSkillsStore((state) => selectSkillsForDirectory(state, settingsDirectory));
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName, settingsDirectory) : null;
const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill);
const hasStaleSelection = Boolean(selectedSkillName && !selectedSkill && !skillDraft);
const isReadOnlySkill = selectedSkill?.path === '<built-in>';
@@ -232,7 +235,7 @@ const SkillsInstalledPage: React.FC = () => {
} else if (selectedSkillName && selectedSkill) {
setIsLoading(true);
try {
const detail = await getSkillDetail(selectedSkillName);
const detail = await getSkillDetail(selectedSkillName, settingsDirectory);
if (detail) {
const md = detail.sources.md;
const nextDescription = md.description || '';
@@ -253,7 +256,7 @@ const SkillsInstalledPage: React.FC = () => {
};
loadSkillDetails();
}, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail]);
}, [selectedSkill, isNewSkill, selectedSkillName, settingsDirectory, skills, skillDraft, getSkillDetail]);
const editorFontSize = useUIStore((state) => state.editorFontSize);
@@ -338,14 +341,14 @@ const SkillsInstalledPage: React.FC = () => {
let success: boolean;
if (isNewSkill) {
success = await createSkill(config);
success = await createSkill(config, settingsDirectory);
if (success) {
setSkillDraft(null);
setPendingFiles([]);
setSelectedSkill(skillName);
}
} else {
success = await updateSkill(skillName, config);
success = await updateSkill(skillName, config, settingsDirectory);
if (success) {
setOriginalDescription(description.trim());
setOriginalInstructions(instructions.trim());
@@ -402,7 +405,7 @@ const SkillsInstalledPage: React.FC = () => {
try {
const { readSupportingFile } = useSkillsStore.getState();
const content = await readSupportingFile(selectedSkillName, filePath);
const content = await readSupportingFile(selectedSkillName, filePath, settingsDirectory);
setNewFileContent(content || '');
setOriginalFileContent(content || '');
} catch {
@@ -448,13 +451,13 @@ const SkillsInstalledPage: React.FC = () => {
}
const { writeSupportingFile } = useSkillsStore.getState();
const success = await writeSupportingFile(selectedSkillName, filePath, newFileContent);
const success = await writeSupportingFile(selectedSkillName, filePath, newFileContent, settingsDirectory);
if (success) {
toast.success(isEditing ? t('settings.skills.page.toast.fileUpdated', { path: filePath }) : t('settings.skills.page.toast.fileCreated', { path: filePath }));
setIsFileDialogOpen(false);
setEditingFilePath(null);
const detail = await getSkillDetail(selectedSkillName);
const detail = await getSkillDetail(selectedSkillName, settingsDirectory);
if (detail) {
setSupportingFiles(detail.sources.md.supportingFiles || []);
}
@@ -484,11 +487,11 @@ const SkillsInstalledPage: React.FC = () => {
setIsDeletingFile(true);
const { deleteSupportingFile } = useSkillsStore.getState();
const success = await deleteSupportingFile(selectedSkillName, deleteFilePath);
const success = await deleteSupportingFile(selectedSkillName, deleteFilePath, settingsDirectory);
if (success) {
toast.success(t('settings.skills.page.toast.fileDeleted', { path: deleteFilePath }));
const detail = await getSkillDetail(selectedSkillName);
const detail = await getSkillDetail(selectedSkillName, settingsDirectory);
if (detail) {
setSupportingFiles(detail.sources.md.supportingFiles || []);
}
@@ -18,7 +18,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
import { selectSkillsForDirectory, useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -49,7 +50,6 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
const {
selectedSkillName,
skills,
setSelectedSkill,
setSkillDraft,
deleteSkill,
@@ -57,7 +57,6 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
getSkillDetail,
} = useSkillsStore(useShallow((s) => ({
selectedSkillName: s.selectedSkillName,
skills: s.skills,
setSelectedSkill: s.setSelectedSkill,
setSkillDraft: s.setSkillDraft,
deleteSkill: s.deleteSkill,
@@ -65,7 +64,15 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
getSkillDetail: s.getSkillDetail,
})));
// Skills are loaded by the Settings shell when this page is active.
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const skills = useSkillsStore((state) => selectSkillsForDirectory(state, settingsDirectory));
const loadSkills = useSkillsStore((state) => state.loadSkills);
React.useEffect(() => {
void loadSkills(settingsDirectory);
}, [loadSkills, settingsDirectory]);
const bgClass = 'bg-background';
@@ -101,7 +108,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
}
setIsDeletePending(true);
const success = await deleteSkill(deleteDialogSkill.name);
const success = await deleteSkill(deleteDialogSkill.name, settingsDirectory);
if (success) {
toast.success(t('settings.skills.sidebar.toast.skillDeleted', { name: deleteDialogSkill.name }));
setDeleteDialogSkill(null);
@@ -124,7 +131,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
}
// Get full skill detail to copy
const detail = await getSkillDetail(skill.name);
const detail = await getSkillDetail(skill.name, settingsDirectory);
if (!detail) {
toast.error(t('settings.skills.sidebar.toast.duplicateLoadFailed'));
return;
@@ -173,7 +180,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
}
// Rename in place on disk so SKILL.md body and supporting files are preserved.
const success = await renameSkill(renameDialogSkill.name, sanitizedName);
const success = await renameSkill(renameDialogSkill.name, sanitizedName, settingsDirectory);
if (success) {
toast.success(t('settings.skills.sidebar.toast.skillRenamed', { name: sanitizedName }));
setSelectedSkill(sanitizedName);
@@ -1,6 +1,7 @@
import React from 'react';
import { cn, getModifierLabel } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useAgentsStore } from '@/stores/useAgentsStore';
import { useCommandsStore } from '@/stores/useCommandsStore';
@@ -254,23 +255,24 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
}, [visiblePages]);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const settingsDirectory = useSettingsDirectory();
// Load stores when project changes or when a page becomes active.
// Load stores when the settings project changes or a page becomes active.
React.useEffect(() => {
if (!isSettingsDialogOpen && !runtimeCtx.isVSCode && !isWindowed) {
return;
}
if (settingsSlug === 'agents') {
void useAgentsStore.getState().loadAgents();
void useAgentsStore.getState().loadAgents(settingsDirectory);
return;
}
if (settingsSlug === 'commands') {
void useCommandsStore.getState().loadCommands();
void useCommandsStore.getState().loadCommands(settingsDirectory);
return;
}
if (settingsSlug === 'mcp') {
void useMcpConfigStore.getState().loadMcpConfigs();
void useMcpConfigStore.getState().loadMcpConfigs({ directory: settingsDirectory });
return;
}
if (settingsSlug === 'plugins') {
@@ -278,13 +280,15 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return;
}
if (settingsSlug === 'skills.installed' || settingsSlug === 'skills.catalog') {
void useSkillsStore.getState().loadSkills();
void useSkillsStore.getState().loadSkills(settingsDirectory);
void useSkillsCatalogStore.getState().loadCatalog();
}
if (settingsSlug === 'snippets') {
void useSnippetsStore.getState().loadSnippets();
}
}, [activeProjectId, isSettingsDialogOpen, isWindowed, runtimeCtx.isVSCode, settingsSlug]);
// `activeProjectId` still matters: the settings directory follows the active
// project until the user picks another one in the Settings selector.
}, [activeProjectId, isSettingsDialogOpen, isWindowed, runtimeCtx.isVSCode, settingsDirectory, settingsSlug]);
const openPage = React.useCallback((slug: SettingsPageSlug) => {
setSettingsPage(slug);