From 0079347edcb8ce3443de8c41501aa492ed8e5634 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 22 Aug 2026 19:50:16 +0300 Subject: [PATCH] refactor(settings): scope the settings project selector to settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 1 + .../agents/AgentPermissionsEditor.tsx | 13 +- .../components/sections/agents/AgentsPage.tsx | 15 +- .../sections/agents/AgentsSidebar.tsx | 20 ++- .../sections/commands/CommandsPage.tsx | 15 +- .../sections/commands/CommandsSidebar.tsx | 25 +-- .../src/components/sections/mcp/McpPage.tsx | 26 +-- .../components/sections/mcp/McpSidebar.tsx | 26 +-- .../sections/providers/ProvidersPage.tsx | 26 ++- .../sections/providers/ProvidersSidebar.tsx | 19 ++- .../shared/SettingsProjectSelector.tsx | 17 +- .../components/sections/skills/SkillsPage.tsx | 29 ++-- .../sections/skills/SkillsSidebar.tsx | 21 ++- .../ui/src/components/views/SettingsView.tsx | 16 +- .../ui/src/hooks/useSettingsDirectory.test.ts | 30 ++++ packages/ui/src/hooks/useSettingsDirectory.ts | 38 +++++ packages/ui/src/stores/DOCUMENTATION.md | 32 ++++ packages/ui/src/stores/useAgentsStore.ts | 91 +++++++--- .../ui/src/stores/useCommandsStore.test.ts | 27 +++ packages/ui/src/stores/useCommandsStore.ts | 148 +++++++++++----- packages/ui/src/stores/useConfigStore.ts | 20 +++ packages/ui/src/stores/useMcpConfigStore.ts | 98 ++++++++--- packages/ui/src/stores/useSkillsStore.test.ts | 28 ++++ packages/ui/src/stores/useSkillsStore.ts | 158 ++++++++++++------ packages/ui/src/stores/useUIStore.ts | 14 ++ 25 files changed, 703 insertions(+), 250 deletions(-) create mode 100644 packages/ui/src/hooks/useSettingsDirectory.test.ts create mode 100644 packages/ui/src/hooks/useSettingsDirectory.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a51753f4..d9510884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. - Desktop/Remote instances: a managed remote server can now also be published to the remote machine's own network, so other devices there reach it without the SSH tunnel. It requires a UI password, and stays private to the tunnel otherwise. - Desktop/Remote instances: disconnecting from a connection set to not keep the server running now actually stops that remote server. - Chat: in a chat without a project, the work status card again steps aside when the context panel is open, instead of sitting next to it. +- **Settings:** the project selector on Providers, Agents, MCP, Commands and Skills now only changes what those pages show. It used to switch the whole app, so opening another project's configuration moved your chat, session list and file tree with it. - Settings/Providers: the provider you select no longer jumps to a different one on its own. Changing the chat's model or agent, and background provider refreshes, used to move the settings selection with them. - **Chat sessions:** start chats without choosing a project. They live in their own Chats section, rather than inheriting a project's repository and worktree context. - **Skills catalog:** browse curated GitHub skill collections in a card-based catalog with cross-source search, skill counts, stars, recent updates, and links back to each skill's repository. diff --git a/packages/ui/src/components/sections/agents/AgentPermissionsEditor.tsx b/packages/ui/src/components/sections/agents/AgentPermissionsEditor.tsx index 2638225a..09681aa8 100644 --- a/packages/ui/src/components/sections/agents/AgentPermissionsEditor.tsx +++ b/packages/ui/src/components/sections/agents/AgentPermissionsEditor.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ return () => { cancelled = true; }; - }, [agentName]); + }, [agentName, settingsDirectory]); // --- Effective rules from the resolved view (read-only hints). --- const effectiveRules = React.useMemo(() => { diff --git a/packages/ui/src/components/sections/agents/AgentsPage.tsx b/packages/ui/src/components/sections/agents/AgentsPage.tsx index 6fb23f2b..3a759e0a 100644 --- a/packages/ui/src/components/sections/agents/AgentsPage.tsx +++ b/packages/ui/src/components/sections/agents/AgentsPage.tsx @@ -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) { diff --git a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx index ebca0cf2..5009b623 100644 --- a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx +++ b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx @@ -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 = ({ onItemSelect }) => const { selectedAgentName, - agents, setSelectedAgent, setAgentDraft, createAgent, @@ -121,7 +121,6 @@ export const AgentsSidebar: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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')); diff --git a/packages/ui/src/components/sections/commands/CommandsPage.tsx b/packages/ui/src/components/sections/commands/CommandsPage.tsx index b04649d1..f677422d 100644 --- a/packages/ui/src/components/sections/commands/CommandsPage.tsx +++ b/packages/ui/src/components/sections/commands/CommandsPage.tsx @@ -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) { diff --git a/packages/ui/src/components/sections/commands/CommandsSidebar.tsx b/packages/ui/src/components/sections/commands/CommandsSidebar.tsx index 091a46a3..2e413314 100644 --- a/packages/ui/src/components/sections/commands/CommandsSidebar.tsx +++ b/packages/ui/src/components/sections/commands/CommandsSidebar.tsx @@ -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 = ({ onItemSelect } const { selectedCommandName, - commands, setSelectedCommand, setCommandDraft, createCommand, @@ -51,20 +51,23 @@ export const CommandsSidebar: React.FC = ({ 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 = ({ 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 = ({ 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); diff --git a/packages/ui/src/components/sections/mcp/McpPage.tsx b/packages/ui/src/components/sections/mcp/McpPage.tsx index 96409af2..84b49244 100644 --- a/packages/ui/src/components/sections/mcp/McpPage.tsx +++ b/packages/ui/src/components/sections/mcp/McpPage.tsx @@ -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; diff --git a/packages/ui/src/components/sections/mcp/McpSidebar.tsx b/packages/ui/src/components/sections/mcp/McpSidebar.tsx index bb77bb52..9df99147 100644 --- a/packages/ui/src/components/sections/mcp/McpSidebar.tsx +++ b/packages/ui/src/components/sections/mcp/McpSidebar.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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`, { diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index 6a4655f5..a84bb4fd 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -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) { diff --git a/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx b/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx index cd1da383..258ccfe7 100644 --- a/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx @@ -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 = ({ 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>({}); 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) { diff --git a/packages/ui/src/components/sections/shared/SettingsProjectSelector.tsx b/packages/ui/src/components/sections/shared/SettingsProjectSelector.tsx index 9acbd7b0..33eb60b5 100644 --- a/packages/ui/src/components/sections/shared/SettingsProjectSelector.tsx +++ b/packages/ui/src/components/sections/shared/SettingsProjectSelector.tsx @@ -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) => { diff --git a/packages/ui/src/components/sections/skills/SkillsPage.tsx b/packages/ui/src/components/sections/skills/SkillsPage.tsx index bf738b55..c9b722ab 100644 --- a/packages/ui/src/components/sections/skills/SkillsPage.tsx +++ b/packages/ui/src/components/sections/skills/SkillsPage.tsx @@ -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 === ''; @@ -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 || []); } diff --git a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx index 18042671..2774d7b0 100644 --- a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx +++ b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx @@ -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 = ({ onItemSelect }) => const { selectedSkillName, - skills, setSelectedSkill, setSkillDraft, deleteSkill, @@ -57,7 +57,6 @@ export const SkillsSidebar: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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); diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index 189821f9..dbec130d 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -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 = ({ 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 = ({ 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); diff --git a/packages/ui/src/hooks/useSettingsDirectory.test.ts b/packages/ui/src/hooks/useSettingsDirectory.test.ts new file mode 100644 index 00000000..7548f2d7 --- /dev/null +++ b/packages/ui/src/hooks/useSettingsDirectory.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'bun:test'; +import type { ProjectEntry } from '@/lib/api/types'; +import { resolveSettingsDirectory } from './useSettingsDirectory'; + +const project = (id: string, path: string): ProjectEntry => ({ id, path } as ProjectEntry); + +const projects = [ + project('a', '/workspace/alpha'), + project('b', '/workspace/beta'), +]; + +describe('resolveSettingsDirectory', () => { + test('follows the active project until Settings picks one', () => { + expect(resolveSettingsDirectory(null, projects, 'b')).toBe('/workspace/beta'); + }); + + test('keeps the Settings pick even when the app is on another project', () => { + // The whole point: browsing another project's configuration must not depend + // on moving the app to it. + expect(resolveSettingsDirectory('/workspace/alpha', projects, 'b')).toBe('/workspace/alpha'); + }); + + test('falls back to the active project when the picked one is gone', () => { + expect(resolveSettingsDirectory('/workspace/removed', projects, 'b')).toBe('/workspace/beta'); + }); + + test('resolves to nothing when there are no projects', () => { + expect(resolveSettingsDirectory('/workspace/alpha', [], null)).toBe(null); + }); +}); diff --git a/packages/ui/src/hooks/useSettingsDirectory.ts b/packages/ui/src/hooks/useSettingsDirectory.ts new file mode 100644 index 00000000..2ee48d84 --- /dev/null +++ b/packages/ui/src/hooks/useSettingsDirectory.ts @@ -0,0 +1,38 @@ +import type { ProjectEntry } from '@/lib/api/types'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useUIStore } from '@/stores/useUIStore'; + +/** + * Resolves which project the Settings pages describe. + * + * `settingsProjectPath` is the user's pick in the Settings project selector. + * Until they make one — or when the project it names is gone — Settings follows + * the app's active project, so nothing looks different before it is used. + */ +export const resolveSettingsDirectory = ( + settingsProjectPath: string | null, + projects: ProjectEntry[], + activeProjectId: string | null, +): string | null => { + if (settingsProjectPath && projects.some((project) => project.path === settingsProjectPath)) { + return settingsProjectPath; + } + + const activeProject = projects.find((project) => project.id === activeProjectId) ?? projects[0]; + return activeProject?.path ?? null; +}; + +/** + * Directory the Settings pages read and write configuration for. + * + * Settings has its own project selector. Picking a project there used to call + * `setActiveProject`, which moves the whole app — chat, sessions, files, git — + * so reading another project's MCP servers silently relocated the user. + */ +export const useSettingsDirectory = (): string | null => { + const settingsProjectPath = useUIStore((state) => state.settingsProjectPath); + const projects = useProjectsStore((state) => state.projects); + const activeProjectId = useProjectsStore((state) => state.activeProjectId); + + return resolveSettingsDirectory(settingsProjectPath, projects, activeProjectId); +}; diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 0b80047c..4d42e66f 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -198,6 +198,38 @@ These rules are important. Breaking them tends to reintroduce idle CPU churn, st 9. Global session refresh must remain bounded and failure-isolated per directory. 10. Global session cache must not drive live activity indicators or message-loading state. +### Configuration stores and the Settings directory + +`useAgentsStore`, `useCommandsStore`, `useSkillsStore`, `useMcpConfigStore` and +the provider half of `useConfigStore` describe **one project's configuration**. +Two surfaces read them at once: the app (chat, autocompletes, pickers), which +wants the active project, and Settings, whose own project selector may point +somewhere else. + +Each of them therefore keeps two things: + +- a per-directory map (`agentsByDirectory`, `commandsByDirectory`, + `skillsByDirectory`, `serversByDirectory`, `directoryScoped`); +- a flat mirror (`agents`, `commands`, `skills`, `mcpServers`, `providers`) that + tracks the **active** project only. + +Every loader and mutation takes an explicit directory; omitting it means the +active project, which is what non-Settings callers pass. A load for another +directory writes the map and leaves the mirror alone, so browsing another +project in Settings cannot change what chat sees. Components select through +`selectAgentsForDirectory` / `selectCommandsForDirectory` / +`selectSkillsForDirectory` / `selectMcpServersForDirectory` / +`selectProvidersForDirectory`, which return stored arrays. + +Settings resolves its directory through `useSettingsDirectory`, backed by +`useUIStore.settingsProjectPath`. That selection is Settings-local and not +persisted: it follows the active project until the user picks another one. The +Settings project selector must never call `setActiveProject` — that relocates +the chat, the session list and the file tree. + +Failure is still not empty: a failed load restores that directory's previous +list rather than clearing it. + ## Selector Rules Use leaf selectors. diff --git a/packages/ui/src/stores/useAgentsStore.ts b/packages/ui/src/stores/useAgentsStore.ts index c487edfc..5aa23a1f 100644 --- a/packages/ui/src/stores/useAgentsStore.ts +++ b/packages/ui/src/stores/useAgentsStore.ts @@ -40,6 +40,19 @@ const getCurrentDirectory = (): string | null => { return null; }; +/** + * 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(); +}; + export const getConfigDirectory = (): string | null => { try { const projectsStore = useProjectsStore.getState(); @@ -257,19 +270,22 @@ export interface AgentDraft { interface AgentsStore { selectedAgentName: string | null; + /** Agents of the project the app is on. Chat and pickers read this one. */ agents: Agent[]; + /** Every directory loaded so far, including the ambient one. */ + agentsByDirectory: Record; isLoading: boolean; agentDraft: AgentDraft | null; setSelectedAgent: (name: string | null) => void; setAgentDraft: (draft: AgentDraft | null) => void; - loadAgents: () => Promise; - createAgent: (config: AgentConfig) => Promise; - updateAgent: (name: string, config: Partial) => Promise; - deleteAgent: (name: string, scope?: AgentScope) => Promise; - getAgentByName: (name: string) => Agent | undefined; + loadAgents: (directory?: string | null) => Promise; + createAgent: (config: AgentConfig, directory?: string | null) => Promise; + updateAgent: (name: string, config: Partial, directory?: string | null) => Promise; + deleteAgent: (name: string, scope?: AgentScope, directory?: string | null) => Promise; + getAgentByName: (name: string, directory?: string | null) => Agent | undefined; // Returns only visible agents (excludes hidden internal agents) - getVisibleAgents: () => Agent[]; + getVisibleAgents: (directory?: string | null) => Agent[]; } declare global { @@ -278,6 +294,20 @@ declare global { } } +const EMPTY_AGENTS: Agent[] = []; + +/** + * Agents 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 selectAgentsForDirectory = ( + state: Pick, + directory?: string | null, +): Agent[] => { + const cacheKey = getAgentsCacheKey(resolveDirectory(directory)); + return state.agentsByDirectory[cacheKey] ?? EMPTY_AGENTS; +}; + export const useAgentsStore = create()( devtools( persist( @@ -285,6 +315,7 @@ export const useAgentsStore = create()( selectedAgentName: null, agents: [], + agentsByDirectory: {}, isLoading: false, agentDraft: null, @@ -296,12 +327,13 @@ export const useAgentsStore = create()( set({ agentDraft: draft }); }, - loadAgents: async () => { - const configDirectory = getConfigDirectory(); + loadAgents: async (requestedDirectory?: string | null) => { + const configDirectory = resolveDirectory(requestedDirectory); const cacheKey = getAgentsCacheKey(configDirectory); + const isAmbient = cacheKey === getAgentsCacheKey(getConfigDirectory()); const now = Date.now(); const loadedAt = agentsLastLoadedAt.get(cacheKey) ?? 0; - const hasCachedAgents = get().agents.length > 0; + const hasCachedAgents = (get().agentsByDirectory[cacheKey] ?? (isAmbient ? get().agents : [])).length > 0; if (hasCachedAgents && now - loadedAt < AGENTS_LOAD_CACHE_TTL_MS) { return true; @@ -314,7 +346,9 @@ export const useAgentsStore = create()( const request = (async () => { set({ isLoading: true }); - const previousAgents = get().agents; + // Failure must never look like an empty project. The mirror is the + // fallback so a directory loaded before this map existed still counts. + const previousAgents = get().agentsByDirectory[cacheKey] ?? (isAmbient ? get().agents : []); const previousSignature = buildAgentsSignature(previousAgents); for (let attempt = 0; attempt < 3; attempt++) { @@ -372,7 +406,14 @@ export const useAgentsStore = create()( const nextSignature = buildAgentsSignature(agentsWithScope); if (previousSignature !== nextSignature) { - set({ agents: agentsWithScope, isLoading: false }); + set((state) => { + const next: Partial = { + agentsByDirectory: { ...state.agentsByDirectory, [cacheKey]: agentsWithScope }, + isLoading: false, + }; + if (isAmbient) next.agents = agentsWithScope; + return next; + }); } else { set({ isLoading: false }); } @@ -395,7 +436,7 @@ export const useAgentsStore = create()( } }, - createAgent: async (config: AgentConfig) => { + createAgent: async (config: AgentConfig, requestedDirectory?: string | null) => { try { console.log('[AgentsStore] Creating agent:', config.name); @@ -415,7 +456,7 @@ export const useAgentsStore = create()( console.log('[AgentsStore] Agent config to save:', agentConfig); - const configDirectory = getConfigDirectory(); + const configDirectory = resolveDirectory(requestedDirectory); const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(config.name)}${queryParams}`, { @@ -458,7 +499,7 @@ export const useAgentsStore = create()( return { ok: true }; } - const loaded = await get().loadAgents(); + const loaded = await get().loadAgents(configDirectory); if (loaded) { emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE }); } @@ -471,7 +512,7 @@ export const useAgentsStore = create()( } }, - updateAgent: async (name: string, config: Partial) => { + updateAgent: async (name: string, config: Partial, requestedDirectory?: string | null) => { try { const agentConfig: Record = {}; @@ -485,7 +526,7 @@ export const useAgentsStore = create()( if (config.permission !== undefined) agentConfig.permission = config.permission; if (config.disable !== undefined) agentConfig.disable = config.disable; - const configDirectory = getConfigDirectory(); + const configDirectory = resolveDirectory(requestedDirectory); const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, { @@ -528,7 +569,7 @@ export const useAgentsStore = create()( return { ok: true }; } - const loaded = await get().loadAgents(); + const loaded = await get().loadAgents(configDirectory); if (loaded) { emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE }); } @@ -541,9 +582,9 @@ export const useAgentsStore = create()( } }, - deleteAgent: async (name: string, scope?: AgentScope) => { + deleteAgent: async (name: string, scope?: AgentScope, requestedDirectory?: string | null) => { try { - const configDirectory = getConfigDirectory(); + const configDirectory = resolveDirectory(requestedDirectory); const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, { @@ -594,7 +635,7 @@ export const useAgentsStore = create()( return { ok: true }; } - const loaded = await get().loadAgents(); + const loaded = await get().loadAgents(configDirectory); if (loaded) { emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE }); } @@ -609,14 +650,12 @@ export const useAgentsStore = create()( }, - getAgentByName: (name: string) => { - const { agents } = get(); - return agents.find((a) => a.name === name); + getAgentByName: (name: string, requestedDirectory?: string | null) => { + return selectAgentsForDirectory(get(), requestedDirectory).find((agent) => agent.name === name); }, - getVisibleAgents: () => { - const { agents } = get(); - return filterVisibleAgents(agents); + getVisibleAgents: (requestedDirectory?: string | null) => { + return filterVisibleAgents(selectAgentsForDirectory(get(), requestedDirectory)); }, }), { diff --git a/packages/ui/src/stores/useCommandsStore.test.ts b/packages/ui/src/stores/useCommandsStore.test.ts index 454510f7..419ec682 100644 --- a/packages/ui/src/stores/useCommandsStore.test.ts +++ b/packages/ui/src/stores/useCommandsStore.test.ts @@ -66,11 +66,38 @@ describe('useCommandsStore', () => { useCommandsStore.setState({ selectedCommandName: null, commands: [], + commandsByDirectory: {}, isLoading: false, commandDraft: null, }); }); + test('loading another project leaves the active project\'s commands alone', async () => { + // Settings can browse a project the app is not on. Chat reads `commands`, + // so that list must keep describing the active project. + const activeCommands = [{ + name: 'active-only', + description: 'Active project command', + template: 'run it', + scope: 'project' as const, + }]; + useCommandsStore.setState({ + commands: activeCommands, + commandsByDirectory: { [activeProjectPath]: activeCommands }, + }); + listCommandsWithDetailsImpl = async () => [ + { name: 'other-only', description: 'Other project command', template: 'run there' }, + ]; + + const result = await useCommandsStore.getState().loadCommands('/workspace/other'); + + expect(result).toBe(true); + const state = useCommandsStore.getState(); + expect(state.commands).toEqual(activeCommands); + expect(state.commandsByDirectory['/workspace/other']?.map((command) => command.name)).toEqual(['other-only']); + expect(state.commandsByDirectory[activeProjectPath]).toEqual(activeCommands); + }); + test('loadCommands preserves previous commands when the command list fails', async () => { const previousCommands = [{ name: 'existing', diff --git a/packages/ui/src/stores/useCommandsStore.ts b/packages/ui/src/stores/useCommandsStore.ts index e9aab754..31838ec2 100644 --- a/packages/ui/src/stores/useCommandsStore.ts +++ b/packages/ui/src/stores/useCommandsStore.ts @@ -67,12 +67,16 @@ const buildCommandsSignature = (commands: Command[]): string => { }; const upsertCommandLocal = ( - set: (state: Partial) => void, + set: (updater: (state: CommandsStore) => Partial) => void, get: () => CommandsStore, name: string, config: Partial, + directory: string | null, ) => { - const existing = get().commands.find((command) => command.name === name); + const cacheKey = getCommandsCacheKey(directory); + const isAmbient = cacheKey === getCommandsCacheKey(getRequestDirectory()); + const current = get().commandsByDirectory[cacheKey] ?? []; + const existing = current.find((command) => command.name === name); const nextCommand: Command = { ...existing, name, @@ -81,25 +85,49 @@ const upsertCommandLocal = ( scope: config.scope ?? existing?.scope, isBuiltIn: existing?.isBuiltIn, }; - const commands = get().commands; - const nextCommands = commands.some((command) => command.name === name) - ? commands.map((command) => (command.name === name ? nextCommand : command)) - : [...commands, nextCommand]; - set({ commands: nextCommands }); + const nextCommands = current.some((command) => command.name === name) + ? current.map((command) => (command.name === name ? nextCommand : command)) + : [...current, nextCommand]; + set((state) => { + const next: Partial = { + commandsByDirectory: { ...state.commandsByDirectory, [cacheKey]: nextCommands }, + }; + if (isAmbient) next.commands = nextCommands; + return next; + }); }; const removeCommandLocal = ( - set: (state: Partial) => void, + set: (updater: (state: CommandsStore) => Partial) => void, get: () => CommandsStore, name: string, + directory: string | null, ) => { - const nextState: Partial = { - commands: get().commands.filter((command) => command.name !== name), - }; - if (get().selectedCommandName === name) { - nextState.selectedCommandName = null; + const cacheKey = getCommandsCacheKey(directory); + const isAmbient = cacheKey === getCommandsCacheKey(getRequestDirectory()); + const nextCommands = (get().commandsByDirectory[cacheKey] ?? []).filter((command) => command.name !== name); + const clearSelection = get().selectedCommandName === name; + set((state) => { + const next: Partial = { + commandsByDirectory: { ...state.commandsByDirectory, [cacheKey]: nextCommands }, + }; + if (isAmbient) next.commands = nextCommands; + if (clearSelection) next.selectedCommandName = null; + return next; + }); +}; + +/** + * 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; } - set(nextState); + return getRequestDirectory(); }; const getRequestDirectory = (): string | null => { @@ -143,17 +171,20 @@ export interface CommandDraft { interface CommandsStore { selectedCommandName: string | null; + /** Commands of the project the app is on. Chat and autocompletes read this one. */ commands: Command[]; + /** Every directory loaded so far, including the ambient one. */ + commandsByDirectory: Record; isLoading: boolean; commandDraft: CommandDraft | null; setSelectedCommand: (name: string | null) => void; setCommandDraft: (draft: CommandDraft | null) => void; - loadCommands: () => Promise; - createCommand: (config: CommandConfig) => Promise; - updateCommand: (name: string, config: Partial) => Promise; - deleteCommand: (name: string) => Promise; - getCommandByName: (name: string) => Command | undefined; + loadCommands: (directory?: string | null) => Promise; + createCommand: (config: CommandConfig, directory?: string | null) => Promise; + updateCommand: (name: string, config: Partial, directory?: string | null) => Promise; + deleteCommand: (name: string, directory?: string | null) => Promise; + getCommandByName: (name: string, directory?: string | null) => Command | undefined; } declare global { @@ -162,6 +193,20 @@ declare global { } } +const EMPTY_COMMANDS: Command[] = []; + +/** + * Commands 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 selectCommandsForDirectory = ( + state: Pick, + directory?: string | null, +): Command[] => { + const cacheKey = getCommandsCacheKey(resolveDirectory(directory)); + return state.commandsByDirectory[cacheKey] ?? EMPTY_COMMANDS; +}; + export const useCommandsStore = create()( devtools( persist( @@ -169,6 +214,7 @@ export const useCommandsStore = create()( selectedCommandName: null, commands: [], + commandsByDirectory: {}, isLoading: false, commandDraft: null, @@ -180,12 +226,13 @@ export const useCommandsStore = create()( set({ commandDraft: draft }); }, - loadCommands: async () => { - const directory = getRequestDirectory(); + loadCommands: async (requestedDirectory?: string | null) => { + const directory = resolveDirectory(requestedDirectory); const cacheKey = getCommandsCacheKey(directory); + const isAmbient = cacheKey === getCommandsCacheKey(getRequestDirectory()); const now = Date.now(); const loadedAt = commandsLastLoadedAt.get(cacheKey) ?? 0; - const hasCachedCommands = get().commands.length > 0; + const hasCachedCommands = (get().commandsByDirectory[cacheKey] ?? (isAmbient ? get().commands : [])).length > 0; if (hasCachedCommands && now - loadedAt < COMMANDS_LOAD_CACHE_TTL_MS) { return true; @@ -198,7 +245,9 @@ export const useCommandsStore = create()( const request = (async () => { set({ isLoading: true }); - const previousCommands = get().commands; + // Failure must never look like an empty project. The mirror is the + // fallback so a directory loaded before this map existed still counts. + const previousCommands = get().commandsByDirectory[cacheKey] ?? (isAmbient ? get().commands : []); const previousSignature = buildCommandsSignature(previousCommands); let lastError: unknown = null; @@ -255,7 +304,14 @@ export const useCommandsStore = create()( const nextSignature = buildCommandsSignature(commandsWithScope); if (previousSignature !== nextSignature) { - set({ commands: commandsWithScope, isLoading: false }); + set((state) => { + const next: Partial = { + commandsByDirectory: { ...state.commandsByDirectory, [cacheKey]: commandsWithScope }, + isLoading: false, + }; + if (isAmbient) next.commands = commandsWithScope; + return next; + }); } else { set({ isLoading: false }); } @@ -269,7 +325,14 @@ export const useCommandsStore = create()( } console.error("Failed to load commands:", lastError); - set({ commands: previousCommands, isLoading: false }); + set((state) => { + const next: Partial = { + commandsByDirectory: { ...state.commandsByDirectory, [cacheKey]: previousCommands }, + isLoading: false, + }; + if (isAmbient) next.commands = previousCommands; + return next; + }); return false; })(); @@ -281,7 +344,7 @@ export const useCommandsStore = create()( } }, - createCommand: async (config: CommandConfig) => { + createCommand: async (config: CommandConfig, requestedDirectory?: string | null) => { try { console.log('[CommandsStore] Creating command:', config.name); @@ -296,7 +359,7 @@ export const useCommandsStore = create()( console.log('[CommandsStore] Command config to save:', commandConfig); - const directory = getRequestDirectory(); + const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(config.name)}${queryParams}`, { @@ -319,12 +382,12 @@ export const useCommandsStore = create()( invalidateCommandsLoadCache(directory); if (payload?.requiresManualRestart) { - upsertCommandLocal(set, get, config.name, config); + upsertCommandLocal(set, get, config.name, config, directory); return true; } if (noteDeferredRestartFromPayload(payload, 'commands', { id: config.name })) { - upsertCommandLocal(set, get, config.name, config); + upsertCommandLocal(set, get, config.name, config, directory); emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE }); return true; } @@ -338,7 +401,7 @@ export const useCommandsStore = create()( return true; } - const loaded = await get().loadCommands(); + const loaded = await get().loadCommands(directory); if (loaded) { emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE }); } @@ -349,7 +412,7 @@ export const useCommandsStore = create()( } }, - updateCommand: async (name: string, config: Partial) => { + updateCommand: async (name: string, config: Partial, requestedDirectory?: string | null) => { try { console.log('[CommandsStore] Updating command:', name); console.log('[CommandsStore] Config received:', config); @@ -363,7 +426,7 @@ export const useCommandsStore = create()( console.log('[CommandsStore] Command config to update:', commandConfig); - const directory = getRequestDirectory(); + const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, { @@ -386,12 +449,12 @@ export const useCommandsStore = create()( invalidateCommandsLoadCache(directory); if (payload?.requiresManualRestart) { - upsertCommandLocal(set, get, name, config); + upsertCommandLocal(set, get, name, config, directory); return true; } if (noteDeferredRestartFromPayload(payload, 'commands', { id: name })) { - upsertCommandLocal(set, get, name, config); + upsertCommandLocal(set, get, name, config, directory); emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE }); return true; } @@ -405,7 +468,7 @@ export const useCommandsStore = create()( return true; } - const loaded = await get().loadCommands(); + const loaded = await get().loadCommands(directory); if (loaded) { emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE }); } @@ -416,10 +479,10 @@ export const useCommandsStore = create()( } }, - deleteCommand: async (name: string) => { + deleteCommand: async (name: string, requestedDirectory?: string | null) => { try { // Use active project root for project-level command support - const directory = getRequestDirectory(); + const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, { @@ -438,12 +501,12 @@ export const useCommandsStore = create()( invalidateCommandsLoadCache(directory); if (payload?.requiresManualRestart) { - removeCommandLocal(set, get, name); + removeCommandLocal(set, get, name, directory); return true; } if (noteDeferredRestartFromPayload(payload, 'commands', { id: name })) { - removeCommandLocal(set, get, name); + removeCommandLocal(set, get, name, directory); emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE }); return true; } @@ -457,7 +520,7 @@ export const useCommandsStore = create()( return true; } - const loaded = await get().loadCommands(); + const loaded = await get().loadCommands(directory); if (loaded) { emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE }); } @@ -473,9 +536,8 @@ export const useCommandsStore = create()( } }, - getCommandByName: (name: string) => { - const { commands } = get(); - return commands.find((c) => c.name === name); + getCommandByName: (name: string, requestedDirectory?: string | null) => { + return selectCommandsForDirectory(get(), requestedDirectory).find((command) => command.name === name); }, }), { diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 0ab496df..4977d314 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -1134,6 +1134,26 @@ const _inFlightProviders = new Map>(); const _inFlightAgents = new Map>(); let _initializeAppInFlight: Promise | null = null; +/** + * Providers of one project. Returns a stored array, so components can select it + * directly and re-render only when that project's list is replaced. + * + * Settings pages browse a project the app is not on; everything else wants the + * active one, which is what an omitted directory resolves to. + */ +export const selectProvidersForDirectory = ( + state: Pick, + directory?: string | null, +): ProviderWithModelList[] => { + const directoryKey = toConfigDirectoryKey(directory); + if (directoryKey === state.activeDirectoryKey) { + return state.providers; + } + return state.directoryScoped[directoryKey]?.providers ?? EMPTY_PROVIDERS; +}; + +const EMPTY_PROVIDERS: ProviderWithModelList[] = []; + export const useConfigStore = create()( devtools( persist( diff --git a/packages/ui/src/stores/useMcpConfigStore.ts b/packages/ui/src/stores/useMcpConfigStore.ts index 18ee8463..24303fcc 100644 --- a/packages/ui/src/stores/useMcpConfigStore.ts +++ b/packages/ui/src/stores/useMcpConfigStore.ts @@ -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; selectedMcpName: string | null; isLoading: boolean; mcpDraft: McpDraft | null; setSelectedMcp: (name: string | null) => void; setMcpDraft: (draft: McpDraft | null) => void; - loadMcpConfigs: (options?: { force?: boolean }) => Promise; - createMcp: (config: McpDraft) => Promise; - updateMcp: (name: string, config: Partial) => Promise; - deleteMcp: (name: string) => Promise; - getMcpByName: (name: string) => McpServerWithScope | undefined; + loadMcpConfigs: (options?: { force?: boolean; directory?: string | null }) => Promise; + createMcp: (config: McpDraft, directory?: string | null) => Promise; + updateMcp: (name: string, config: Partial, directory?: string | null) => Promise; + deleteMcp: (name: string, directory?: string | null) => Promise; + 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, + directory?: string | null, +): McpServerWithScope[] => { + const cacheKey = getMcpCacheKey(resolveDirectory(directory)); + return state.serversByDirectory[cacheKey] ?? EMPTY_MCP_SERVERS; +}; + export const useMcpConfigStore = create()( devtools( persist( (set, get) => ({ mcpServers: [], + serversByDirectory: {}, selectedMcpName: null, isLoading: false, mcpDraft: null, @@ -147,11 +179,12 @@ export const useMcpConfigStore = create()( 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()( throw new Error('Failed to load MCP configs'); } const data: McpServerWithScope[] = await response.json(); - set({ mcpServers: data, isLoading: false }); + set((state) => { + const next: Partial = { + 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()( } }, - 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()( 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()( } 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()( 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()( }; } - 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()( } }, - updateMcp: async (name: string, config: Partial) => { + updateMcp: async (name: string, config: Partial, 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()( 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()( } 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()( 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()( }; } - 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()( } }, - 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()( } 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()( } 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()( }); } - 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()( } }, - 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); }, }), { diff --git a/packages/ui/src/stores/useSkillsStore.test.ts b/packages/ui/src/stores/useSkillsStore.test.ts index 3c569d98..45fcab57 100644 --- a/packages/ui/src/stores/useSkillsStore.test.ts +++ b/packages/ui/src/stores/useSkillsStore.test.ts @@ -77,14 +77,42 @@ describe('useSkillsStore directory resolution', () => { }); invalidateSkillsLoadCache(activeProjectPath); + invalidateSkillsLoadCache('/workspace/other-project'); useSkillsStore.setState({ selectedSkillName: null, skills: [], + skillsByDirectory: {}, isLoading: false, skillDraft: null, }); }); + test('loading another project leaves the active project\'s skills alone', async () => { + // Settings can browse a project the app is not on. Chat autocompletes read + // `skills`, so that list must keep describing the active project. + const activeSkills = [{ + name: 'active-only', + path: `${activeProjectPath}/.agents/skills/active-only/SKILL.md`, + scope: 'project' as const, + source: 'agents' as const, + description: 'Active project skill', + group: undefined, + renamable: false, + }]; + useSkillsStore.setState({ + skills: activeSkills, + skillsByDirectory: { [activeProjectPath]: activeSkills }, + }); + + const loaded = await useSkillsStore.getState().loadSkills('/workspace/other-project'); + + expect(loaded).toBe(true); + expect(runtimeFetchCalls[0]?.url).toContain(`directory=${encodeURIComponent('/workspace/other-project')}`); + const state = useSkillsStore.getState(); + expect(state.skills).toEqual(activeSkills); + expect(state.skillsByDirectory['/workspace/other-project']?.map((skill) => skill.name)).toEqual(['repo-local-skill']); + }); + test('loadSkills scopes discovery to the active project even when client directory is unset', async () => { const loaded = await useSkillsStore.getState().loadSkills(); diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index 4eecc87d..a4c6a4e2 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -20,6 +20,19 @@ import { filterSkillsByRuntimeFlags } from './skillVisibility'; // project selector (and Commands/Agents). Falling back only to the session // directory misses repository-local `.agents/skills` when the client directory // is unset or points elsewhere while an active project exists. +/** + * 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 getRequestDirectory(); +}; + const getRequestDirectory = (): string | null => { try { const projectsStore = useProjectsStore.getState(); @@ -144,24 +157,27 @@ interface SkillDetail { interface SkillsStore { selectedSkillName: string | null; + /** Skills of the project the app is on. Chat and autocompletes read this one. */ skills: DiscoveredSkill[]; + /** Every directory loaded so far, including the ambient one. */ + skillsByDirectory: Record; isLoading: boolean; skillDraft: SkillDraft | null; setSelectedSkill: (name: string | null) => void; setSkillDraft: (draft: SkillDraft | null) => void; - loadSkills: () => Promise; - getSkillDetail: (name: string) => Promise; - createSkill: (config: SkillConfig) => Promise; - updateSkill: (name: string, config: Partial) => Promise; - renameSkill: (name: string, newName: string) => Promise; - deleteSkill: (name: string) => Promise; - getSkillByName: (name: string) => DiscoveredSkill | undefined; - + loadSkills: (directory?: string | null) => Promise; + getSkillDetail: (name: string, directory?: string | null) => Promise; + createSkill: (config: SkillConfig, directory?: string | null) => Promise; + updateSkill: (name: string, config: Partial, directory?: string | null) => Promise; + renameSkill: (name: string, newName: string, directory?: string | null) => Promise; + deleteSkill: (name: string, directory?: string | null) => Promise; + getSkillByName: (name: string, directory?: string | null) => DiscoveredSkill | undefined; + // Supporting files - readSupportingFile: (skillName: string, filePath: string) => Promise; - writeSupportingFile: (skillName: string, filePath: string, content: string) => Promise; - deleteSupportingFile: (skillName: string, filePath: string) => Promise; + readSupportingFile: (skillName: string, filePath: string, directory?: string | null) => Promise; + writeSupportingFile: (skillName: string, filePath: string, content: string, directory?: string | null) => Promise; + deleteSupportingFile: (skillName: string, filePath: string, directory?: string | null) => Promise; } declare global { @@ -186,12 +202,16 @@ export const invalidateSkillsLoadCache = (directory: string | null = getRequestD }; const upsertSkillLocal = ( - set: (state: Partial) => void, + set: (updater: (state: SkillsStore) => Partial) => void, get: () => SkillsStore, name: string, config: Partial, + directory: string | null, ) => { - const existing = get().skills.find((skill) => skill.name === name); + const cacheKey = getSkillsCacheKey(directory); + const isAmbient = cacheKey === getSkillsCacheKey(getRequestDirectory()); + const current = get().skillsByDirectory[cacheKey] ?? []; + const existing = current.find((skill) => skill.name === name); const path = config.targetPath ?? existing?.path ?? ''; const nextSkill: DiscoveredSkill = { ...existing, @@ -202,11 +222,16 @@ const upsertSkillLocal = ( description: config.description ?? existing?.description ?? '', group: parseSkillGroup(path), }; - const skills = get().skills; - const nextSkills = skills.some((skill) => skill.name === name) - ? skills.map((skill) => (skill.name === name ? nextSkill : skill)) - : [...skills, nextSkill]; - set({ skills: nextSkills }); + const nextSkills = current.some((skill) => skill.name === name) + ? current.map((skill) => (skill.name === name ? nextSkill : skill)) + : [...current, nextSkill]; + set((state) => { + const next: Partial = { + skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: nextSkills }, + }; + if (isAmbient) next.skills = nextSkills; + return next; + }); }; const removeSkillLocal = ( @@ -230,12 +255,27 @@ const SLOW_HEALTH_POLL_BASE_MS = 800; const SLOW_HEALTH_POLL_INCREMENT_MS = 200; const SLOW_HEALTH_POLL_MAX_MS = 2000; +const EMPTY_SKILLS: DiscoveredSkill[] = []; + +/** + * Skills 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 selectSkillsForDirectory = ( + state: Pick, + directory?: string | null, +): DiscoveredSkill[] => { + const cacheKey = getSkillsCacheKey(resolveDirectory(directory)); + return state.skillsByDirectory[cacheKey] ?? EMPTY_SKILLS; +}; + export const useSkillsStore = create()( devtools( persist( (set, get) => ({ selectedSkillName: null, skills: [], + skillsByDirectory: {}, isLoading: false, skillDraft: null, @@ -247,12 +287,13 @@ export const useSkillsStore = create()( set({ skillDraft: draft }); }, - loadSkills: async () => { - const directory = getRequestDirectory(); + loadSkills: async (requestedDirectory?: string | null) => { + const directory = resolveDirectory(requestedDirectory); const cacheKey = getSkillsCacheKey(directory); + const isAmbient = cacheKey === getSkillsCacheKey(getRequestDirectory()); const now = Date.now(); const loadedAt = skillsLastLoadedAt.get(cacheKey) ?? 0; - const hasCachedSkills = get().skills.length > 0; + const hasCachedSkills = (get().skillsByDirectory[cacheKey] ?? (isAmbient ? get().skills : [])).length > 0; if (hasCachedSkills && now - loadedAt < SKILLS_LOAD_CACHE_TTL_MS) { return true; @@ -265,7 +306,9 @@ export const useSkillsStore = create()( const request = (async () => { set({ isLoading: true }); - const previousSkills = get().skills; + // Failure must never look like an empty project. The mirror is the + // fallback so a directory loaded before this map existed still counts. + const previousSkills = get().skillsByDirectory[cacheKey] ?? (isAmbient ? get().skills : []); let lastError: unknown = null; for (let attempt = 0; attempt < 3; attempt++) { @@ -306,7 +349,14 @@ export const useSkillsStore = create()( data.externalSkills ?? null, ); - set({ skills: visibleSkills, isLoading: false }); + set((state) => { + const next: Partial = { + skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: visibleSkills }, + isLoading: false, + }; + if (isAmbient) next.skills = visibleSkills; + return next; + }); skillsLastLoadedAt.set(cacheKey, Date.now()); return true; } catch (error) { @@ -317,7 +367,14 @@ export const useSkillsStore = create()( } console.error("Failed to load skills:", lastError); - set({ skills: previousSkills, isLoading: false }); + set((state) => { + const next: Partial = { + skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: previousSkills }, + isLoading: false, + }; + if (isAmbient) next.skills = previousSkills; + return next; + }); return false; })(); @@ -329,9 +386,9 @@ export const useSkillsStore = create()( } }, - getSkillDetail: async (name: string) => { + getSkillDetail: async (name: string, requestedDirectory?: string | null) => { try { - const directory = getRequestDirectory(); + const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, { @@ -347,7 +404,7 @@ export const useSkillsStore = create()( } }, - createSkill: async (config: SkillConfig) => { + createSkill: async (config: SkillConfig, requestedDirectory?: string | null) => { try { const skillConfig: Record = { name: config.name, @@ -359,7 +416,7 @@ export const useSkillsStore = create()( if (config.source) skillConfig.source = config.source; if (config.supportingFiles) skillConfig.supportingFiles = config.supportingFiles; - const directory = getRequestDirectory(); + const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(config.name)}${queryParams}`, { @@ -380,12 +437,12 @@ export const useSkillsStore = create()( invalidateSkillsLoadCache(directory); if (payload?.requiresManualRestart) { - upsertSkillLocal(set, get, config.name, config); + upsertSkillLocal(set, get, config.name, config, directory); return true; } if (noteDeferredRestartFromPayload(payload, 'skills', { id: config.name })) { - upsertSkillLocal(set, get, config.name, config); + upsertSkillLocal(set, get, config.name, config, directory); emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); return true; } @@ -399,7 +456,7 @@ export const useSkillsStore = create()( return true; } - const loaded = await get().loadSkills(); + const loaded = await get().loadSkills(directory); if (loaded) { emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); } @@ -409,7 +466,7 @@ export const useSkillsStore = create()( } }, - updateSkill: async (name: string, config: Partial) => { + updateSkill: async (name: string, config: Partial, requestedDirectory?: string | null) => { try { const skillConfig: Record = {}; @@ -418,7 +475,7 @@ export const useSkillsStore = create()( if (config.supportingFiles !== undefined) skillConfig.supportingFiles = config.supportingFiles; if (config.targetPath !== undefined) skillConfig.targetPath = config.targetPath; - const directory = getRequestDirectory(); + const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, { @@ -439,12 +496,12 @@ export const useSkillsStore = create()( invalidateSkillsLoadCache(directory); if (payload?.requiresManualRestart) { - upsertSkillLocal(set, get, name, config); + upsertSkillLocal(set, get, name, config, directory); return true; } if (noteDeferredRestartFromPayload(payload, 'skills', { id: name })) { - upsertSkillLocal(set, get, name, config); + upsertSkillLocal(set, get, name, config, directory); emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); return true; } @@ -458,7 +515,7 @@ export const useSkillsStore = create()( return true; } - const loaded = await get().loadSkills(); + const loaded = await get().loadSkills(directory); if (loaded) { emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); } @@ -468,11 +525,11 @@ export const useSkillsStore = create()( } }, - renameSkill: async (name: string, newName: string) => { + renameSkill: async (name: string, newName: string, requestedDirectory?: string | null) => { startConfigUpdate("Renaming skill..."); let requiresReload = false; try { - const directory = getRequestDirectory(); + const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, { @@ -501,7 +558,7 @@ export const useSkillsStore = create()( return true; } - const loaded = await get().loadSkills(); + const loaded = await get().loadSkills(directory); if (loaded) { emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); } @@ -515,9 +572,9 @@ export const useSkillsStore = create()( } }, - deleteSkill: async (name: string) => { + deleteSkill: async (name: string, requestedDirectory?: string | null) => { try { - const directory = getRequestDirectory(); + const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, { @@ -553,7 +610,7 @@ export const useSkillsStore = create()( return true; } - const loaded = await get().loadSkills(); + const loaded = await get().loadSkills(directory); if (loaded) { emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); } @@ -568,14 +625,13 @@ export const useSkillsStore = create()( } }, - getSkillByName: (name: string) => { - const { skills } = get(); - return skills.find((s) => s.name === name); + getSkillByName: (name: string, requestedDirectory?: string | null) => { + return selectSkillsForDirectory(get(), requestedDirectory).find((skill) => skill.name === name); }, - readSupportingFile: async (skillName: string, filePath: string) => { + readSupportingFile: async (skillName: string, filePath: string, requestedDirectory?: string | null) => { try { - const directory = getRequestDirectory(); + const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `&directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch( @@ -593,9 +649,9 @@ export const useSkillsStore = create()( } }, - writeSupportingFile: async (skillName: string, filePath: string, content: string) => { + writeSupportingFile: async (skillName: string, filePath: string, content: string, requestedDirectory?: string | null) => { try { - const directory = getRequestDirectory(); + const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch( @@ -616,9 +672,9 @@ export const useSkillsStore = create()( } }, - deleteSupportingFile: async (skillName: string, filePath: string) => { + deleteSupportingFile: async (skillName: string, filePath: string, requestedDirectory?: string | null) => { try { - const directory = getRequestDirectory(); + const directory = resolveDirectory(requestedDirectory); const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch( diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 8ed14467..85f4ad02 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -688,6 +688,13 @@ interface UIStore { settingsPage: string; settingsHasOpenedOnce: boolean; settingsProjectsSelectedId: string | null; + /** + * Project the Settings pages are looking at. `null` follows the app's active + * project. Settings browses another project's configuration without moving + * the chat, the session list or the file tree, so this is its own state and + * not a second writer of the active project. + */ + settingsProjectPath: string | null; settingsRemoteInstancesSelectedId: string | null; eventStreamStatus: EventStreamStatus; eventStreamHint: string | null; @@ -883,6 +890,7 @@ interface UIStore { setSidebarSection: (section: SidebarSection) => void; setSettingsPage: (slug: string) => void; setSettingsProjectsSelectedId: (projectId: string | null) => void; + setSettingsProjectPath: (path: string | null) => void; setSettingsRemoteInstancesSelectedId: (instanceId: string | null) => void; setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void; setShowReasoningTraces: (value: boolean) => void; @@ -1055,6 +1063,7 @@ export const useUIStore = create()( settingsPage: 'home', settingsHasOpenedOnce: false, settingsProjectsSelectedId: null, + settingsProjectPath: null, settingsRemoteInstancesSelectedId: null, eventStreamStatus: 'idle', eventStreamHint: null, @@ -1820,6 +1829,11 @@ export const useUIStore = create()( set({ settingsPage: slug }); }, + setSettingsProjectPath: (path) => { + const trimmed = path?.trim(); + set({ settingsProjectPath: trimmed ? trimmed : null }); + }, + setSettingsProjectsSelectedId: (projectId) => { set({ settingsProjectsSelectedId: projectId }); },