refactor(settings): scope the settings project selector to settings
Picking a project in Settings called setActiveProject, which relocates the chat, the session list, the file tree and the Git surface. Reading another project's MCP servers or agents moved the user's whole app. It had to, because the configuration stores resolved the directory themselves from the active project and held one flat list. Each of them now takes an explicit directory — omitted still means the active project, so every caller outside Settings is unchanged — and keys loaded data by directory next to a flat mirror of the active project. Chat, autocompletes and pickers keep reading that mirror; a load for another directory writes only the map. A failed load restores that directory's previous list. Settings resolves its own directory through useSettingsDirectory, backed by a session-local settingsProjectPath that follows the active project until the user picks something else.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -6,10 +6,10 @@ import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import {
|
||||
useAgentsStore,
|
||||
getConfigDirectory,
|
||||
type AgentWithExtras,
|
||||
} from '@/stores/useAgentsStore';
|
||||
import {
|
||||
@@ -105,6 +105,9 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
|
||||
const [reloadToken, setReloadToken] = React.useState(0);
|
||||
|
||||
const agentName = agent.name;
|
||||
// Settings browses whichever project its own selector points at; the app
|
||||
// stays where it is.
|
||||
const settingsDirectory = useSettingsDirectory();
|
||||
|
||||
// --- Load the SOURCE permission map (the agent's own config file). ---
|
||||
React.useEffect(() => {
|
||||
@@ -113,7 +116,7 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
|
||||
setLoadFailed(false);
|
||||
void (async () => {
|
||||
try {
|
||||
const directory = getConfigDirectory();
|
||||
const directory = settingsDirectory;
|
||||
const query = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(agentName)}/config${query}`, {
|
||||
headers: {
|
||||
@@ -136,14 +139,14 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentName, reloadToken]);
|
||||
}, [agentName, reloadToken, settingsDirectory]);
|
||||
|
||||
// --- Known tool ids for the key list (display only). ---
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const ids = await opencodeClient.listToolIds({ directory: getConfigDirectory() });
|
||||
const ids = await opencodeClient.listToolIds({ directory: settingsDirectory });
|
||||
if (!cancelled && Array.isArray(ids)) {
|
||||
setToolIds(ids.filter((id) => typeof id === 'string' && !FOLDED_TOOL_IDS.has(id)));
|
||||
}
|
||||
@@ -154,7 +157,7 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentName]);
|
||||
}, [agentName, settingsDirectory]);
|
||||
|
||||
// --- Effective rules from the resolved view (read-only hints). ---
|
||||
const effectiveRules = React.useMemo<EffectiveRule[]>(() => {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useAgentsStore, type AgentConfig, type AgentMutationResult, type AgentScope } from '@/stores/useAgentsStore';
|
||||
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
||||
import { selectAgentsForDirectory, useAgentsStore, type AgentConfig, type AgentMutationResult, type AgentScope } from '@/stores/useAgentsStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -60,7 +61,6 @@ export const AgentsPage: React.FC = () => {
|
||||
getAgentByName,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
agents,
|
||||
agentDraft,
|
||||
setAgentDraft,
|
||||
} = useAgentsStore(useShallow((s) => ({
|
||||
@@ -68,12 +68,15 @@ export const AgentsPage: React.FC = () => {
|
||||
getAgentByName: s.getAgentByName,
|
||||
createAgent: s.createAgent,
|
||||
updateAgent: s.updateAgent,
|
||||
agents: s.agents,
|
||||
agentDraft: s.agentDraft,
|
||||
setAgentDraft: s.setAgentDraft,
|
||||
})));
|
||||
|
||||
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null;
|
||||
// Settings browses whichever project its own selector points at; the app
|
||||
// stays where it is.
|
||||
const settingsDirectory = useSettingsDirectory();
|
||||
const agents = useAgentsStore((state) => selectAgentsForDirectory(state, settingsDirectory));
|
||||
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName, settingsDirectory) : null;
|
||||
const isNewAgent = Boolean(agentDraft && agentDraft.name === selectedAgentName && !selectedAgent);
|
||||
|
||||
const [draftName, setDraftName] = React.useState('');
|
||||
@@ -232,12 +235,12 @@ export const AgentsPage: React.FC = () => {
|
||||
|
||||
let result: AgentMutationResult;
|
||||
if (isNewAgent) {
|
||||
result = await createAgent(config);
|
||||
result = await createAgent(config, settingsDirectory);
|
||||
if (result.ok) {
|
||||
setAgentDraft(null); // Clear draft after successful creation
|
||||
}
|
||||
} else {
|
||||
result = await updateAgent(agentName, config);
|
||||
result = await updateAgent(agentName, config, settingsDirectory);
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
|
||||
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
||||
import { selectAgentsForDirectory, useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Agent } from '@opencode-ai/sdk/v2';
|
||||
@@ -113,7 +114,6 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
|
||||
const {
|
||||
selectedAgentName,
|
||||
agents,
|
||||
setSelectedAgent,
|
||||
setAgentDraft,
|
||||
createAgent,
|
||||
@@ -121,7 +121,6 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
loadAgents,
|
||||
} = useAgentsStore(useShallow((s) => ({
|
||||
selectedAgentName: s.selectedAgentName,
|
||||
agents: s.agents,
|
||||
setSelectedAgent: s.setSelectedAgent,
|
||||
setAgentDraft: s.setAgentDraft,
|
||||
createAgent: s.createAgent,
|
||||
@@ -129,9 +128,14 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
loadAgents: s.loadAgents,
|
||||
})));
|
||||
|
||||
// Settings browses whichever project its own selector points at; the app
|
||||
// stays where it is.
|
||||
const settingsDirectory = useSettingsDirectory();
|
||||
const agents = useAgentsStore((state) => selectAgentsForDirectory(state, settingsDirectory));
|
||||
|
||||
React.useEffect(() => {
|
||||
loadAgents();
|
||||
}, [loadAgents]);
|
||||
void loadAgents(settingsDirectory);
|
||||
}, [loadAgents, settingsDirectory]);
|
||||
|
||||
const bgClass = 'bg-background';
|
||||
|
||||
@@ -183,7 +187,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
|
||||
setIsConfirmActionPending(true);
|
||||
try {
|
||||
const result = await deleteAgent(confirmActionAgent.name, (confirmActionAgent as Agent & { scope?: AgentScope }).scope);
|
||||
const result = await deleteAgent(confirmActionAgent.name, (confirmActionAgent as Agent & { scope?: AgentScope }).scope, settingsDirectory);
|
||||
|
||||
if (result.ok) {
|
||||
if (result.requiresManualRestart) {
|
||||
@@ -291,11 +295,11 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
permission: rulesetToPermissionConfig(renameDialogAgent.permission),
|
||||
disable: renameExt.disable,
|
||||
scope: renameExt.scope,
|
||||
});
|
||||
}, settingsDirectory);
|
||||
|
||||
if (createResult.ok) {
|
||||
// Delete old agent
|
||||
const deleteResult = await deleteAgent(renameDialogAgent.name, renameExt.scope);
|
||||
const deleteResult = await deleteAgent(renameDialogAgent.name, renameExt.scope, settingsDirectory);
|
||||
if (deleteResult.ok) {
|
||||
if (createResult.requiresManualRestart || deleteResult.requiresManualRestart) {
|
||||
toast.warning(t('settings.agents.page.toast.savedManualRestart'));
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore';
|
||||
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
||||
import { selectCommandsForDirectory, useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore';
|
||||
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { ModelSelector } from '../agents/ModelSelector';
|
||||
@@ -33,7 +34,6 @@ export const CommandsPage: React.FC = () => {
|
||||
getCommandByName,
|
||||
createCommand,
|
||||
updateCommand,
|
||||
commands,
|
||||
commandDraft,
|
||||
setCommandDraft,
|
||||
} = useCommandsStore(useShallow((s) => ({
|
||||
@@ -41,12 +41,15 @@ export const CommandsPage: React.FC = () => {
|
||||
getCommandByName: s.getCommandByName,
|
||||
createCommand: s.createCommand,
|
||||
updateCommand: s.updateCommand,
|
||||
commands: s.commands,
|
||||
commandDraft: s.commandDraft,
|
||||
setCommandDraft: s.setCommandDraft,
|
||||
})));
|
||||
|
||||
const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName) : null;
|
||||
// Settings browses whichever project its own selector points at; the app
|
||||
// stays where it is.
|
||||
const settingsDirectory = useSettingsDirectory();
|
||||
const commands = useCommandsStore((state) => selectCommandsForDirectory(state, settingsDirectory));
|
||||
const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName, settingsDirectory) : null;
|
||||
const isNewCommand = Boolean(commandDraft && commandDraft.name === selectedCommandName && !selectedCommand);
|
||||
|
||||
const [draftName, setDraftName] = React.useState('');
|
||||
@@ -162,12 +165,12 @@ export const CommandsPage: React.FC = () => {
|
||||
|
||||
let success: boolean;
|
||||
if (isNewCommand) {
|
||||
success = await createCommand(config);
|
||||
success = await createCommand(config, settingsDirectory);
|
||||
if (success) {
|
||||
setCommandDraft(null);
|
||||
}
|
||||
} else {
|
||||
success = await updateCommand(commandName, config);
|
||||
success = await updateCommand(commandName, config, settingsDirectory);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
|
||||
@@ -18,8 +18,9 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
import { useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { selectCommandsForDirectory, useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
|
||||
import { selectSkillsForDirectory, useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -43,7 +44,6 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
|
||||
const {
|
||||
selectedCommandName,
|
||||
commands,
|
||||
setSelectedCommand,
|
||||
setCommandDraft,
|
||||
createCommand,
|
||||
@@ -51,20 +51,23 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
loadCommands,
|
||||
} = useCommandsStore(useShallow((s) => ({
|
||||
selectedCommandName: s.selectedCommandName,
|
||||
commands: s.commands,
|
||||
setSelectedCommand: s.setSelectedCommand,
|
||||
setCommandDraft: s.setCommandDraft,
|
||||
createCommand: s.createCommand,
|
||||
deleteCommand: s.deleteCommand,
|
||||
loadCommands: s.loadCommands,
|
||||
})));
|
||||
const skills = useSkillsStore((s) => s.skills);
|
||||
// Settings browses whichever project its own selector points at; the app
|
||||
// stays where it is.
|
||||
const settingsDirectory = useSettingsDirectory();
|
||||
const commands = useCommandsStore((state) => selectCommandsForDirectory(state, settingsDirectory));
|
||||
const skills = useSkillsStore((state) => selectSkillsForDirectory(state, settingsDirectory));
|
||||
const loadSkills = useSkillsStore((s) => s.loadSkills);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadCommands();
|
||||
loadSkills();
|
||||
}, [loadCommands, loadSkills]);
|
||||
void loadCommands(settingsDirectory);
|
||||
void loadSkills(settingsDirectory);
|
||||
}, [loadCommands, loadSkills, settingsDirectory]);
|
||||
|
||||
const skillNames = React.useMemo(() => new Set(skills.map((skill) => skill.name)), [skills]);
|
||||
const commandOnlyItems = React.useMemo(
|
||||
@@ -131,7 +134,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
}
|
||||
|
||||
setIsConfirmActionPending(true);
|
||||
const success = await deleteCommand(confirmActionCommand.name);
|
||||
const success = await deleteCommand(confirmActionCommand.name, settingsDirectory);
|
||||
|
||||
if (success) {
|
||||
if (confirmActionType === 'delete') {
|
||||
@@ -204,11 +207,11 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
template: renameDialogCommand.template,
|
||||
agent: renameDialogCommand.agent,
|
||||
model: renameDialogCommand.model,
|
||||
});
|
||||
}, settingsDirectory);
|
||||
|
||||
if (success) {
|
||||
// Delete old command
|
||||
const deleteSuccess = await deleteCommand(renameDialogCommand.name);
|
||||
const deleteSuccess = await deleteCommand(renameDialogCommand.name, settingsDirectory);
|
||||
if (deleteSuccess) {
|
||||
toast.success(`Command renamed to "${sanitizedName}"`);
|
||||
setSelectedCommand(sanitizedName);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import {
|
||||
selectMcpServersForDirectory,
|
||||
useMcpConfigStore,
|
||||
envRecordToArray,
|
||||
type McpDraft,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
} from './mcpImport';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
@@ -556,7 +557,6 @@ export const McpPage: React.FC = () => {
|
||||
);
|
||||
const {
|
||||
selectedMcpName,
|
||||
mcpServers,
|
||||
mcpDraft,
|
||||
setMcpDraft,
|
||||
setSelectedMcp,
|
||||
@@ -566,7 +566,6 @@ export const McpPage: React.FC = () => {
|
||||
deleteMcp,
|
||||
} = useMcpConfigStore(useShallow((s) => ({
|
||||
selectedMcpName: s.selectedMcpName,
|
||||
mcpServers: s.mcpServers,
|
||||
mcpDraft: s.mcpDraft,
|
||||
setMcpDraft: s.setMcpDraft,
|
||||
setSelectedMcp: s.setSelectedMcp,
|
||||
@@ -576,10 +575,12 @@ export const McpPage: React.FC = () => {
|
||||
deleteMcp: s.deleteMcp,
|
||||
})));
|
||||
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
// Settings browses whichever project its own selector points at; the app
|
||||
// stays where it is.
|
||||
const currentDirectory = useSettingsDirectory();
|
||||
const isVSCodeAuthRuntime = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
|
||||
const mcpDiagnostics = useMcpStore((state) => state.getDiagnosticForDirectory(currentDirectory ?? null));
|
||||
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory));
|
||||
const mcpDiagnostics = useMcpStore((state) => state.getDiagnosticForDirectory(currentDirectory));
|
||||
const refreshStatus = useMcpStore((state) => state.refresh);
|
||||
const connectMcp = useMcpStore((state) => state.connect);
|
||||
const disconnectMcp = useMcpStore((state) => state.disconnect);
|
||||
@@ -588,7 +589,8 @@ export const McpPage: React.FC = () => {
|
||||
const testConnectionMcp = useMcpStore((state) => state.testConnection);
|
||||
const pendingRestartChanges = usePendingOpenCodeRestartStore((state) => state.changes);
|
||||
|
||||
const selectedServer = selectedMcpName ? getMcpByName(selectedMcpName) : null;
|
||||
const mcpServers = useMcpConfigStore((state) => selectMcpServersForDirectory(state, currentDirectory));
|
||||
const selectedServer = selectedMcpName ? getMcpByName(selectedMcpName, currentDirectory) : null;
|
||||
const isNewServer = Boolean(mcpDraft && mcpDraft.name === selectedMcpName && !selectedServer);
|
||||
|
||||
// ── form state ──
|
||||
@@ -908,7 +910,7 @@ export const McpPage: React.FC = () => {
|
||||
};
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = isNewServer ? await createMcp(draft) : await updateMcp(name, draft);
|
||||
const result = isNewServer ? await createMcp(draft, currentDirectory) : await updateMcp(name, draft, currentDirectory);
|
||||
if (result.ok) {
|
||||
await clearPendingMcpAuthContext(authStateKey);
|
||||
resetTransientAuthState();
|
||||
@@ -940,7 +942,7 @@ export const McpPage: React.FC = () => {
|
||||
const handleDelete = async () => {
|
||||
if (!selectedMcpName) return;
|
||||
setIsDeleting(true);
|
||||
const result = await deleteMcp(selectedMcpName);
|
||||
const result = await deleteMcp(selectedMcpName, currentDirectory);
|
||||
if (result.ok) {
|
||||
await clearPendingMcpAuthContext(authStateKey);
|
||||
resetTransientAuthState();
|
||||
@@ -967,7 +969,7 @@ export const McpPage: React.FC = () => {
|
||||
} else {
|
||||
await connectMcp(selectedMcpName, currentDirectory);
|
||||
await refreshStatus({ directory: currentDirectory, silent: true });
|
||||
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName];
|
||||
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName];
|
||||
if (nextStatus?.status === 'connected') {
|
||||
toast.success(t('settings.mcp.page.toast.connected'));
|
||||
} else if (nextStatus?.status === 'needs_auth') {
|
||||
@@ -1029,7 +1031,7 @@ export const McpPage: React.FC = () => {
|
||||
const actionKey = runtimeActionKey;
|
||||
let queuedStateKey: string | null = null;
|
||||
try {
|
||||
const currentStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName]?.status;
|
||||
const currentStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName]?.status;
|
||||
authPollStartsFromNeedsAuthRef.current = currentStatus === 'needs_auth' || currentStatus === 'needs_client_registration';
|
||||
|
||||
// One implementation for every surface that can authorise; the page
|
||||
@@ -1237,7 +1239,7 @@ export const McpPage: React.FC = () => {
|
||||
void (async () => {
|
||||
authPollAttemptsRef.current += 1;
|
||||
await refreshStatus({ directory: currentDirectory, silent: true });
|
||||
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName];
|
||||
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName];
|
||||
|
||||
if (!nextStatus) {
|
||||
return;
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
|
||||
import { selectMcpServersForDirectory, useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
||||
import { isMobileDeviceViaCSS } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui';
|
||||
@@ -65,9 +65,8 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const bgClass = 'bg-background';
|
||||
|
||||
const { mcpServers, selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
|
||||
const { selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
|
||||
useMcpConfigStore(useShallow((s) => ({
|
||||
mcpServers: s.mcpServers,
|
||||
selectedMcpName: s.selectedMcpName,
|
||||
setSelectedMcp: s.setSelectedMcp,
|
||||
setMcpDraft: s.setMcpDraft,
|
||||
@@ -75,8 +74,11 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
deleteMcp: s.deleteMcp,
|
||||
})));
|
||||
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
|
||||
// Settings browses whichever project its own selector points at; the app
|
||||
// stays where it is.
|
||||
const settingsDirectory = useSettingsDirectory();
|
||||
const mcpServers = useMcpConfigStore((state) => selectMcpServersForDirectory(state, settingsDirectory));
|
||||
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(settingsDirectory));
|
||||
const refreshStatus = useMcpStore((state) => state.refresh);
|
||||
const getErrorForDirectory = useMcpStore((state) => state.getErrorForDirectory);
|
||||
|
||||
@@ -96,8 +98,8 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadMcpConfigs();
|
||||
}, [loadMcpConfigs]);
|
||||
void loadMcpConfigs({ directory: settingsDirectory });
|
||||
}, [loadMcpConfigs, settingsDirectory]);
|
||||
|
||||
const handleRefresh = React.useCallback(() => {
|
||||
if (isRefreshingStatus) return;
|
||||
@@ -106,17 +108,17 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
const minSpinPromise = new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
Promise.all([
|
||||
refreshStatus({ directory: currentDirectory, silent: true }),
|
||||
refreshStatus({ directory: settingsDirectory, silent: true }),
|
||||
minSpinPromise,
|
||||
]).then(() => {
|
||||
const error = getErrorForDirectory(currentDirectory);
|
||||
const error = getErrorForDirectory(settingsDirectory);
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
}
|
||||
}).finally(() => {
|
||||
setIsRefreshingStatus(false);
|
||||
});
|
||||
}, [currentDirectory, getErrorForDirectory, isRefreshingStatus, refreshStatus]);
|
||||
}, [getErrorForDirectory, isRefreshingStatus, refreshStatus, settingsDirectory]);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
const baseName = 'new-mcp-server';
|
||||
@@ -151,7 +153,7 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setIsDeleting(true);
|
||||
const result = await deleteMcp(deleteTarget.name);
|
||||
const result = await deleteMcp(deleteTarget.name, settingsDirectory);
|
||||
if (result.ok) {
|
||||
if (result.reloadFailed) {
|
||||
toast.warning(result.message || `MCP server "${deleteTarget.name}" deleted, but OpenCode reload failed`, {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLay
|
||||
import { SettingsSection, SETTINGS_CUSTOM_TRIGGER_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { selectProvidersForDirectory, useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -144,7 +145,10 @@ const parseProvidersPayload = (payload: unknown): ProviderOption[] => {
|
||||
|
||||
export const ProvidersPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
// Settings browses whichever project its own selector points at; the app
|
||||
// stays where it is.
|
||||
const settingsDirectory = useSettingsDirectory();
|
||||
const providers = useConfigStore((state) => selectProvidersForDirectory(state, settingsDirectory));
|
||||
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
|
||||
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
||||
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
|
||||
@@ -341,7 +345,8 @@ export const ProvidersPage: React.FC = () => {
|
||||
try {
|
||||
// OpenChamber-only metadata endpoint: the SDK exposes provider data but
|
||||
// not local auth/source-file provenance used by this settings UI.
|
||||
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(selectedProviderId)}/source`, {
|
||||
const query = settingsDirectory ? `?directory=${encodeURIComponent(settingsDirectory)}` : '';
|
||||
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(selectedProviderId)}/source${query}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
@@ -370,7 +375,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedProviderId, t]);
|
||||
}, [selectedProviderId, settingsDirectory, t]);
|
||||
|
||||
const selectedProvider = providers.find((provider) => provider.id === selectedProviderId);
|
||||
const selectedSources = selectedProviderId ? providerSources[selectedProviderId] : undefined;
|
||||
@@ -431,7 +436,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
? (editingCustomScope ?? resolveProviderConfigScope(providerSources[editingCustomProviderId]))
|
||||
: 'user',
|
||||
});
|
||||
const response = await runtimeFetch('/api/provider', {
|
||||
const response = await runtimeFetch(`/api/provider${settingsDirectory ? `?directory=${encodeURIComponent(settingsDirectory)}` : ''}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -484,10 +489,13 @@ export const ProvidersPage: React.FC = () => {
|
||||
setAuthBusyKey(busyKey);
|
||||
|
||||
try {
|
||||
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(providerId)}/auth?scope=all`, {
|
||||
method: 'DELETE',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const response = await runtimeFetch(
|
||||
`/api/provider/${encodeURIComponent(providerId)}/auth?scope=all${settingsDirectory ? `&directory=${encodeURIComponent(settingsDirectory)}` : ''}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { Accept: 'application/json' },
|
||||
},
|
||||
);
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -2,7 +2,8 @@ import React from 'react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { selectProvidersForDirectory, useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
|
||||
@@ -40,16 +41,28 @@ interface ProvidersSidebarProps {
|
||||
|
||||
export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
// Settings browses whichever project its own selector points at; the app
|
||||
// stays where it is.
|
||||
const settingsDirectory = useSettingsDirectory();
|
||||
const providers = useConfigStore((state) => selectProvidersForDirectory(state, settingsDirectory));
|
||||
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
|
||||
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
||||
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
|
||||
const [sourcesByProvider, setSourcesByProvider] = React.useState<Record<string, ProviderSources>>({});
|
||||
const directory = React.useMemo(() => {
|
||||
if (settingsDirectory) return settingsDirectory;
|
||||
// tie refresh to active project changes (directory is stored in the client)
|
||||
void activeProjectId;
|
||||
return getCurrentDirectory();
|
||||
}, [activeProjectId]);
|
||||
}, [activeProjectId, settingsDirectory]);
|
||||
|
||||
// The app only loads providers for the project it is on; Settings has to ask
|
||||
// for the one it is looking at.
|
||||
const loadProviders = useConfigStore((state) => state.loadProviders);
|
||||
React.useEffect(() => {
|
||||
if (!settingsDirectory) return;
|
||||
void loadProviders({ directory: settingsDirectory, source: 'settings:providers' });
|
||||
}, [loadProviders, settingsDirectory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (providers.length === 0) {
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -17,8 +19,11 @@ const formatProjectLabel = (label: string): string => label.trim();
|
||||
export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ className }) => {
|
||||
const { t } = useI18n();
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
|
||||
// Settings-only selection. Picking a project here used to call
|
||||
// `setActiveProject`, which relocates the chat, the session list and the file
|
||||
// tree; reading another project's configuration must not move the app.
|
||||
const settingsDirectory = useSettingsDirectory();
|
||||
const setSettingsProjectPath = useUIStore((state) => state.setSettingsProjectPath);
|
||||
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
|
||||
@@ -30,8 +35,8 @@ export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ clas
|
||||
if (sortedProjects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return sortedProjects.find((p) => p.id === activeProjectId) ?? sortedProjects[0];
|
||||
}, [activeProjectId, sortedProjects]);
|
||||
return sortedProjects.find((p) => p.path === settingsDirectory) ?? sortedProjects[0];
|
||||
}, [settingsDirectory, sortedProjects]);
|
||||
|
||||
if (isVSCode || sortedProjects.length === 0) {
|
||||
return null;
|
||||
@@ -67,7 +72,9 @@ export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ clas
|
||||
value={activeProject?.id ?? ''}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setActiveProject(value);
|
||||
const project = sortedProjects.find((entry) => entry.id === value);
|
||||
if (!project) return;
|
||||
setSettingsProjectPath(project.path);
|
||||
}}
|
||||
>
|
||||
{sortedProjects.map((project) => {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
|
||||
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
||||
import { selectSkillsForDirectory, useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
|
||||
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
@@ -119,7 +120,6 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
getSkillDetail,
|
||||
createSkill,
|
||||
updateSkill,
|
||||
skills,
|
||||
skillDraft,
|
||||
setSkillDraft,
|
||||
setSelectedSkill,
|
||||
@@ -129,13 +129,16 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
getSkillDetail: s.getSkillDetail,
|
||||
createSkill: s.createSkill,
|
||||
updateSkill: s.updateSkill,
|
||||
skills: s.skills,
|
||||
skillDraft: s.skillDraft,
|
||||
setSkillDraft: s.setSkillDraft,
|
||||
setSelectedSkill: s.setSelectedSkill,
|
||||
})));
|
||||
|
||||
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null;
|
||||
// Settings browses whichever project its own selector points at; the app
|
||||
// stays where it is.
|
||||
const settingsDirectory = useSettingsDirectory();
|
||||
const skills = useSkillsStore((state) => selectSkillsForDirectory(state, settingsDirectory));
|
||||
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName, settingsDirectory) : null;
|
||||
const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill);
|
||||
const hasStaleSelection = Boolean(selectedSkillName && !selectedSkill && !skillDraft);
|
||||
const isReadOnlySkill = selectedSkill?.path === '<built-in>';
|
||||
@@ -232,7 +235,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
} else if (selectedSkillName && selectedSkill) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const detail = await getSkillDetail(selectedSkillName);
|
||||
const detail = await getSkillDetail(selectedSkillName, settingsDirectory);
|
||||
if (detail) {
|
||||
const md = detail.sources.md;
|
||||
const nextDescription = md.description || '';
|
||||
@@ -253,7 +256,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
};
|
||||
|
||||
loadSkillDetails();
|
||||
}, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail]);
|
||||
}, [selectedSkill, isNewSkill, selectedSkillName, settingsDirectory, skills, skillDraft, getSkillDetail]);
|
||||
|
||||
const editorFontSize = useUIStore((state) => state.editorFontSize);
|
||||
|
||||
@@ -338,14 +341,14 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
|
||||
let success: boolean;
|
||||
if (isNewSkill) {
|
||||
success = await createSkill(config);
|
||||
success = await createSkill(config, settingsDirectory);
|
||||
if (success) {
|
||||
setSkillDraft(null);
|
||||
setPendingFiles([]);
|
||||
setSelectedSkill(skillName);
|
||||
}
|
||||
} else {
|
||||
success = await updateSkill(skillName, config);
|
||||
success = await updateSkill(skillName, config, settingsDirectory);
|
||||
if (success) {
|
||||
setOriginalDescription(description.trim());
|
||||
setOriginalInstructions(instructions.trim());
|
||||
@@ -402,7 +405,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
|
||||
try {
|
||||
const { readSupportingFile } = useSkillsStore.getState();
|
||||
const content = await readSupportingFile(selectedSkillName, filePath);
|
||||
const content = await readSupportingFile(selectedSkillName, filePath, settingsDirectory);
|
||||
setNewFileContent(content || '');
|
||||
setOriginalFileContent(content || '');
|
||||
} catch {
|
||||
@@ -448,13 +451,13 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
}
|
||||
|
||||
const { writeSupportingFile } = useSkillsStore.getState();
|
||||
const success = await writeSupportingFile(selectedSkillName, filePath, newFileContent);
|
||||
const success = await writeSupportingFile(selectedSkillName, filePath, newFileContent, settingsDirectory);
|
||||
|
||||
if (success) {
|
||||
toast.success(isEditing ? t('settings.skills.page.toast.fileUpdated', { path: filePath }) : t('settings.skills.page.toast.fileCreated', { path: filePath }));
|
||||
setIsFileDialogOpen(false);
|
||||
setEditingFilePath(null);
|
||||
const detail = await getSkillDetail(selectedSkillName);
|
||||
const detail = await getSkillDetail(selectedSkillName, settingsDirectory);
|
||||
if (detail) {
|
||||
setSupportingFiles(detail.sources.md.supportingFiles || []);
|
||||
}
|
||||
@@ -484,11 +487,11 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
|
||||
setIsDeletingFile(true);
|
||||
const { deleteSupportingFile } = useSkillsStore.getState();
|
||||
const success = await deleteSupportingFile(selectedSkillName, deleteFilePath);
|
||||
const success = await deleteSupportingFile(selectedSkillName, deleteFilePath, settingsDirectory);
|
||||
|
||||
if (success) {
|
||||
toast.success(t('settings.skills.page.toast.fileDeleted', { path: deleteFilePath }));
|
||||
const detail = await getSkillDetail(selectedSkillName);
|
||||
const detail = await getSkillDetail(selectedSkillName, settingsDirectory);
|
||||
if (detail) {
|
||||
setSupportingFiles(detail.sources.md.supportingFiles || []);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
import { useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
|
||||
import { selectSkillsForDirectory, useSkillsStore, type DiscoveredSkill } from '@/stores/useSkillsStore';
|
||||
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
@@ -49,7 +50,6 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
|
||||
const {
|
||||
selectedSkillName,
|
||||
skills,
|
||||
setSelectedSkill,
|
||||
setSkillDraft,
|
||||
deleteSkill,
|
||||
@@ -57,7 +57,6 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
getSkillDetail,
|
||||
} = useSkillsStore(useShallow((s) => ({
|
||||
selectedSkillName: s.selectedSkillName,
|
||||
skills: s.skills,
|
||||
setSelectedSkill: s.setSelectedSkill,
|
||||
setSkillDraft: s.setSkillDraft,
|
||||
deleteSkill: s.deleteSkill,
|
||||
@@ -65,7 +64,15 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
getSkillDetail: s.getSkillDetail,
|
||||
})));
|
||||
|
||||
// Skills are loaded by the Settings shell when this page is active.
|
||||
// Settings browses whichever project its own selector points at; the app
|
||||
// stays where it is.
|
||||
const settingsDirectory = useSettingsDirectory();
|
||||
const skills = useSkillsStore((state) => selectSkillsForDirectory(state, settingsDirectory));
|
||||
const loadSkills = useSkillsStore((state) => state.loadSkills);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadSkills(settingsDirectory);
|
||||
}, [loadSkills, settingsDirectory]);
|
||||
|
||||
const bgClass = 'bg-background';
|
||||
|
||||
@@ -101,7 +108,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
}
|
||||
|
||||
setIsDeletePending(true);
|
||||
const success = await deleteSkill(deleteDialogSkill.name);
|
||||
const success = await deleteSkill(deleteDialogSkill.name, settingsDirectory);
|
||||
if (success) {
|
||||
toast.success(t('settings.skills.sidebar.toast.skillDeleted', { name: deleteDialogSkill.name }));
|
||||
setDeleteDialogSkill(null);
|
||||
@@ -124,7 +131,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
}
|
||||
|
||||
// Get full skill detail to copy
|
||||
const detail = await getSkillDetail(skill.name);
|
||||
const detail = await getSkillDetail(skill.name, settingsDirectory);
|
||||
if (!detail) {
|
||||
toast.error(t('settings.skills.sidebar.toast.duplicateLoadFailed'));
|
||||
return;
|
||||
@@ -173,7 +180,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
}
|
||||
|
||||
// Rename in place on disk so SKILL.md body and supporting files are preserved.
|
||||
const success = await renameSkill(renameDialogSkill.name, sanitizedName);
|
||||
const success = await renameSkill(renameDialogSkill.name, sanitizedName, settingsDirectory);
|
||||
if (success) {
|
||||
toast.success(t('settings.skills.sidebar.toast.skillRenamed', { name: sanitizedName }));
|
||||
setSelectedSkill(sanitizedName);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { cn, getModifierLabel } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useAgentsStore } from '@/stores/useAgentsStore';
|
||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
||||
@@ -254,23 +255,24 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
}, [visiblePages]);
|
||||
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const settingsDirectory = useSettingsDirectory();
|
||||
|
||||
// Load stores when project changes or when a page becomes active.
|
||||
// Load stores when the settings project changes or a page becomes active.
|
||||
React.useEffect(() => {
|
||||
if (!isSettingsDialogOpen && !runtimeCtx.isVSCode && !isWindowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (settingsSlug === 'agents') {
|
||||
void useAgentsStore.getState().loadAgents();
|
||||
void useAgentsStore.getState().loadAgents(settingsDirectory);
|
||||
return;
|
||||
}
|
||||
if (settingsSlug === 'commands') {
|
||||
void useCommandsStore.getState().loadCommands();
|
||||
void useCommandsStore.getState().loadCommands(settingsDirectory);
|
||||
return;
|
||||
}
|
||||
if (settingsSlug === 'mcp') {
|
||||
void useMcpConfigStore.getState().loadMcpConfigs();
|
||||
void useMcpConfigStore.getState().loadMcpConfigs({ directory: settingsDirectory });
|
||||
return;
|
||||
}
|
||||
if (settingsSlug === 'plugins') {
|
||||
@@ -278,13 +280,15 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return;
|
||||
}
|
||||
if (settingsSlug === 'skills.installed' || settingsSlug === 'skills.catalog') {
|
||||
void useSkillsStore.getState().loadSkills();
|
||||
void useSkillsStore.getState().loadSkills(settingsDirectory);
|
||||
void useSkillsCatalogStore.getState().loadCatalog();
|
||||
}
|
||||
if (settingsSlug === 'snippets') {
|
||||
void useSnippetsStore.getState().loadSnippets();
|
||||
}
|
||||
}, [activeProjectId, isSettingsDialogOpen, isWindowed, runtimeCtx.isVSCode, settingsSlug]);
|
||||
// `activeProjectId` still matters: the settings directory follows the active
|
||||
// project until the user picks another one in the Settings selector.
|
||||
}, [activeProjectId, isSettingsDialogOpen, isWindowed, runtimeCtx.isVSCode, settingsDirectory, settingsSlug]);
|
||||
|
||||
const openPage = React.useCallback((slug: SettingsPageSlug) => {
|
||||
setSettingsPage(slug);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, Agent[]>;
|
||||
isLoading: boolean;
|
||||
agentDraft: AgentDraft | null;
|
||||
|
||||
setSelectedAgent: (name: string | null) => void;
|
||||
setAgentDraft: (draft: AgentDraft | null) => void;
|
||||
loadAgents: () => Promise<boolean>;
|
||||
createAgent: (config: AgentConfig) => Promise<AgentMutationResult>;
|
||||
updateAgent: (name: string, config: Partial<AgentConfig>) => Promise<AgentMutationResult>;
|
||||
deleteAgent: (name: string, scope?: AgentScope) => Promise<AgentMutationResult>;
|
||||
getAgentByName: (name: string) => Agent | undefined;
|
||||
loadAgents: (directory?: string | null) => Promise<boolean>;
|
||||
createAgent: (config: AgentConfig, directory?: string | null) => Promise<AgentMutationResult>;
|
||||
updateAgent: (name: string, config: Partial<AgentConfig>, directory?: string | null) => Promise<AgentMutationResult>;
|
||||
deleteAgent: (name: string, scope?: AgentScope, directory?: string | null) => Promise<AgentMutationResult>;
|
||||
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<AgentsStore, 'agentsByDirectory'>,
|
||||
directory?: string | null,
|
||||
): Agent[] => {
|
||||
const cacheKey = getAgentsCacheKey(resolveDirectory(directory));
|
||||
return state.agentsByDirectory[cacheKey] ?? EMPTY_AGENTS;
|
||||
};
|
||||
|
||||
export const useAgentsStore = create<AgentsStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
@@ -285,6 +315,7 @@ export const useAgentsStore = create<AgentsStore>()(
|
||||
|
||||
selectedAgentName: null,
|
||||
agents: [],
|
||||
agentsByDirectory: {},
|
||||
isLoading: false,
|
||||
agentDraft: null,
|
||||
|
||||
@@ -296,12 +327,13 @@ export const useAgentsStore = create<AgentsStore>()(
|
||||
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<AgentsStore>()(
|
||||
|
||||
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<AgentsStore>()(
|
||||
|
||||
const nextSignature = buildAgentsSignature(agentsWithScope);
|
||||
if (previousSignature !== nextSignature) {
|
||||
set({ agents: agentsWithScope, isLoading: false });
|
||||
set((state) => {
|
||||
const next: Partial<AgentsStore> = {
|
||||
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<AgentsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
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<AgentsStore>()(
|
||||
|
||||
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<AgentsStore>()(
|
||||
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<AgentsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
updateAgent: async (name: string, config: Partial<AgentConfig>) => {
|
||||
updateAgent: async (name: string, config: Partial<AgentConfig>, requestedDirectory?: string | null) => {
|
||||
try {
|
||||
const agentConfig: Record<string, unknown> = {};
|
||||
|
||||
@@ -485,7 +526,7 @@ export const useAgentsStore = create<AgentsStore>()(
|
||||
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<AgentsStore>()(
|
||||
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<AgentsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
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<AgentsStore>()(
|
||||
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<AgentsStore>()(
|
||||
},
|
||||
|
||||
|
||||
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));
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -67,12 +67,16 @@ const buildCommandsSignature = (commands: Command[]): string => {
|
||||
};
|
||||
|
||||
const upsertCommandLocal = (
|
||||
set: (state: Partial<CommandsStore>) => void,
|
||||
set: (updater: (state: CommandsStore) => Partial<CommandsStore>) => void,
|
||||
get: () => CommandsStore,
|
||||
name: string,
|
||||
config: Partial<CommandConfig>,
|
||||
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<CommandsStore> = {
|
||||
commandsByDirectory: { ...state.commandsByDirectory, [cacheKey]: nextCommands },
|
||||
};
|
||||
if (isAmbient) next.commands = nextCommands;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeCommandLocal = (
|
||||
set: (state: Partial<CommandsStore>) => void,
|
||||
set: (updater: (state: CommandsStore) => Partial<CommandsStore>) => void,
|
||||
get: () => CommandsStore,
|
||||
name: string,
|
||||
directory: string | null,
|
||||
) => {
|
||||
const nextState: Partial<CommandsStore> = {
|
||||
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<CommandsStore> = {
|
||||
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<string, Command[]>;
|
||||
isLoading: boolean;
|
||||
commandDraft: CommandDraft | null;
|
||||
|
||||
setSelectedCommand: (name: string | null) => void;
|
||||
setCommandDraft: (draft: CommandDraft | null) => void;
|
||||
loadCommands: () => Promise<boolean>;
|
||||
createCommand: (config: CommandConfig) => Promise<boolean>;
|
||||
updateCommand: (name: string, config: Partial<CommandConfig>) => Promise<boolean>;
|
||||
deleteCommand: (name: string) => Promise<boolean>;
|
||||
getCommandByName: (name: string) => Command | undefined;
|
||||
loadCommands: (directory?: string | null) => Promise<boolean>;
|
||||
createCommand: (config: CommandConfig, directory?: string | null) => Promise<boolean>;
|
||||
updateCommand: (name: string, config: Partial<CommandConfig>, directory?: string | null) => Promise<boolean>;
|
||||
deleteCommand: (name: string, directory?: string | null) => Promise<boolean>;
|
||||
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<CommandsStore, 'commandsByDirectory'>,
|
||||
directory?: string | null,
|
||||
): Command[] => {
|
||||
const cacheKey = getCommandsCacheKey(resolveDirectory(directory));
|
||||
return state.commandsByDirectory[cacheKey] ?? EMPTY_COMMANDS;
|
||||
};
|
||||
|
||||
export const useCommandsStore = create<CommandsStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
@@ -169,6 +214,7 @@ export const useCommandsStore = create<CommandsStore>()(
|
||||
|
||||
selectedCommandName: null,
|
||||
commands: [],
|
||||
commandsByDirectory: {},
|
||||
isLoading: false,
|
||||
commandDraft: null,
|
||||
|
||||
@@ -180,12 +226,13 @@ export const useCommandsStore = create<CommandsStore>()(
|
||||
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<CommandsStore>()(
|
||||
|
||||
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<CommandsStore>()(
|
||||
|
||||
const nextSignature = buildCommandsSignature(commandsWithScope);
|
||||
if (previousSignature !== nextSignature) {
|
||||
set({ commands: commandsWithScope, isLoading: false });
|
||||
set((state) => {
|
||||
const next: Partial<CommandsStore> = {
|
||||
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<CommandsStore>()(
|
||||
}
|
||||
|
||||
console.error("Failed to load commands:", lastError);
|
||||
set({ commands: previousCommands, isLoading: false });
|
||||
set((state) => {
|
||||
const next: Partial<CommandsStore> = {
|
||||
commandsByDirectory: { ...state.commandsByDirectory, [cacheKey]: previousCommands },
|
||||
isLoading: false,
|
||||
};
|
||||
if (isAmbient) next.commands = previousCommands;
|
||||
return next;
|
||||
});
|
||||
return false;
|
||||
})();
|
||||
|
||||
@@ -281,7 +344,7 @@ export const useCommandsStore = create<CommandsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
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<CommandsStore>()(
|
||||
|
||||
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<CommandsStore>()(
|
||||
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<CommandsStore>()(
|
||||
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<CommandsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
updateCommand: async (name: string, config: Partial<CommandConfig>) => {
|
||||
updateCommand: async (name: string, config: Partial<CommandConfig>, requestedDirectory?: string | null) => {
|
||||
try {
|
||||
console.log('[CommandsStore] Updating command:', name);
|
||||
console.log('[CommandsStore] Config received:', config);
|
||||
@@ -363,7 +426,7 @@ export const useCommandsStore = create<CommandsStore>()(
|
||||
|
||||
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<CommandsStore>()(
|
||||
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<CommandsStore>()(
|
||||
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<CommandsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
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<CommandsStore>()(
|
||||
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<CommandsStore>()(
|
||||
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<CommandsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -1134,6 +1134,26 @@ const _inFlightProviders = new Map<string, Promise<void>>();
|
||||
const _inFlightAgents = new Map<string, Promise<boolean>>();
|
||||
let _initializeAppInFlight: Promise<void> | 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<ConfigStore, "providers" | "directoryScoped" | "activeDirectoryKey">,
|
||||
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<ConfigStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
|
||||
@@ -19,6 +19,19 @@ type McpMutationResult = {
|
||||
restartDeferred?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Directory a call operates on. Settings can browse another project without
|
||||
* moving the app, so every entry point takes one; omitting it means the
|
||||
* project the app is currently on.
|
||||
*/
|
||||
const resolveDirectory = (directory?: string | null): string | null => {
|
||||
if (directory !== undefined) {
|
||||
const trimmed = directory?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
return getConfigDirectory();
|
||||
};
|
||||
|
||||
const getConfigDirectory = (): string | null => {
|
||||
try {
|
||||
const projectsStore = useProjectsStore.getState();
|
||||
@@ -115,29 +128,48 @@ const getMcpCacheKey = (directory: string | null): string => {
|
||||
// ============== STORE ==============
|
||||
|
||||
interface McpConfigStore {
|
||||
/** Servers of the project the app is on. Chat and mobile read this one. */
|
||||
mcpServers: McpServerWithScope[];
|
||||
/** Every directory loaded so far, including the ambient one. */
|
||||
serversByDirectory: Record<string, McpServerWithScope[]>;
|
||||
selectedMcpName: string | null;
|
||||
isLoading: boolean;
|
||||
mcpDraft: McpDraft | null;
|
||||
|
||||
setSelectedMcp: (name: string | null) => void;
|
||||
setMcpDraft: (draft: McpDraft | null) => void;
|
||||
loadMcpConfigs: (options?: { force?: boolean }) => Promise<boolean>;
|
||||
createMcp: (config: McpDraft) => Promise<McpMutationResult>;
|
||||
updateMcp: (name: string, config: Partial<McpDraft>) => Promise<McpMutationResult>;
|
||||
deleteMcp: (name: string) => Promise<McpMutationResult>;
|
||||
getMcpByName: (name: string) => McpServerWithScope | undefined;
|
||||
loadMcpConfigs: (options?: { force?: boolean; directory?: string | null }) => Promise<boolean>;
|
||||
createMcp: (config: McpDraft, directory?: string | null) => Promise<McpMutationResult>;
|
||||
updateMcp: (name: string, config: Partial<McpDraft>, directory?: string | null) => Promise<McpMutationResult>;
|
||||
deleteMcp: (name: string, directory?: string | null) => Promise<McpMutationResult>;
|
||||
getMcpByName: (name: string, directory?: string | null) => McpServerWithScope | undefined;
|
||||
getMcpServersForDirectory: (directory?: string | null) => McpServerWithScope[];
|
||||
}
|
||||
|
||||
const invalidateMcpCache = (directory: string | null) => {
|
||||
mcpLastLoadedAt.delete(getMcpCacheKey(directory));
|
||||
};
|
||||
|
||||
const EMPTY_MCP_SERVERS: McpServerWithScope[] = [];
|
||||
|
||||
/**
|
||||
* Servers of one project. Returns a stored array so components can select it
|
||||
* directly; an omitted directory means the project the app is on.
|
||||
*/
|
||||
export const selectMcpServersForDirectory = (
|
||||
state: Pick<McpConfigStore, 'serversByDirectory'>,
|
||||
directory?: string | null,
|
||||
): McpServerWithScope[] => {
|
||||
const cacheKey = getMcpCacheKey(resolveDirectory(directory));
|
||||
return state.serversByDirectory[cacheKey] ?? EMPTY_MCP_SERVERS;
|
||||
};
|
||||
|
||||
export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
mcpServers: [],
|
||||
serversByDirectory: {},
|
||||
selectedMcpName: null,
|
||||
isLoading: false,
|
||||
mcpDraft: null,
|
||||
@@ -147,11 +179,12 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
setMcpDraft: (draft) => set({ mcpDraft: draft }),
|
||||
|
||||
loadMcpConfigs: async (options) => {
|
||||
const configDirectory = getConfigDirectory();
|
||||
const configDirectory = resolveDirectory(options?.directory);
|
||||
const cacheKey = getMcpCacheKey(configDirectory);
|
||||
const isAmbient = cacheKey === getMcpCacheKey(getConfigDirectory());
|
||||
const now = Date.now();
|
||||
const loadedAt = mcpLastLoadedAt.get(cacheKey) ?? 0;
|
||||
const hasCachedConfigs = get().mcpServers.length > 0;
|
||||
const hasCachedConfigs = (get().serversByDirectory[cacheKey] ?? (isAmbient ? get().mcpServers : [])).length > 0;
|
||||
|
||||
if (!options?.force && hasCachedConfigs && now - loadedAt < MCP_LOAD_CACHE_TTL_MS) {
|
||||
return true;
|
||||
@@ -173,7 +206,14 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
throw new Error('Failed to load MCP configs');
|
||||
}
|
||||
const data: McpServerWithScope[] = await response.json();
|
||||
set({ mcpServers: data, isLoading: false });
|
||||
set((state) => {
|
||||
const next: Partial<McpConfigStore> = {
|
||||
serversByDirectory: { ...state.serversByDirectory, [cacheKey]: data },
|
||||
isLoading: false,
|
||||
};
|
||||
if (isAmbient) next.mcpServers = data;
|
||||
return next;
|
||||
});
|
||||
mcpLastLoadedAt.set(cacheKey, Date.now());
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -191,10 +231,10 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
createMcp: async (config: McpDraft) => {
|
||||
createMcp: async (config: McpDraft, directory?: string | null) => {
|
||||
try {
|
||||
const body = buildMcpBody(config);
|
||||
const configDirectory = getConfigDirectory();
|
||||
const configDirectory = resolveDirectory(directory);
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
const response = await runtimeFetch(`/api/config/mcp/${encodeURIComponent(config.name)}${queryParams}`, {
|
||||
method: 'POST',
|
||||
@@ -213,7 +253,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
invalidateMcpCache(configDirectory);
|
||||
|
||||
if (payload?.requiresManualRestart) {
|
||||
await get().loadMcpConfigs({ force: true });
|
||||
await get().loadMcpConfigs({ force: true, directory: configDirectory });
|
||||
return {
|
||||
ok: true,
|
||||
requiresManualRestart: true,
|
||||
@@ -224,7 +264,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
}
|
||||
|
||||
if (noteDeferredRestartFromPayload(payload, 'mcp', { id: config.name })) {
|
||||
await get().loadMcpConfigs({ force: true });
|
||||
await get().loadMcpConfigs({ force: true, directory: configDirectory });
|
||||
return {
|
||||
ok: true,
|
||||
restartDeferred: true,
|
||||
@@ -241,7 +281,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
|
||||
scopes: ['all'],
|
||||
});
|
||||
await get().loadMcpConfigs({ force: true });
|
||||
await get().loadMcpConfigs({ force: true, directory: configDirectory });
|
||||
return {
|
||||
ok: true,
|
||||
reloadFailed: payload?.reloadFailed === true,
|
||||
@@ -250,7 +290,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
};
|
||||
}
|
||||
|
||||
await get().loadMcpConfigs({ force: true });
|
||||
await get().loadMcpConfigs({ force: true, directory: configDirectory });
|
||||
return {
|
||||
ok: true,
|
||||
reloadFailed: payload?.reloadFailed === true,
|
||||
@@ -263,10 +303,10 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
updateMcp: async (name: string, config: Partial<McpDraft>) => {
|
||||
updateMcp: async (name: string, config: Partial<McpDraft>, directory?: string | null) => {
|
||||
try {
|
||||
const body = buildMcpBody(config);
|
||||
const configDirectory = getConfigDirectory();
|
||||
const configDirectory = resolveDirectory(directory);
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
const response = await runtimeFetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, {
|
||||
method: 'PATCH',
|
||||
@@ -285,7 +325,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
invalidateMcpCache(configDirectory);
|
||||
|
||||
if (payload?.requiresManualRestart) {
|
||||
await get().loadMcpConfigs({ force: true });
|
||||
await get().loadMcpConfigs({ force: true, directory: configDirectory });
|
||||
return {
|
||||
ok: true,
|
||||
requiresManualRestart: true,
|
||||
@@ -296,7 +336,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
}
|
||||
|
||||
if (noteDeferredRestartFromPayload(payload, 'mcp', { id: name })) {
|
||||
await get().loadMcpConfigs({ force: true });
|
||||
await get().loadMcpConfigs({ force: true, directory: configDirectory });
|
||||
return {
|
||||
ok: true,
|
||||
restartDeferred: true,
|
||||
@@ -313,7 +353,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
|
||||
scopes: ['all'],
|
||||
});
|
||||
await get().loadMcpConfigs({ force: true });
|
||||
await get().loadMcpConfigs({ force: true, directory: configDirectory });
|
||||
return {
|
||||
ok: true,
|
||||
reloadFailed: payload?.reloadFailed === true,
|
||||
@@ -322,7 +362,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
};
|
||||
}
|
||||
|
||||
await get().loadMcpConfigs({ force: true });
|
||||
await get().loadMcpConfigs({ force: true, directory: configDirectory });
|
||||
return {
|
||||
ok: true,
|
||||
reloadFailed: payload?.reloadFailed === true,
|
||||
@@ -335,9 +375,9 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
deleteMcp: async (name: string) => {
|
||||
deleteMcp: async (name: string, directory?: string | null) => {
|
||||
try {
|
||||
const configDirectory = getConfigDirectory();
|
||||
const configDirectory = resolveDirectory(directory);
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
const response = await runtimeFetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, {
|
||||
method: 'DELETE',
|
||||
@@ -356,7 +396,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
}
|
||||
|
||||
if (payload?.requiresManualRestart) {
|
||||
await get().loadMcpConfigs({ force: true });
|
||||
await get().loadMcpConfigs({ force: true, directory: configDirectory });
|
||||
return {
|
||||
ok: true,
|
||||
requiresManualRestart: true,
|
||||
@@ -367,7 +407,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
}
|
||||
|
||||
if (noteDeferredRestartFromPayload(payload, 'mcp', { id: name })) {
|
||||
await get().loadMcpConfigs({ force: true });
|
||||
await get().loadMcpConfigs({ force: true, directory: configDirectory });
|
||||
return {
|
||||
ok: true,
|
||||
restartDeferred: true,
|
||||
@@ -386,7 +426,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
});
|
||||
}
|
||||
|
||||
await get().loadMcpConfigs({ force: true });
|
||||
await get().loadMcpConfigs({ force: true, directory: configDirectory });
|
||||
return {
|
||||
ok: true,
|
||||
reloadFailed: payload?.reloadFailed === true,
|
||||
@@ -399,8 +439,12 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
getMcpByName: (name: string) => {
|
||||
return get().mcpServers.find((s) => s.name === name);
|
||||
getMcpByName: (name: string, directory?: string | null) => {
|
||||
return get().getMcpServersForDirectory(directory).find((s) => s.name === name);
|
||||
},
|
||||
|
||||
getMcpServersForDirectory: (directory?: string | null) => {
|
||||
return selectMcpServersForDirectory(get(), directory);
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<string, DiscoveredSkill[]>;
|
||||
isLoading: boolean;
|
||||
skillDraft: SkillDraft | null;
|
||||
|
||||
setSelectedSkill: (name: string | null) => void;
|
||||
setSkillDraft: (draft: SkillDraft | null) => void;
|
||||
loadSkills: () => Promise<boolean>;
|
||||
getSkillDetail: (name: string) => Promise<SkillDetail | null>;
|
||||
createSkill: (config: SkillConfig) => Promise<boolean>;
|
||||
updateSkill: (name: string, config: Partial<SkillConfig>) => Promise<boolean>;
|
||||
renameSkill: (name: string, newName: string) => Promise<boolean>;
|
||||
deleteSkill: (name: string) => Promise<boolean>;
|
||||
getSkillByName: (name: string) => DiscoveredSkill | undefined;
|
||||
|
||||
loadSkills: (directory?: string | null) => Promise<boolean>;
|
||||
getSkillDetail: (name: string, directory?: string | null) => Promise<SkillDetail | null>;
|
||||
createSkill: (config: SkillConfig, directory?: string | null) => Promise<boolean>;
|
||||
updateSkill: (name: string, config: Partial<SkillConfig>, directory?: string | null) => Promise<boolean>;
|
||||
renameSkill: (name: string, newName: string, directory?: string | null) => Promise<boolean>;
|
||||
deleteSkill: (name: string, directory?: string | null) => Promise<boolean>;
|
||||
getSkillByName: (name: string, directory?: string | null) => DiscoveredSkill | undefined;
|
||||
|
||||
// Supporting files
|
||||
readSupportingFile: (skillName: string, filePath: string) => Promise<string | null>;
|
||||
writeSupportingFile: (skillName: string, filePath: string, content: string) => Promise<boolean>;
|
||||
deleteSupportingFile: (skillName: string, filePath: string) => Promise<boolean>;
|
||||
readSupportingFile: (skillName: string, filePath: string, directory?: string | null) => Promise<string | null>;
|
||||
writeSupportingFile: (skillName: string, filePath: string, content: string, directory?: string | null) => Promise<boolean>;
|
||||
deleteSupportingFile: (skillName: string, filePath: string, directory?: string | null) => Promise<boolean>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -186,12 +202,16 @@ export const invalidateSkillsLoadCache = (directory: string | null = getRequestD
|
||||
};
|
||||
|
||||
const upsertSkillLocal = (
|
||||
set: (state: Partial<SkillsStore>) => void,
|
||||
set: (updater: (state: SkillsStore) => Partial<SkillsStore>) => void,
|
||||
get: () => SkillsStore,
|
||||
name: string,
|
||||
config: Partial<SkillConfig>,
|
||||
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<SkillsStore> = {
|
||||
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<SkillsStore, 'skillsByDirectory'>,
|
||||
directory?: string | null,
|
||||
): DiscoveredSkill[] => {
|
||||
const cacheKey = getSkillsCacheKey(resolveDirectory(directory));
|
||||
return state.skillsByDirectory[cacheKey] ?? EMPTY_SKILLS;
|
||||
};
|
||||
|
||||
export const useSkillsStore = create<SkillsStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
selectedSkillName: null,
|
||||
skills: [],
|
||||
skillsByDirectory: {},
|
||||
isLoading: false,
|
||||
skillDraft: null,
|
||||
|
||||
@@ -247,12 +287,13 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
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<SkillsStore>()(
|
||||
|
||||
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<SkillsStore>()(
|
||||
data.externalSkills ?? null,
|
||||
);
|
||||
|
||||
set({ skills: visibleSkills, isLoading: false });
|
||||
set((state) => {
|
||||
const next: Partial<SkillsStore> = {
|
||||
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<SkillsStore>()(
|
||||
}
|
||||
|
||||
console.error("Failed to load skills:", lastError);
|
||||
set({ skills: previousSkills, isLoading: false });
|
||||
set((state) => {
|
||||
const next: Partial<SkillsStore> = {
|
||||
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: previousSkills },
|
||||
isLoading: false,
|
||||
};
|
||||
if (isAmbient) next.skills = previousSkills;
|
||||
return next;
|
||||
});
|
||||
return false;
|
||||
})();
|
||||
|
||||
@@ -329,9 +386,9 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
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<SkillsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
createSkill: async (config: SkillConfig) => {
|
||||
createSkill: async (config: SkillConfig, requestedDirectory?: string | null) => {
|
||||
try {
|
||||
const skillConfig: Record<string, unknown> = {
|
||||
name: config.name,
|
||||
@@ -359,7 +416,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
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<SkillsStore>()(
|
||||
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<SkillsStore>()(
|
||||
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<SkillsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
updateSkill: async (name: string, config: Partial<SkillConfig>) => {
|
||||
updateSkill: async (name: string, config: Partial<SkillConfig>, requestedDirectory?: string | null) => {
|
||||
try {
|
||||
const skillConfig: Record<string, unknown> = {};
|
||||
|
||||
@@ -418,7 +475,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
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<SkillsStore>()(
|
||||
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<SkillsStore>()(
|
||||
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<SkillsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
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<SkillsStore>()(
|
||||
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<SkillsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
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<SkillsStore>()(
|
||||
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<SkillsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
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<SkillsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
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<SkillsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
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(
|
||||
|
||||
@@ -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<UIStore>()(
|
||||
settingsPage: 'home',
|
||||
settingsHasOpenedOnce: false,
|
||||
settingsProjectsSelectedId: null,
|
||||
settingsProjectPath: null,
|
||||
settingsRemoteInstancesSelectedId: null,
|
||||
eventStreamStatus: 'idle',
|
||||
eventStreamHint: null,
|
||||
@@ -1820,6 +1829,11 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ settingsPage: slug });
|
||||
},
|
||||
|
||||
setSettingsProjectPath: (path) => {
|
||||
const trimmed = path?.trim();
|
||||
set({ settingsProjectPath: trimmed ? trimmed : null });
|
||||
},
|
||||
|
||||
setSettingsProjectsSelectedId: (projectId) => {
|
||||
set({ settingsProjectsSelectedId: projectId });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user