From 3aec79a066a902d271e8412288af6090384f97ce 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: Thu, 9 Apr 2026 12:31:04 +0800 Subject: [PATCH] feat: add Agents tab to desktop Settings page - Add API client, Zustand store, and i18n for agents list - Display installed agents from ~/.claude/agents/ with detail view - Render agent system prompt as Markdown via MarkdownRenderer - Include error state with retry and empty state guidance - Add 11 component tests covering all UI states and navigation --- desktop/src/__tests__/agentsSettings.test.tsx | 207 ++++++++++++++++++ desktop/src/api/agents.ts | 14 ++ desktop/src/i18n/locales/en.ts | 14 ++ desktop/src/i18n/locales/zh.ts | 14 ++ desktop/src/pages/Settings.tsx | 154 ++++++++++++- desktop/src/stores/agentStore.ts | 32 +++ 6 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 desktop/src/__tests__/agentsSettings.test.tsx create mode 100644 desktop/src/api/agents.ts create mode 100644 desktop/src/stores/agentStore.ts diff --git a/desktop/src/__tests__/agentsSettings.test.tsx b/desktop/src/__tests__/agentsSettings.test.tsx new file mode 100644 index 00000000..c353c789 --- /dev/null +++ b/desktop/src/__tests__/agentsSettings.test.tsx @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { Settings } from '../pages/Settings' +import { useAgentStore } from '../stores/agentStore' + +// Mock the API module so no real HTTP calls are made +vi.mock('../api/agents', () => ({ + agentsApi: { + list: vi.fn().mockResolvedValue({ agents: [] }), + }, +})) + +/** Override fetchAgents to a no-op so useEffect doesn't clobber manually set state */ +const noopFetch = vi.fn() + +// Mock MarkdownRenderer to avoid pulling in `marked` and CodeViewer deps +vi.mock('../components/markdown/MarkdownRenderer', () => ({ + MarkdownRenderer: ({ content }: { content: string }) => ( +
{content}
+ ), +})) + +// Mock the provider store so ProviderSettings doesn't crash +vi.mock('../stores/providerStore', () => ({ + useProviderStore: () => ({ + providers: [], + activeId: null, + isLoading: false, + fetchProviders: vi.fn(), + deleteProvider: vi.fn(), + activateProvider: vi.fn(), + activateOfficial: vi.fn(), + testProvider: vi.fn(), + createProvider: vi.fn(), + updateProvider: vi.fn(), + testConfig: vi.fn(), + }), +})) + +// Mock the adapter settings to avoid its side effects +vi.mock('../pages/AdapterSettings', () => ({ + AdapterSettings: () =>
Adapter Settings Mock
, +})) + +const MOCK_AGENTS = [ + { + name: 'code-reviewer', + description: 'Reviews code for quality and security', + model: 'claude-sonnet-4-6', + tools: ['Read', 'Grep', 'Glob'], + systemPrompt: '# Code Reviewer\n\nYou are an expert code reviewer.', + color: 'blue', + }, + { + name: 'doc-writer', + description: 'Writes technical documentation', + model: 'claude-haiku-4-5-20251001', + systemPrompt: 'You write clear and concise docs.', + color: 'green', + }, + { + name: 'plain-agent', + description: undefined, + model: undefined, + systemPrompt: undefined, + color: undefined, + }, +] + +function switchToAgentsTab() { + // The Agents tab button has text "Agents" + const agentsTab = screen.getByText('Agents') + fireEvent.click(agentsTab) +} + +describe('Settings > Agents tab', () => { + beforeEach(() => { + // Reset store to default state before each test + useAgentStore.setState({ + agents: [], + isLoading: false, + error: null, + selectedAgent: null, + }) + }) + + it('renders the Agents tab button in sidebar', () => { + render() + expect(screen.getByText('Agents')).toBeInTheDocument() + }) + + it('shows loading spinner when fetching agents', () => { + useAgentStore.setState({ isLoading: true, agents: [], fetchAgents: noopFetch }) + render() + switchToAgentsTab() + + expect(screen.getByText('Installed Agents')).toBeInTheDocument() + const spinner = document.querySelector('.animate-spin') + expect(spinner).toBeInTheDocument() + }) + + it('shows empty state when no agents installed', () => { + useAgentStore.setState({ agents: [], isLoading: false, fetchAgents: noopFetch }) + render() + switchToAgentsTab() + + expect(screen.getByText('No agents installed yet.')).toBeInTheDocument() + expect(screen.getByText(/Create .md or .yaml files/)).toBeInTheDocument() + }) + + it('shows error state with retry button when API fails', () => { + useAgentStore.setState({ agents: [], isLoading: false, error: 'Network error', fetchAgents: noopFetch }) + render() + switchToAgentsTab() + + expect(screen.getByText('Network error')).toBeInTheDocument() + expect(screen.getByText('Retry')).toBeInTheDocument() + }) + + it('renders agent list with names and descriptions', () => { + useAgentStore.setState({ agents: MOCK_AGENTS, isLoading: false, fetchAgents: noopFetch }) + render() + switchToAgentsTab() + + expect(screen.getByText('code-reviewer')).toBeInTheDocument() + expect(screen.getByText('Reviews code for quality and security')).toBeInTheDocument() + expect(screen.getByText('doc-writer')).toBeInTheDocument() + expect(screen.getByText('Writes technical documentation')).toBeInTheDocument() + // Agent count badge + expect(screen.getByText('3 agents')).toBeInTheDocument() + }) + + it('shows model badge for agents with model defined', () => { + useAgentStore.setState({ agents: MOCK_AGENTS, isLoading: false, fetchAgents: noopFetch }) + render() + switchToAgentsTab() + + expect(screen.getByText('claude-sonnet-4-6')).toBeInTheDocument() + expect(screen.getByText('claude-haiku-4-5-20251001')).toBeInTheDocument() + }) + + it('shows "No description" for agents without description', () => { + useAgentStore.setState({ agents: MOCK_AGENTS, isLoading: false, fetchAgents: noopFetch }) + render() + switchToAgentsTab() + + expect(screen.getByText('No description')).toBeInTheDocument() + }) + + it('navigates to agent detail view when clicking an agent', () => { + useAgentStore.setState({ agents: MOCK_AGENTS, isLoading: false, fetchAgents: noopFetch }) + render() + switchToAgentsTab() + + fireEvent.click(screen.getByText('code-reviewer')) + + // Detail view should show + expect(screen.getByText('Back to list')).toBeInTheDocument() + expect(screen.getByText('Reviews code for quality and security')).toBeInTheDocument() + // Model meta + expect(screen.getByText(/claude-sonnet-4-6/)).toBeInTheDocument() + // Tools meta + expect(screen.getByText(/Read, Grep, Glob/)).toBeInTheDocument() + }) + + it('renders system prompt as Markdown in detail view', () => { + useAgentStore.setState({ agents: MOCK_AGENTS, isLoading: false, fetchAgents: noopFetch }) + render() + switchToAgentsTab() + + fireEvent.click(screen.getByText('code-reviewer')) + + const mdRenderer = screen.getByTestId('markdown-renderer') + expect(mdRenderer).toBeInTheDocument() + expect(mdRenderer.textContent).toContain('Code Reviewer') + }) + + it('shows "no system prompt" message when agent has no prompt', () => { + useAgentStore.setState({ agents: MOCK_AGENTS, isLoading: false, fetchAgents: noopFetch }) + render() + switchToAgentsTab() + + fireEvent.click(screen.getByText('plain-agent')) + + expect(screen.getByText('No system prompt defined.')).toBeInTheDocument() + }) + + it('navigates back to list from detail view', () => { + useAgentStore.setState({ agents: MOCK_AGENTS, isLoading: false, fetchAgents: noopFetch }) + render() + switchToAgentsTab() + + // Go to detail + fireEvent.click(screen.getByText('code-reviewer')) + expect(screen.getByText('Back to list')).toBeInTheDocument() + + // Go back + fireEvent.click(screen.getByText('Back to list')) + + // Should see the list again + expect(screen.getByText('code-reviewer')).toBeInTheDocument() + expect(screen.getByText('doc-writer')).toBeInTheDocument() + expect(screen.getByText('plain-agent')).toBeInTheDocument() + }) +}) diff --git a/desktop/src/api/agents.ts b/desktop/src/api/agents.ts new file mode 100644 index 00000000..b123c825 --- /dev/null +++ b/desktop/src/api/agents.ts @@ -0,0 +1,14 @@ +import { api } from './client' + +export type AgentDefinition = { + name: string + description?: string + model?: string + tools?: string[] + systemPrompt?: string + color?: string +} + +export const agentsApi = { + list: () => api.get<{ agents: AgentDefinition[] }>('/api/agents'), +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index b669712b..cc763340 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -139,6 +139,20 @@ export const en = { 'settings.adapters.platform.telegram': 'Telegram', 'settings.adapters.platform.feishu': 'Feishu', + // Settings > Agents + 'settings.tab.agents': 'Agents', + 'settings.agents.title': 'Installed Agents', + 'settings.agents.description': 'Agents discovered from ~/.claude/agents/. Use /agents in TUI to manage them.', + 'settings.agents.empty': 'No agents installed yet.', + 'settings.agents.emptyHint': 'Create .md or .yaml files in ~/.claude/agents/ to add custom agents.', + 'settings.agents.model': 'Model', + 'settings.agents.tools': 'Tools', + 'settings.agents.systemPrompt': 'System Prompt', + 'settings.agents.noDescription': 'No description', + 'settings.agents.noSystemPrompt': 'No system prompt defined.', + 'settings.agents.backToList': 'Back to list', + 'settings.agents.agentCount': '{count} agents', + // Settings > General 'settings.general.languageTitle': 'Language', 'settings.general.languageDescription': 'Choose the display language for the application.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index e9be3840..dac1b53f 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -141,6 +141,20 @@ export const zh: Record = { 'settings.adapters.platform.telegram': 'Telegram', 'settings.adapters.platform.feishu': '飞书', + // Settings > Agents + 'settings.tab.agents': 'Agents', + 'settings.agents.title': '已安装的 Agents', + 'settings.agents.description': '从 ~/.claude/agents/ 发现的 Agent。可在 TUI 中使用 /agents 管理。', + 'settings.agents.empty': '暂无已安装的 Agent。', + 'settings.agents.emptyHint': '在 ~/.claude/agents/ 中创建 .md 或 .yaml 文件来添加自定义 Agent。', + 'settings.agents.model': '模型', + 'settings.agents.tools': '工具', + 'settings.agents.systemPrompt': '系统提示词', + 'settings.agents.noDescription': '暂无描述', + 'settings.agents.noSystemPrompt': '未定义系统提示词。', + 'settings.agents.backToList': '返回列表', + 'settings.agents.agentCount': '{count} 个 Agent', + // Settings > General 'settings.general.languageTitle': '语言', 'settings.general.languageDescription': '选择应用程序的显示语言。', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 2d225b68..fca8ecd4 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -11,8 +11,11 @@ import { PROVIDER_PRESETS } from '../config/providerPresets' import type { ProviderPreset } from '../config/providerPresets' import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider' import { AdapterSettings } from './AdapterSettings' +import { useAgentStore } from '../stores/agentStore' +import type { AgentDefinition } from '../api/agents' +import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer' -type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' +type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'agents' export function Settings() { const [activeTab, setActiveTab] = useState('providers') @@ -27,6 +30,7 @@ export function Settings() { setActiveTab('permissions')} /> setActiveTab('general')} /> setActiveTab('adapters')} /> + setActiveTab('agents')} /> {/* Tab content */} @@ -35,6 +39,7 @@ export function Settings() { {activeTab === 'permissions' && } {activeTab === 'general' && } {activeTab === 'adapters' && } + {activeTab === 'agents' && } @@ -603,3 +608,150 @@ function GeneralSettings() { ) } + +// ─── Agents Settings ────────────────────────────────────── + +const AGENT_COLORS: Record = { + red: '#ef4444', + orange: '#f97316', + yellow: '#eab308', + green: '#22c55e', + blue: '#3b82f6', + purple: '#a855f7', + pink: '#ec4899', + cyan: '#06b6d4', +} + +function AgentsSettings() { + const { agents, isLoading, error, selectedAgent, fetchAgents, selectAgent } = useAgentStore() + const t = useTranslation() + + useEffect(() => { fetchAgents() }, [fetchAgents]) + + if (selectedAgent) { + return selectAgent(null)} /> + } + + return ( +
+
+
+

{t('settings.agents.title')}

+

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

+
+ {agents.length > 0 && ( + {t('settings.agents.agentCount', { count: String(agents.length) })} + )} +
+ + {isLoading && agents.length === 0 ? ( +
+
+
+ ) : error ? ( +
+ error_outline +

{error}

+ +
+ ) : agents.length === 0 ? ( +
+ smart_toy +

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

+

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

+
+ ) : ( +
+ {agents.map((agent) => { + const dotColor = agent.color && AGENT_COLORS[agent.color] ? AGENT_COLORS[agent.color] : 'var(--color-text-tertiary)' + return ( + + ) + })} +
+ )} +
+ ) +} + +function AgentDetailView({ agent, onBack }: { agent: AgentDefinition; onBack: () => void }) { + const t = useTranslation() + const dotColor = agent.color && AGENT_COLORS[agent.color] ? AGENT_COLORS[agent.color] : 'var(--color-text-tertiary)' + + return ( +
+ {/* Back button */} + + + {/* Agent header */} +
+ +

{agent.name}

+
+ + {/* Description */} + {agent.description && ( +

{agent.description}

+ )} + + {/* Meta info */} +
+ {agent.model && ( +
+ psychology + {t('settings.agents.model')}: {agent.model} +
+ )} + {agent.tools && agent.tools.length > 0 && ( +
+ build + {t('settings.agents.tools')}: {agent.tools.join(', ')} +
+ )} +
+ + {/* System Prompt */} +
+

{t('settings.agents.systemPrompt')}

+ {agent.systemPrompt ? ( +
+ +
+ ) : ( +

{t('settings.agents.noSystemPrompt')}

+ )} +
+
+ ) +} diff --git a/desktop/src/stores/agentStore.ts b/desktop/src/stores/agentStore.ts new file mode 100644 index 00000000..c3dae35f --- /dev/null +++ b/desktop/src/stores/agentStore.ts @@ -0,0 +1,32 @@ +import { create } from 'zustand' +import { agentsApi, type AgentDefinition } from '../api/agents' + +type AgentStore = { + agents: AgentDefinition[] + isLoading: boolean + error: string | null + selectedAgent: AgentDefinition | null + + fetchAgents: () => Promise + selectAgent: (agent: AgentDefinition | null) => void +} + +export const useAgentStore = create((set) => ({ + agents: [], + isLoading: false, + error: null, + selectedAgent: null, + + fetchAgents: async () => { + set({ isLoading: true, error: null }) + try { + const { agents } = await agentsApi.list() + set({ agents, isLoading: false }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to load agents' + set({ isLoading: false, error: message }) + } + }, + + selectAgent: (agent) => set({ selectedAgent: agent }), +}))