From de736c49f706122bca0ec4f100e91595ee8c352d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 22 Apr 2026 12:31:57 +0800 Subject: [PATCH] Unify plugin capabilities with the desktop management views Desktop plugin details now route into the shared Skills, Agents, and MCP management surfaces instead of maintaining separate read-only drilldowns. This also extends the desktop/server skill aggregation so plugin-provided skills appear in the shared list, groups MCP entries by source, and preserves detail-view back navigation based on where the user entered the page. The implementation keeps plugin detail as the high-level capability hub while pushing real inspection into the existing management pages. Disabled plugins no longer expose false navigation paths into shared views, and the agent-browser regression script was expanded to exercise the new end-to-end flows. Constraint: Shared Agents data only includes enabled plugin agents, so disabled plugins cannot deep-link into agent detail Rejected: Keep duplicating full Skills/Agents/MCP detail inside Plugin detail | creates divergent UI flows and stale data paths Confidence: high Scope-risk: moderate Reversibility: clean Directive: If a detail view can be opened from multiple entry points, keep the return target in store state rather than hardcoding a single back destination Tested: desktop vitest for plugins/skills/agents/mcp; desktop tsc --noEmit; desktop vite build; server skills API test; agent-browser web regression on plugin->skill/mcp and plugin->agent back navigation Not-tested: packaged desktop app regression after rebuilding the Tauri bundle --- .../scripts/e2e-slash-plugin-agent-browser.sh | 254 +++++++++++ desktop/src/__tests__/agentsSettings.test.tsx | 40 ++ desktop/src/__tests__/mcpSettings.test.tsx | 22 +- desktop/src/__tests__/pages.test.tsx | 134 +++++- .../src/__tests__/pluginsSettings.test.tsx | 253 ++++++++++- desktop/src/__tests__/skillsSettings.test.tsx | 51 +++ desktop/src/components/chat/ChatInput.tsx | 28 +- .../chat/LocalSlashCommandPanel.tsx | 20 +- desktop/src/components/chat/composerUtils.ts | 38 ++ .../src/components/plugins/PluginDetail.tsx | 404 +++++++++++++++++- desktop/src/components/skills/SkillDetail.tsx | 13 +- desktop/src/components/skills/SkillList.tsx | 2 +- desktop/src/i18n/locales/en.ts | 2 + desktop/src/i18n/locales/zh.ts | 2 + desktop/src/pages/EmptySession.tsx | 18 +- desktop/src/pages/McpSettings.tsx | 137 ++++-- desktop/src/pages/Settings.tsx | 13 +- desktop/src/stores/agentStore.ts | 15 +- desktop/src/stores/skillStore.ts | 21 +- desktop/src/types/plugin.ts | 37 ++ src/server/api/skills.ts | 128 +++++- src/server/services/pluginService.ts | 354 +++++++++++++-- 22 files changed, 1825 insertions(+), 161 deletions(-) create mode 100644 desktop/scripts/e2e-slash-plugin-agent-browser.sh diff --git a/desktop/scripts/e2e-slash-plugin-agent-browser.sh b/desktop/scripts/e2e-slash-plugin-agent-browser.sh new file mode 100644 index 00000000..cae2832b --- /dev/null +++ b/desktop/scripts/e2e-slash-plugin-agent-browser.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +if ! command -v agent-browser >/dev/null 2>&1; then + echo "agent-browser is required but was not found in PATH" >&2 + exit 1 +fi + +API_URL="${API_URL:-http://127.0.0.1:3457}" +WEB_URL="${WEB_URL:-http://127.0.0.1:5175/?serverUrl=http://127.0.0.1:3457}" +RUN_ID="$(date +%s)-$RANDOM" +SESSION_NAME="cc-haha-webui-e2e-${RUN_ID}" +ARTIFACT_DIR="$(mktemp -d "/tmp/cc-haha-webui-e2e-${RUN_ID}-XXXX")" +AB=(agent-browser --session "${SESSION_NAME}") + +cleanup() { + local exit_code=$? + "${AB[@]}" close >/dev/null 2>&1 || true + echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2 +} +trap cleanup EXIT + +wait_for_text() { + local needle="$1" + if ! "${AB[@]}" wait "text=${needle}" >/dev/null 2>&1; then + echo "Timed out waiting for page text: ${needle}" >&2 + "${AB[@]}" screenshot "${ARTIFACT_DIR}/failure-wait-$(echo "${needle}" | tr ' /' '__').png" >/dev/null 2>&1 || true + return 1 + fi +} + +press_escape() { + "${AB[@]}" press Escape >/dev/null 2>&1 || true +} + +focus_composer() { + "${AB[@]}" click textarea >/dev/null 2>&1 || "${AB[@]}" focus textarea >/dev/null 2>&1 || true +} + +submit_slash_command() { + local command="$1" + focus_composer + "${AB[@]}" fill textarea "${command}" + "${AB[@]}" press Enter +} + +healthcheck() { + curl -fsS "${API_URL}/health" >/dev/null +} + +select_plugin_targets() { + DETAIL_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r ' + .plugins + | map(select((.componentCounts.commands + .componentCounts.agents + .componentCounts.hooks + .componentCounts.skills) > 0)) + | sort_by(-(.componentCounts.commands + .componentCounts.agents + .componentCounts.hooks + .componentCounts.skills)) + | .[0].id // empty + ')" + + ENABLED_SKILL_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r ' + .plugins + | map(select(.enabled == true and .componentCounts.skills > 0)) + | sort_by(-.componentCounts.skills) + | .[0].id // empty + ')" + + MCP_PLUGIN_ID="$(curl -fsS "${API_URL}/api/plugins" | jq -r ' + .plugins + | map(select(.enabled == true and .componentCounts.mcpServers > 0)) + | sort_by(-.componentCounts.mcpServers) + | .[0].id // empty + ')" + + if [[ -z "${DETAIL_PLUGIN_ID}" ]]; then + echo "No plugin with commands/agents/hooks/skills was found in current API data." >&2 + exit 1 + fi + + if [[ -z "${ENABLED_SKILL_PLUGIN_ID}" ]]; then + echo "No enabled plugin with skills was found in current API data." >&2 + exit 1 + fi + + if [[ -z "${MCP_PLUGIN_ID}" ]]; then + echo "No plugin with MCP servers was found in current API data." >&2 + exit 1 + fi + + DETAIL_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.name')" + DETAIL_PLUGIN_ENABLED="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.enabled')" + DETAIL_COMMAND="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.commandEntries[0].name // empty')" + DETAIL_AGENT="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.agentEntries[0].name // empty')" + DETAIL_AGENT_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.agentEntries[0].displayName // .detail.agentEntries[0].name // empty')" + DETAIL_HOOK_EVENT="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.hookEntries[0].event // empty')" + DETAIL_HOOK_ACTION="$(curl -fsS "${API_URL}/api/plugins/detail?id=${DETAIL_PLUGIN_ID}" | jq -r '.detail.hookEntries[0].actions[0] // empty')" + DETAIL_SKILL_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].name // empty')" + DETAIL_SKILL_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].displayName // .detail.skillEntries[0].name // empty')" + DETAIL_SKILL_DESCRIPTION="$(curl -fsS --get --data-urlencode "source=plugin" --data-urlencode "name=${DETAIL_SKILL_NAME}" "${API_URL}/api/skills/detail" | jq -r '.detail.meta.description // empty')" + DETAIL_SKILL_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${ENABLED_SKILL_PLUGIN_ID}" | jq -r '.detail.name')" + + MCP_PLUGIN_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.name')" + MCP_SERVER_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.mcpServerEntries[0].name // empty')" + MCP_SERVER_LABEL="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.mcpServerEntries[0].displayName // .detail.mcpServerEntries[0].name // empty')" + MCP_SKILL_NAME="$(curl -fsS "${API_URL}/api/plugins/detail?id=${MCP_PLUGIN_ID}" | jq -r '.detail.skillEntries[0].name // empty')" +} + +open_plugin_detail() { + local plugin_name="$1" + local escaped_name="${plugin_name//\"/\\\"}" + if "${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes(\"${escaped_name}\")); if(!target) throw new Error('plugin button not found'); target.click();" >/dev/null 2>&1; then + wait_for_text "Bundled capabilities" + return 0 + fi + + echo "Failed to open plugin detail for: ${plugin_name}" >&2 + "${AB[@]}" screenshot "${ARTIFACT_DIR}/failure-open-plugin-${plugin_name}.png" >/dev/null 2>&1 || true + return 1 +} + +go_back_to_plugin_list() { + "${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes('Back to list')); if(!target) throw new Error('back button not found'); target.click();" >/dev/null + wait_for_text "Browse installed plugins" +} + +open_settings_sidebar_tab() { + local tab_name="$1" + "${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.trim()==='${tab_name}'); if(!target) throw new Error('settings tab not found: ${tab_name}'); target.click();" >/dev/null +} + +click_visible_card_text() { + local text="$1" + local escaped_text="${text//\"/\\\"}" + "${AB[@]}" eval "const target=[...document.querySelectorAll('button')].find((node)=>node.textContent?.includes(\"${escaped_text}\")); if(!target) throw new Error('button not found: ${escaped_text}'); target.click();" >/dev/null +} + +healthcheck +select_plugin_targets + +echo "Using detail plugin: ${DETAIL_PLUGIN_NAME} (${DETAIL_PLUGIN_ID})" +echo "Using enabled skill plugin: ${DETAIL_SKILL_PLUGIN_NAME} (${ENABLED_SKILL_PLUGIN_ID})" +echo "Using MCP plugin: ${MCP_PLUGIN_NAME} (${MCP_PLUGIN_ID})" + +"${AB[@]}" open "${WEB_URL}" +"${AB[@]}" wait --load networkidle +wait_for_text "Claude Code Haha" +"${AB[@]}" screenshot "${ARTIFACT_DIR}/01-home.png" >/dev/null + +# Always work from a fresh chat surface so slash-command behavior is deterministic. +"${AB[@]}" find role button click --name "New session" +"${AB[@]}" wait textarea >/dev/null + +submit_slash_command "/mcp" +wait_for_text "Available MCP tools" +"${AB[@]}" screenshot "${ARTIFACT_DIR}/02-mcp-panel.png" >/dev/null +press_escape +"${AB[@]}" wait 300 >/dev/null + +submit_slash_command "/skills" +wait_for_text "Available skills" +"${AB[@]}" screenshot "${ARTIFACT_DIR}/03-skills-panel.png" >/dev/null +press_escape +"${AB[@]}" wait 300 >/dev/null + +submit_slash_command "/plugin" +wait_for_text "Browse installed plugins" +wait_for_text "Plugin Manager" +"${AB[@]}" screenshot "${ARTIFACT_DIR}/04-plugins-list.png" >/dev/null + +open_plugin_detail "${DETAIL_PLUGIN_NAME}" +wait_for_text "Commands" +wait_for_text "Agents" +wait_for_text "Hooks" +wait_for_text "Skills" +if [[ -n "${DETAIL_COMMAND}" ]]; then + wait_for_text "/${DETAIL_COMMAND}" +fi +if [[ -n "${DETAIL_AGENT}" ]]; then + wait_for_text "${DETAIL_AGENT}" +fi +if [[ -n "${DETAIL_HOOK_EVENT}" ]]; then + wait_for_text "${DETAIL_HOOK_EVENT}" +fi +if [[ -n "${DETAIL_HOOK_ACTION}" ]]; then + wait_for_text "${DETAIL_HOOK_ACTION}" +fi +if [[ -n "${DETAIL_SKILL_NAME}" ]]; then + wait_for_text "/${DETAIL_SKILL_NAME}" +fi +"${AB[@]}" screenshot "${ARTIFACT_DIR}/05-plugin-detail-main.png" >/dev/null + +open_settings_sidebar_tab "Plugins" +wait_for_text "Plugin Detail" +go_back_to_plugin_list +open_plugin_detail "${DETAIL_SKILL_PLUGIN_NAME}" + +if [[ -n "${DETAIL_SKILL_LABEL}" ]]; then + click_visible_card_text "${DETAIL_SKILL_LABEL}" + wait_for_text "Skill metadata" + wait_for_text "${DETAIL_SKILL_LABEL}" + if [[ -n "${DETAIL_SKILL_DESCRIPTION}" ]]; then + wait_for_text "${DETAIL_SKILL_DESCRIPTION}" + fi + "${AB[@]}" screenshot "${ARTIFACT_DIR}/06-skill-detail-from-plugin.png" >/dev/null + + open_settings_sidebar_tab "Skills" + wait_for_text "Skill Browser" + wait_for_text "Plugin" + wait_for_text "${DETAIL_SKILL_LABEL}" + "${AB[@]}" screenshot "${ARTIFACT_DIR}/07-skills-list-with-plugin-group.png" >/dev/null +fi + +open_settings_sidebar_tab "Plugins" +wait_for_text "Plugin Detail" + +if [[ "${DETAIL_PLUGIN_ENABLED}" == "true" && -n "${DETAIL_AGENT_LABEL}" ]]; then + click_visible_card_text "${DETAIL_AGENT_LABEL}" + wait_for_text "Agent Profile" + wait_for_text "${DETAIL_AGENT}" + "${AB[@]}" screenshot "${ARTIFACT_DIR}/08-agent-detail-from-plugin.png" >/dev/null + + open_settings_sidebar_tab "Agents" + wait_for_text "Agent Browser" + wait_for_text "Plugin" + wait_for_text "${DETAIL_AGENT}" + "${AB[@]}" screenshot "${ARTIFACT_DIR}/09-agents-list-with-plugin-group.png" >/dev/null +fi + +go_back_to_plugin_list +open_plugin_detail "${MCP_PLUGIN_NAME}" +wait_for_text "MCP servers" +if [[ -n "${MCP_SERVER_NAME}" ]]; then + wait_for_text "${MCP_SERVER_LABEL:-$MCP_SERVER_NAME}" +fi +if [[ -n "${MCP_SKILL_NAME}" ]]; then + wait_for_text "/${MCP_SKILL_NAME}" +fi +"${AB[@]}" screenshot "${ARTIFACT_DIR}/10-plugin-detail-mcp.png" >/dev/null + +if [[ -n "${MCP_SERVER_LABEL}" ]]; then + click_visible_card_text "${MCP_SERVER_LABEL}" + wait_for_text "${MCP_SERVER_NAME}" + wait_for_text "Plugin" + "${AB[@]}" screenshot "${ARTIFACT_DIR}/11-mcp-detail-from-plugin.png" >/dev/null + + open_settings_sidebar_tab "MCP" + wait_for_text "MCP servers" + wait_for_text "Plugin" + wait_for_text "${MCP_SERVER_NAME}" + "${AB[@]}" screenshot "${ARTIFACT_DIR}/12-mcp-list-with-plugin-group.png" >/dev/null +fi + +echo "agent-browser web UI regression passed" +echo "Artifacts: ${ARTIFACT_DIR}" diff --git a/desktop/src/__tests__/agentsSettings.test.tsx b/desktop/src/__tests__/agentsSettings.test.tsx index 72a39672..e124362d 100644 --- a/desktop/src/__tests__/agentsSettings.test.tsx +++ b/desktop/src/__tests__/agentsSettings.test.tsx @@ -8,6 +8,7 @@ import { useSkillStore } from '../stores/skillStore' import { useSettingsStore } from '../stores/settingsStore' import { useSessionStore } from '../stores/sessionStore' import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore' +import { useUIStore } from '../stores/uiStore' vi.mock('../api/agents', () => ({ agentsApi: { @@ -79,6 +80,18 @@ const MOCK_AGENTS = [ isActive: false, overriddenBy: 'userSettings' as const, }, + { + agentType: 'telegram:pairing', + description: 'Plugin agent for Telegram pairing flows', + model: 'inherit', + modelDisplay: 'inherit', + tools: ['Read'], + systemPrompt: 'Pair Telegram access for the current workspace.', + color: 'cyan', + source: 'plugin' as const, + baseDir: '/Users/test/.claude/plugins/cache/telegram', + isActive: true, + }, ] const MOCK_SKILL_DETAIL = { @@ -132,6 +145,7 @@ describe('Settings > Agents tab', () => { activeTabId: 'session-1', tabs: [{ sessionId: 'session-1', title: 'Test', type: 'session', status: 'idle' }], }) + useUIStore.setState({ pendingSettingsTab: null }) useSessionStore.setState({ sessions: [ { @@ -164,6 +178,7 @@ describe('Settings > Agents tab', () => { isLoading: false, error: null, selectedAgent: null, + selectedAgentReturnTab: 'agents', fetchAgents: noopFetch, selectAgent: (agent) => useAgentStore.setState({ selectedAgent: agent }), }) @@ -236,8 +251,10 @@ describe('Settings > Agents tab', () => { expect(screen.getAllByText('User').length).toBeGreaterThan(0) expect(screen.getAllByText('Built-in').length).toBeGreaterThan(0) expect(screen.getAllByText('Project').length).toBeGreaterThan(0) + expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0) expect(screen.getByText('code-reviewer')).toBeInTheDocument() expect(screen.getByText('Writes technical documentation')).toBeInTheDocument() + expect(screen.getByText('telegram:pairing')).toBeInTheDocument() expect(screen.getByText('Overridden by User')).toBeInTheDocument() }) @@ -296,6 +313,29 @@ describe('Settings > Agents tab', () => { expect(screen.getByText('doc-writer')).toBeInTheDocument() expect(screen.getByText('plain-agent')).toBeInTheDocument() }) + + it('returns to plugins tab when agent detail was opened from plugins', () => { + useAgentStore.setState({ + allAgents: MOCK_AGENTS, + activeAgents: MOCK_AGENTS.filter((agent) => agent.isActive), + isLoading: false, + selectedAgent: MOCK_AGENTS[0], + selectedAgentReturnTab: 'plugins', + fetchAgents: noopFetch, + selectAgent: (agent) => + useAgentStore.setState({ + selectedAgent: agent, + selectedAgentReturnTab: agent ? 'plugins' : 'agents', + }), + }) + + render() + switchToAgentsTab() + + fireEvent.click(screen.getByText('Back to list')) + + expect(screen.getByText('Installed Plugins')).toBeInTheDocument() + }) }) describe('Settings > Skills tab', () => { diff --git a/desktop/src/__tests__/mcpSettings.test.tsx b/desktop/src/__tests__/mcpSettings.test.tsx index d65f5f55..973cad37 100644 --- a/desktop/src/__tests__/mcpSettings.test.tsx +++ b/desktop/src/__tests__/mcpSettings.test.tsx @@ -55,7 +55,7 @@ describe('McpSettings', () => { render() - expect(fetchServers).toHaveBeenCalledWith(undefined, undefined) + expect(fetchServers).toHaveBeenCalledWith(undefined, '/workspace/project') }) it('renders the empty state and add button', () => { @@ -66,24 +66,23 @@ describe('McpSettings', () => { expect(screen.getByRole('button', { name: /add server/i })).toBeInTheDocument() }) - it('shows only global MCP servers on the homepage', () => { + it('shows plugin and user MCP servers in grouped sections', () => { useMcpStore.setState({ servers: [ { - name: 'project-private', - scope: 'local', - projectPath: '/workspace/project', + name: 'plugin:telegram:telegram', + scope: 'dynamic', transport: 'stdio', enabled: true, status: 'connected', statusLabel: 'Connected', configLocation: '/tmp/config', - summary: 'npx demo', - canEdit: true, - canRemove: true, + summary: 'npx @telegram/mcp', + canEdit: false, + canRemove: false, canReconnect: true, canToggle: true, - config: { type: 'stdio', command: 'npx', args: ['demo'], env: {} }, + config: { type: 'stdio', command: 'npx', args: ['@telegram/mcp'], env: {} }, }, { name: 'global-user', @@ -105,8 +104,9 @@ describe('McpSettings', () => { render() - expect(screen.queryByText('project-private')).not.toBeInTheDocument() + expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0) + expect(screen.getAllByText('User').length).toBeGreaterThan(0) + expect(screen.getByText('plugin:telegram:telegram')).toBeInTheDocument() expect(screen.getByText('global-user')).toBeInTheDocument() - expect(screen.getByText('This page manages only user-global MCP servers for speed. Project-specific MCP will move into the chat slash-command experience.')).toBeInTheDocument() }) }) diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 644fdae4..b8656cf2 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -66,6 +66,10 @@ describe('Content-only pages render without errors', () => { }) expect(await screen.findByText('/lark-mail')).toBeInTheDocument() + expect(screen.getByText('/mcp')).toBeInTheDocument() + expect(screen.getByText('/skills')).toBeInTheDocument() + expect(screen.getByText('/plugin')).toBeInTheDocument() + expect(screen.getByText('/plugins')).toBeInTheDocument() expect(screen.queryByText('/internal-only')).not.toBeInTheDocument() }) @@ -263,7 +267,7 @@ describe('Content-only pages render without errors', () => { tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', - slashCommands: [{ name: 'mcp', description: 'List available MCP tools' }], + slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, }, @@ -288,6 +292,134 @@ describe('Content-only pages render without errors', () => { useChatStore.setState({ sessions: {} }) }) + it('ActiveSession opens a local /skills panel from the fallback slash commands', async () => { + const SESSION_ID = 'skills-panel-session' + const sendMessage = vi.fn() + vi.mocked(skillsApi.list).mockResolvedValueOnce({ + skills: [ + { + name: 'lark-mail', + description: 'Draft, send, and search emails', + source: 'user', + userInvocable: true, + contentLength: 120, + hasDirectory: true, + }, + ], + }) + useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID }) + useSessionStore.setState({ + sessions: [{ + id: SESSION_ID, + title: 'Test', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 0, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: SESSION_ID, + isLoading: false, + error: null, + }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + sendMessage, + }) + + render() + + const textarea = screen.getByPlaceholderText('Ask anything...') + fireEvent.change(textarea, { target: { value: '/skills', selectionStart: 7 } }) + fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' }) + + expect(sendMessage).not.toHaveBeenCalled() + expect(await screen.findByText('Available skills')).toBeInTheDocument() + expect(screen.getByText('/lark-mail')).toBeInTheDocument() + + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) + }) + + it('ActiveSession routes /plugin to Settings > Plugins instead of sending a chat message', () => { + const SESSION_ID = 'plugin-panel-session' + const sendMessage = vi.fn() + useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID }) + useSessionStore.setState({ + sessions: [{ + id: SESSION_ID, + title: 'Test', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 0, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: SESSION_ID, + isLoading: false, + error: null, + }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + sendMessage, + }) + + render() + + const textarea = screen.getByPlaceholderText('Ask anything...') + fireEvent.change(textarea, { target: { value: '/plugin', selectionStart: 7 } }) + fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' }) + + expect(sendMessage).not.toHaveBeenCalled() + expect(useTabStore.getState().activeTabId).toBe('__settings__') + expect(useUIStore.getState().pendingSettingsTab).toBe('plugins') + + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) + }) + it('AgentTeams renders team strip and members', () => { const { container } = render() expect(container.innerHTML).toContain('Architect') diff --git a/desktop/src/__tests__/pluginsSettings.test.tsx b/desktop/src/__tests__/pluginsSettings.test.tsx index d9b57a3e..3e8848e8 100644 --- a/desktop/src/__tests__/pluginsSettings.test.tsx +++ b/desktop/src/__tests__/pluginsSettings.test.tsx @@ -6,6 +6,12 @@ import { Settings } from '../pages/Settings' import { usePluginStore } from '../stores/pluginStore' import { useSettingsStore } from '../stores/settingsStore' import { useSessionStore } from '../stores/sessionStore' +import { useUIStore } from '../stores/uiStore' + +const MOCK_FETCH_SKILLS = vi.fn() +const MOCK_FETCH_SKILL_DETAIL = vi.fn() +const MOCK_FETCH_AGENTS = vi.fn() +const MOCK_FETCH_SERVERS = vi.fn() vi.mock('../api/agents', () => ({ agentsApi: { @@ -34,27 +40,87 @@ vi.mock('../pages/AdapterSettings', () => ({ })) vi.mock('../stores/agentStore', () => ({ - useAgentStore: () => ({ - activeAgents: [], - allAgents: [], - isLoading: false, - error: null, - selectedAgent: null, - fetchAgents: vi.fn(), - selectAgent: vi.fn(), + useAgentStore: Object.assign((selector?: (state: any) => unknown) => { + const state = { + activeAgents: [], + allAgents: [], + isLoading: false, + error: null, + selectedAgent: null, + fetchAgents: MOCK_FETCH_AGENTS, + selectAgent: vi.fn(), + } + return selector ? selector(state) : state + }, { + getState: () => ({ + activeAgents: [], + allAgents: [], + isLoading: false, + error: null, + selectedAgent: null, + fetchAgents: MOCK_FETCH_AGENTS, + selectAgent: vi.fn(), + }), }), })) vi.mock('../stores/skillStore', () => ({ - useSkillStore: () => ({ - skills: [], - selectedSkill: null, - isLoading: false, - isDetailLoading: false, - error: null, - fetchSkills: vi.fn(), - fetchSkillDetail: vi.fn(), - clearSelection: vi.fn(), + useSkillStore: Object.assign((selector?: (state: any) => unknown) => { + const state = { + skills: [], + selectedSkill: null, + isLoading: false, + isDetailLoading: false, + error: null, + fetchSkills: MOCK_FETCH_SKILLS, + fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL, + clearSelection: vi.fn(), + } + return selector ? selector(state) : state + }, { + getState: () => ({ + skills: [], + selectedSkill: null, + isLoading: false, + isDetailLoading: false, + error: null, + fetchSkills: MOCK_FETCH_SKILLS, + fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL, + clearSelection: vi.fn(), + }), + }), +})) + +vi.mock('../stores/mcpStore', () => ({ + useMcpStore: Object.assign((selector?: (state: any) => unknown) => { + const state = { + servers: [], + selectedServer: null, + isLoading: false, + error: null, + fetchServers: MOCK_FETCH_SERVERS, + createServer: vi.fn(), + updateServer: vi.fn(), + deleteServer: vi.fn(), + toggleServer: vi.fn(), + reconnectServer: vi.fn(), + selectServer: vi.fn(), + } + return selector ? selector(state) : state + }, { + getState: () => ({ + servers: [], + selectedServer: null, + isLoading: false, + error: null, + fetchServers: MOCK_FETCH_SERVERS, + createServer: vi.fn(), + updateServer: vi.fn(), + deleteServer: vi.fn(), + toggleServer: vi.fn(), + reconnectServer: vi.fn(), + selectServer: vi.fn(), + }), }), })) @@ -66,7 +132,9 @@ function switchToPluginsTab() { describe('Settings > Plugins tab', () => { beforeEach(() => { + vi.clearAllMocks() useSettingsStore.setState({ locale: 'en' }) + useUIStore.setState({ pendingSettingsTab: null }) useSessionStore.setState({ sessions: [ { @@ -210,6 +278,44 @@ describe('Settings > Plugins tab', () => { mcpServers: ['github-api'], lspServers: [], }, + commandEntries: [ + { + name: 'review-pr', + description: 'Review the current pull request.', + }, + ], + agentEntries: [ + { + name: 'pr-reviewer', + description: 'Review pull request quality and risk.', + }, + ], + hookEntries: [ + { + event: 'SessionStart', + matcher: 'Write', + actions: ['echo preparing plugin runtime'], + }, + ], + skillEntries: [ + { + name: 'create-pr', + description: 'Create a pull request from the current branch.', + }, + { + name: 'commit', + description: 'Commit the current staged changes.', + version: '1.0.0', + }, + ], + mcpServerEntries: [ + { + name: 'plugin:github:github-api', + displayName: 'github-api', + transport: 'http', + summary: 'https://api.github.com/mcp', + }, + ], errors: [], }, }) @@ -220,8 +326,119 @@ describe('Settings > Plugins tab', () => { expect(screen.getByText('Plugin Detail')).toBeInTheDocument() expect(screen.getByText('GitHub integration')).toBeInTheDocument() expect(screen.getByText('Bundled capabilities')).toBeInTheDocument() - expect(screen.getByText('review-pr')).toBeInTheDocument() + expect(screen.getByText('/review-pr')).toBeInTheDocument() + expect(screen.getByText('Review pull request quality and risk.')).toBeInTheDocument() + expect(screen.getByText('echo preparing plugin runtime')).toBeInTheDocument() + expect(screen.getByText('Create a pull request from the current branch.')).toBeInTheDocument() + expect(screen.getByText('https://api.github.com/mcp')).toBeInTheDocument() expect(screen.getByText('Apply changes')).toBeInTheDocument() expect(screen.getByText('Uninstall')).toBeInTheDocument() }) + + it('navigates plugin skills into the shared Skills page flow', () => { + usePluginStore.setState({ + selectedPlugin: { + id: 'telegram@claude-plugins-official', + name: 'telegram', + marketplace: 'claude-plugins-official', + scope: 'user', + enabled: true, + hasErrors: false, + isBuiltin: false, + description: 'Telegram integration', + componentCounts: { + commands: 0, + agents: 0, + skills: 1, + hooks: 0, + mcpServers: 0, + lspServers: 0, + }, + capabilities: { + commands: [], + agents: [], + skills: ['telegram:access'], + hooks: [], + mcpServers: [], + lspServers: [], + }, + commandEntries: [], + agentEntries: [], + hookEntries: [], + skillEntries: [ + { + name: 'telegram:access', + displayName: 'access', + description: 'Manage Telegram access.', + pluginName: 'telegram', + }, + ], + mcpServerEntries: [], + errors: [], + }, + }) + + render() + switchToPluginsTab() + + fireEvent.click(screen.getByText('access')) + + expect(MOCK_FETCH_SKILL_DETAIL).toHaveBeenCalledWith('plugin', 'telegram:access', '/workspace/project', 'plugins') + }) + + it('disables shared navigation cards for disabled plugins', () => { + usePluginStore.setState({ + selectedPlugin: { + id: 'codex@openai-codex', + name: 'codex', + marketplace: 'openai-codex', + scope: 'user', + enabled: false, + hasErrors: false, + isBuiltin: false, + description: 'Use Codex from Claude Code', + componentCounts: { + commands: 0, + agents: 1, + skills: 1, + hooks: 0, + mcpServers: 0, + lspServers: 0, + }, + capabilities: { + commands: [], + agents: ['codex:codex-rescue'], + skills: ['codex:gpt-5-4-prompting'], + hooks: [], + mcpServers: [], + lspServers: [], + }, + commandEntries: [], + agentEntries: [ + { + name: 'codex:codex-rescue', + displayName: 'codex-rescue', + description: 'Delegate to Codex.', + }, + ], + hookEntries: [], + skillEntries: [ + { + name: 'codex:gpt-5-4-prompting', + displayName: 'gpt-5-4-prompting', + description: 'Prompting guide.', + }, + ], + mcpServerEntries: [], + errors: [], + }, + }) + + render() + switchToPluginsTab() + + expect(screen.getAllByText('Enable this plugin and apply changes before opening its skills, agents, or MCP entries in the shared management pages.').length).toBeGreaterThan(0) + expect(screen.getByRole('button', { name: /codex-rescue/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /gpt-5-4-prompting/i })).toBeDisabled() + }) }) diff --git a/desktop/src/__tests__/skillsSettings.test.tsx b/desktop/src/__tests__/skillsSettings.test.tsx index 19e46203..8280b647 100644 --- a/desktop/src/__tests__/skillsSettings.test.tsx +++ b/desktop/src/__tests__/skillsSettings.test.tsx @@ -7,6 +7,7 @@ import { useSkillStore } from '../stores/skillStore' import { useSettingsStore } from '../stores/settingsStore' import { useSessionStore } from '../stores/sessionStore' import { useTabStore, SETTINGS_TAB_ID } from '../stores/tabStore' +import { useUIStore } from '../stores/uiStore' vi.mock('../api/agents', () => ({ agentsApi: { @@ -82,9 +83,11 @@ describe('Settings > Skills tab', () => { availableProjects: ['/workspace/project'], }) useTabStore.setState({ tabs: [], activeTabId: null }) + useUIStore.setState({ pendingSettingsTab: null }) useSkillStore.setState({ skills: [], selectedSkill: null, + selectedSkillReturnTab: 'skills', isLoading: false, isDetailLoading: false, error: null, @@ -115,6 +118,16 @@ describe('Settings > Skills tab', () => { contentLength: 200, hasDirectory: true, }, + { + name: 'telegram:access', + displayName: 'Telegram Access', + description: 'Plugin-provided access workflow', + source: 'plugin', + pluginName: 'telegram', + userInvocable: true, + contentLength: 280, + hasDirectory: true, + }, ], }) @@ -126,6 +139,8 @@ describe('Settings > Skills tab', () => { expect(screen.getByText('Total skills')).toBeInTheDocument() expect(screen.getByText('Alpha Skill')).toBeInTheDocument() expect(screen.getByText('Second skill description')).toBeInTheDocument() + expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0) + expect(screen.getByText('Telegram Access')).toBeInTheDocument() }) it('uses the active session workDir even when settings tab is focused', () => { @@ -190,6 +205,7 @@ describe('Settings > Skills tab', () => { ], skillRoot: '/tmp/alpha', }, + selectedSkillReturnTab: 'skills', }) render() @@ -202,4 +218,39 @@ describe('Settings > Skills tab', () => { expect(screen.getByText('Hello')).toBeInTheDocument() expect(screen.queryByText(/^---$/)).not.toBeInTheDocument() }) + + it('returns to plugins tab when skill detail was opened from plugins', () => { + useSkillStore.setState({ + selectedSkill: { + meta: { + name: 'telegram:access', + displayName: 'Access', + description: 'Plugin skill', + source: 'plugin', + userInvocable: true, + contentLength: 200, + hasDirectory: true, + }, + tree: [{ name: 'SKILL.md', path: 'SKILL.md', type: 'file' }], + files: [ + { + path: 'SKILL.md', + content: '# Access', + body: '# Access', + language: 'markdown', + isEntry: true, + }, + ], + skillRoot: '/tmp/telegram-access', + }, + selectedSkillReturnTab: 'plugins', + }) + + render() + switchToSkillsTab() + + fireEvent.click(screen.getByText('Back to list')) + + expect(screen.getByText('Installed Plugins')).toBeInTheDocument() + }) }) diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 398d07df..d6004b38 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -1,7 +1,8 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { useTranslation } from '../../i18n' import { useChatStore } from '../../stores/chatStore' -import { useTabStore } from '../../stores/tabStore' +import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' +import { useUIStore } from '../../stores/uiStore' import { useSessionStore } from '../../stores/sessionStore' import { useTeamStore } from '../../stores/teamStore' import { sessionsApi } from '../../api/sessions' @@ -18,6 +19,7 @@ import { findSlashTrigger, mergeSlashCommands, replaceSlashToken, + resolveSlashUiAction, } from './composerUtils' type GitInfo = { branch: string | null; repoName: string | null; workDir: string; changedFiles: number } @@ -70,6 +72,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const isWorkspaceMissing = activeSession?.workDirExists === false const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0)) const isHeroComposer = variant === 'hero' && !isMemberSession + const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined useEffect(() => { textareaRef.current?.focus() @@ -295,8 +298,19 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { const text = input.trim() if ((!text && (!attachments.length || isMemberSession)) || isWorkspaceMissing) return - if (!isMemberSession && (text === '/mcp' || text === '/skills' || text === '/plugins')) { - setLocalSlashPanel(text.slice(1) as LocalSlashCommandName) + const slashUiAction = !isMemberSession && text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null + if (slashUiAction?.type === 'panel') { + setLocalSlashPanel(slashUiAction.command as LocalSlashCommandName) + setInput('') + setSlashMenuOpen(false) + setFileSearchOpen(false) + setPlusMenuOpen(false) + return + } + + if (slashUiAction?.type === 'settings') { + useUIStore.getState().setPendingSettingsTab(slashUiAction.tab) + useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') setInput('') setSlashMenuOpen(false) setFileSearchOpen(false) @@ -501,7 +515,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) { {!isMemberSession && fileSearchOpen && ( { if (atCursorPos >= 0) { @@ -525,7 +539,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
setLocalSlashPanel(null)} />
@@ -681,13 +695,13 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
{hasMessages ? ( ) : ( { if (!activeTabId) return const oldId = activeTabId diff --git a/desktop/src/components/chat/LocalSlashCommandPanel.tsx b/desktop/src/components/chat/LocalSlashCommandPanel.tsx index dceaf6bf..b0a2e0d0 100644 --- a/desktop/src/components/chat/LocalSlashCommandPanel.tsx +++ b/desktop/src/components/chat/LocalSlashCommandPanel.tsx @@ -9,7 +9,7 @@ import { useSkillStore } from '../../stores/skillStore' import type { McpServerRecord } from '../../types/mcp' import type { SkillMeta } from '../../types/skill' -export type LocalSlashCommandName = 'mcp' | 'skills' | 'plugins' +export type LocalSlashCommandName = 'mcp' | 'skills' type Props = { command: LocalSlashCommandName @@ -243,7 +243,7 @@ function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) { type="button" key={`${skill.source}:${skill.name}`} onClick={async () => { - await fetchSkillDetail(skill.source, skill.name, cwd) + await fetchSkillDetail(skill.source, skill.name, cwd, 'skills') setPendingSettingsTab('skills') useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') onClose() @@ -265,21 +265,7 @@ function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) { ) } -function PluginsPanel({ onClose }: { onClose: () => void }) { - const t = useTranslation() - return ( - - - - ) -} - export function LocalSlashCommandPanel({ command, cwd, onClose }: Props) { if (command === 'mcp') return - if (command === 'skills') return - return + return } diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index c263f2c7..0c2c8897 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -1,4 +1,18 @@ +import type { SettingsTab } from '../../stores/uiStore' + +export const PANEL_SLASH_COMMANDS = [ + { name: 'mcp', description: 'Open available MCP tools for the current chat context' }, + { name: 'skills', description: 'Browse user-invocable skills for the current chat context' }, +] as const + +export const SETTINGS_SLASH_COMMANDS = [ + { name: 'plugin', description: 'Open desktop plugin controls in Settings', tab: 'plugins' as const }, + { name: 'plugins', description: 'Open desktop plugin controls in Settings', tab: 'plugins' as const }, +] as const + export const FALLBACK_SLASH_COMMANDS = [ + ...PANEL_SLASH_COMMANDS, + ...SETTINGS_SLASH_COMMANDS.map(({ name, description }) => ({ name, description })), { name: 'compact', description: 'Compact conversation context' }, { name: 'clear', description: 'Clear conversation history' }, { name: 'help', description: 'Show available commands' }, @@ -25,6 +39,30 @@ export type SlashCommandOption = { description: string } +export type SlashUiAction = + | { + type: 'panel' + command: typeof PANEL_SLASH_COMMANDS[number]['name'] + } + | { + type: 'settings' + tab: SettingsTab + } + +export function resolveSlashUiAction(value: string): SlashUiAction | null { + const panelCommand = PANEL_SLASH_COMMANDS.find((command) => command.name === value) + if (panelCommand) { + return { type: 'panel', command: panelCommand.name } + } + + const settingsCommand = SETTINGS_SLASH_COMMANDS.find((command) => command.name === value) + if (settingsCommand) { + return { type: 'settings', tab: settingsCommand.tab } + } + + return null +} + export function mergeSlashCommands( preferred: ReadonlyArray, fallback: ReadonlyArray = FALLBACK_SLASH_COMMANDS, diff --git a/desktop/src/components/plugins/PluginDetail.tsx b/desktop/src/components/plugins/PluginDetail.tsx index f12bc1f3..ef0acfe2 100644 --- a/desktop/src/components/plugins/PluginDetail.tsx +++ b/desktop/src/components/plugins/PluginDetail.tsx @@ -1,17 +1,16 @@ -import { useState, type ReactNode } from 'react' +import { useMemo, useState, type ReactNode } from 'react' import { usePluginStore } from '../../stores/pluginStore' import { useSessionStore } from '../../stores/sessionStore' import { useTranslation } from '../../i18n' import { useUIStore } from '../../stores/uiStore' import { Button } from '../shared/Button' import type { PluginCapabilityKey } from '../../types/plugin' +import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' +import { useSkillStore } from '../../stores/skillStore' +import { useAgentStore } from '../../stores/agentStore' +import { useMcpStore } from '../../stores/mcpStore' const CAPABILITY_ORDER: PluginCapabilityKey[] = [ - 'skills', - 'commands', - 'agents', - 'hooks', - 'mcpServers', 'lspServers', ] @@ -30,6 +29,11 @@ export function PluginDetail() { const sessions = useSessionStore((s) => s.sessions) const activeSessionId = useSessionStore((s) => s.activeSessionId) const addToast = useUIStore((s) => s.addToast) + const fetchSkillDetail = useSkillStore((s) => s.fetchSkillDetail) + const fetchAgents = useAgentStore((s) => s.fetchAgents) + const selectAgent = useAgentStore((s) => s.selectAgent) + const fetchServers = useMcpStore((s) => s.fetchServers) + const selectServer = useMcpStore((s) => s.selectServer) const t = useTranslation() const [actionKey, setActionKey] = useState(null) @@ -47,6 +51,15 @@ export function PluginDetail() { if (!selectedPlugin) return null const canMutate = selectedPlugin.scope !== 'managed' && selectedPlugin.scope !== 'builtin' + const canNavigateSharedCapabilities = selectedPlugin.enabled + const otherCapabilityItems = useMemo( + () => + CAPABILITY_ORDER.map((key) => ({ + key, + items: selectedPlugin.capabilities[key], + })), + [selectedPlugin], + ) const runAction = async (key: string, fn: () => Promise) => { setActionKey(key) @@ -90,6 +103,76 @@ export function PluginDetail() { return window.confirm(label) } + const openSettingsTab = (tab: 'skills' | 'agents' | 'mcp') => { + useUIStore.getState().setPendingSettingsTab(tab) + useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings') + } + + const handleOpenSkill = async (skillName: string) => { + if (!canNavigateSharedCapabilities) { + addToast({ + type: 'warning', + message: t('settings.plugins.sharedNavigationDisabled'), + }) + return + } + openSettingsTab('skills') + await fetchSkillDetail('plugin', skillName, currentWorkDir, 'plugins') + + const { selectedSkill, error } = useSkillStore.getState() + if (!selectedSkill && error) { + addToast({ type: 'error', message: error }) + } + } + + const handleOpenAgent = async (agentType: string) => { + if (!canNavigateSharedCapabilities) { + addToast({ + type: 'warning', + message: t('settings.plugins.sharedNavigationDisabled'), + }) + return + } + openSettingsTab('agents') + await fetchAgents(currentWorkDir) + + const state = useAgentStore.getState() + const agent = state.allAgents.find((entry) => entry.agentType === agentType) + if (!agent) { + addToast({ + type: 'error', + message: `Unable to locate agent: ${agentType}`, + }) + return + } + + selectAgent(agent, 'plugins') + } + + const handleOpenMcpServer = async (serverName: string) => { + if (!canNavigateSharedCapabilities) { + addToast({ + type: 'warning', + message: t('settings.plugins.sharedNavigationDisabled'), + }) + return + } + openSettingsTab('mcp') + await fetchServers(undefined, currentWorkDir) + + const state = useMcpStore.getState() + const server = state.servers.find((entry) => entry.name === serverName) + if (!server) { + addToast({ + type: 'error', + message: `Unable to locate MCP server: ${serverName}`, + }) + return + } + + selectServer(server) + } + return (
@@ -261,10 +344,112 @@ export function PluginDetail() { {t('settings.plugins.capabilitiesHint')}

-
- {CAPABILITY_ORDER.map((key) => { - const items = selectedPlugin.capabilities[key] - return ( +
+ + {selectedPlugin.skillEntries.length > 0 ? ( +
+ {selectedPlugin.skillEntries.map((skill) => ( + void handleOpenSkill(skill.name)} + disabled={!canNavigateSharedCapabilities} + /> + ))} +
+ ) : null} +
+ + + {selectedPlugin.mcpServerEntries.length > 0 ? ( +
+ {selectedPlugin.mcpServerEntries.map((server) => ( + void handleOpenMcpServer(server.name)} + disabled={!canNavigateSharedCapabilities} + /> + ))} +
+ ) : null} +
+ + + {selectedPlugin.commandEntries.length > 0 ? ( +
+ {selectedPlugin.commandEntries.map((command) => ( + + ))} +
+ ) : null} +
+ + + {selectedPlugin.agentEntries.length > 0 ? ( +
+ {selectedPlugin.agentEntries.map((agent) => ( + void handleOpenAgent(agent.name)} + disabled={!canNavigateSharedCapabilities} + /> + ))} +
+ ) : null} +
+ + + {selectedPlugin.hookEntries.length > 0 ? ( +
+ {selectedPlugin.hookEntries.map((hook, index) => ( + + ))} +
+ ) : null} +
+ +
+ {otherCapabilityItems.map(({ key, items }) => (
)}
- ) - })} + ))} +
) } +function CapabilityPreviewSection({ + title, + count, + children, + emptyLabel, + hint, +}: { + title: string + count: number + children: ReactNode + emptyLabel: string + hint?: string +}) { + return ( +
+
+
{title}
+
{count}
+
+
+ {hint && count > 0 && ( +
{hint}
+ )} + {count > 0 ? children : ( +
{emptyLabel}
+ )} +
+
+ ) +} + +function SkillPreviewCard({ + name, + rawName, + description, + version, + onClick, + disabled, +}: { + name: string + rawName?: string + description: string + version?: string + onClick: () => void + disabled?: boolean +}) { + const t = useTranslation() + const slashName = rawName || name + + return ( + + ) +} + +function CommandPreviewCard({ + name, + description, +}: { + name: string + description: string +}) { + return ( +
+
/{name}
+
{description}
+
+ ) +} + +function AgentPreviewCard({ + name, + description, + onClick, + disabled, +}: { + name: string + description: string + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} + +function McpPreviewCard({ + name, + transport, + summary, + onClick, + disabled, +}: { + name: string + transport: string + summary: string + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} + +function HookPreviewCard({ + event, + matcher, + actions, +}: { + event: string + matcher?: string + actions: string[] +}) { + return ( +
+
+ {event} + {matcher && ( + + {matcher} + + )} +
+
+ {actions.map((action) => ( + + {action} + + ))} +
+
+ ) +} + function MetaPill({ children }: { children: ReactNode }) { return ( diff --git a/desktop/src/components/skills/SkillDetail.tsx b/desktop/src/components/skills/SkillDetail.tsx index d5b9cec0..7788fe46 100644 --- a/desktop/src/components/skills/SkillDetail.tsx +++ b/desktop/src/components/skills/SkillDetail.tsx @@ -4,6 +4,7 @@ import { useTranslation } from '../../i18n' import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { CodeViewer } from '../chat/CodeViewer' import type { FileTreeNode, SkillFrontmatter } from '../../types/skill' +import { useUIStore } from '../../stores/uiStore' const META_PRIORITY = [ 'description', @@ -20,7 +21,7 @@ const META_PRIORITY = [ ] as const export function SkillDetail() { - const { selectedSkill, isDetailLoading, clearSelection } = useSkillStore() + const { selectedSkill, selectedSkillReturnTab, isDetailLoading, clearSelection } = useSkillStore() const t = useTranslation() const [selectedFile, setSelectedFile] = useState('SKILL.md') @@ -31,6 +32,14 @@ export function SkillDetail() { : selectedSkill.files[0]?.path || 'SKILL.md' }, [selectedFile, selectedSkill]) + const handleBack = () => { + const returnTab = selectedSkillReturnTab + clearSelection() + if (returnTab === 'plugins') { + useUIStore.getState().setPendingSettingsTab('plugins') + } + } + if (isDetailLoading) { return (
@@ -50,7 +59,7 @@ export function SkillDetail() {
@@ -340,6 +384,8 @@ function ServerRow({ export function McpSettings() { const { servers, selectedServer, isLoading, error, fetchServers, createServer, updateServer, deleteServer, toggleServer, reconnectServer, selectServer } = useMcpStore() const addToast = useUIStore((s) => s.addToast) + const sessions = useSessionStore((s) => s.sessions) + const activeSessionId = useSessionStore((s) => s.activeSessionId) const t = useTranslation() const [view, setView] = useState({ type: 'list' }) const [draft, setDraft] = useState(createEmptyDraft) @@ -348,20 +394,27 @@ export function McpSettings() { const [busyServerName, setBusyServerName] = useState(null) const [pendingDeleteServer, setPendingDeleteServer] = useState(null) - useEffect(() => { - void fetchServers(undefined, undefined) - }, [fetchServers]) + const activeSession = sessions.find((session) => session.id === activeSessionId) + const currentWorkDir = activeSession?.workDir || undefined - const globalServers = useMemo( - () => servers.filter((server) => server.scope === 'user'), - [servers], - ) + useEffect(() => { + void fetchServers(undefined, currentWorkDir) + }, [fetchServers, currentWorkDir]) + + const groupedServers = useMemo(() => { + const groups: Partial> = {} + for (const server of servers) { + const key = getServerGroupKey(server) + ;(groups[key] ??= []).push(server) + } + return groups + }, [servers]) const stats = useMemo(() => ({ - total: globalServers.length, - connected: globalServers.filter((server) => server.status === 'connected').length, - attention: globalServers.filter((server) => server.status === 'failed' || server.status === 'needs-auth').length, - }), [globalServers]) + total: servers.length, + connected: servers.filter((server) => server.status === 'connected').length, + attention: servers.filter((server) => server.status === 'failed' || server.status === 'needs-auth').length, + }), [servers]) const beginCreate = () => { setDraft(createEmptyDraft()) @@ -546,7 +599,7 @@ export function McpSettings() {
- +
@@ -763,9 +816,6 @@ export function McpSettings() {

{t('settings.mcp.description')}

-

- {t('settings.mcp.globalOnlyHint')} -

- {isLoading && globalServers.length === 0 ? ( + {isLoading && servers.length === 0 ? (
@@ -789,39 +839,48 @@ export function McpSettings() {

{error}

- ) : globalServers.length === 0 ? ( + ) : servers.length === 0 ? (
dns

{t('settings.mcp.empty')}

{t('settings.mcp.emptyHint')}

) : ( -
-
-
- {t('settings.mcp.scope.user')} -
-
{globalServers.length}
-
-
- {globalServers.map((server) => ( - beginEdit(server)} - onToggle={() => void handleToggle(server)} - t={t} - /> - ))} -
-
+
+ {MCP_GROUP_ORDER.map((group) => { + const groupServers = groupedServers[group] + if (!groupServers?.length) return null + + return ( +
+
+
+ {group === 'plugin' ? t('settings.mcp.scope.plugin') : t(`settings.mcp.scope.${group}`)} +
+
{groupServers.length}
+
+
+ {groupServers.map((server) => ( + beginEdit(server)} + onToggle={() => void handleToggle(server)} + t={t} + /> + ))} +
+
+ ) + })} +
)} (groupedAgents[source] ?? []).length > 0).length + const handleAgentBack = () => { + const returnTab = selectedAgentReturnTab + selectAgent(null) + if (returnTab === 'plugins') { + useUIStore.getState().setPendingSettingsTab('plugins') + } + } + if (selectedAgent) { return (
- selectAgent(null)} /> +
) } @@ -938,7 +947,7 @@ function AgentsSettings() { {group.map((agent) => (