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
This commit is contained in:
程序员阿江(Relakkes) 2026-04-09 12:31:04 +08:00
parent 2c19bb77ac
commit 3aec79a066
6 changed files with 434 additions and 1 deletions

View File

@ -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 }) => (
<div data-testid="markdown-renderer">{content}</div>
),
}))
// 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: () => <div>Adapter Settings Mock</div>,
}))
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(<Settings />)
expect(screen.getByText('Agents')).toBeInTheDocument()
})
it('shows loading spinner when fetching agents', () => {
useAgentStore.setState({ isLoading: true, agents: [], fetchAgents: noopFetch })
render(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)
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(<Settings />)
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()
})
})

14
desktop/src/api/agents.ts Normal file
View File

@ -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'),
}

View File

@ -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.',

View File

@ -141,6 +141,20 @@ export const zh: Record<TranslationKey, string> = {
'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': '选择应用程序的显示语言。',

View File

@ -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<SettingsTab>('providers')
@ -27,6 +30,7 @@ export function Settings() {
<TabButton icon="shield" label={t('settings.tab.permissions')} active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
<TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
<TabButton icon="smart_toy" label={t('settings.tab.agents')} active={activeTab === 'agents'} onClick={() => setActiveTab('agents')} />
</div>
{/* Tab content */}
@ -35,6 +39,7 @@ export function Settings() {
{activeTab === 'permissions' && <PermissionSettings />}
{activeTab === 'general' && <GeneralSettings />}
{activeTab === 'adapters' && <AdapterSettings />}
{activeTab === 'agents' && <AgentsSettings />}
</div>
</div>
</div>
@ -603,3 +608,150 @@ function GeneralSettings() {
</div>
)
}
// ─── Agents Settings ──────────────────────────────────────
const AGENT_COLORS: Record<string, string> = {
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 <AgentDetailView agent={selectedAgent} onBack={() => selectAgent(null)} />
}
return (
<div className="max-w-2xl">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">{t('settings.agents.title')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mt-0.5">{t('settings.agents.description')}</p>
</div>
{agents.length > 0 && (
<span className="text-xs text-[var(--color-text-tertiary)]">{t('settings.agents.agentCount', { count: String(agents.length) })}</span>
)}
</div>
{isLoading && agents.length === 0 ? (
<div className="flex justify-center py-12">
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
</div>
) : error ? (
<div className="text-center py-12 px-4">
<span className="material-symbols-outlined text-[40px] text-[var(--color-error)] mb-3 block">error_outline</span>
<p className="text-sm text-[var(--color-error)] mb-2">{error}</p>
<button
onClick={() => fetchAgents()}
className="text-xs text-[var(--color-text-accent)] hover:underline"
>
Retry
</button>
</div>
) : agents.length === 0 ? (
<div className="text-center py-12 px-4">
<span className="material-symbols-outlined text-[40px] text-[var(--color-text-tertiary)] mb-3 block">smart_toy</span>
<p className="text-sm text-[var(--color-text-secondary)] mb-1">{t('settings.agents.empty')}</p>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.agents.emptyHint')}</p>
</div>
) : (
<div className="flex flex-col gap-2">
{agents.map((agent) => {
const dotColor = agent.color && AGENT_COLORS[agent.color] ? AGENT_COLORS[agent.color] : 'var(--color-text-tertiary)'
return (
<button
key={agent.name}
onClick={() => selectAgent(agent)}
className="flex items-center gap-3 px-4 py-3.5 rounded-xl border border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] transition-all text-left group"
>
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: dotColor }} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)] truncate">{agent.name}</span>
{agent.model && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)] leading-none">{agent.model}</span>
)}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
{agent.description || t('settings.agents.noDescription')}
</div>
</div>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 transition-opacity">
chevron_right
</span>
</button>
)
})}
</div>
)}
</div>
)
}
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 (
<div className="max-w-2xl">
{/* Back button */}
<button
onClick={onBack}
className="flex items-center gap-1 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors mb-4"
>
<span className="material-symbols-outlined text-[16px]">arrow_back</span>
{t('settings.agents.backToList')}
</button>
{/* Agent header */}
<div className="flex items-center gap-3 mb-6">
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: dotColor }} />
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">{agent.name}</h2>
</div>
{/* Description */}
{agent.description && (
<p className="text-sm text-[var(--color-text-secondary)] mb-4">{agent.description}</p>
)}
{/* Meta info */}
<div className="flex flex-wrap gap-4 mb-6">
{agent.model && (
<div className="flex items-center gap-1.5">
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">psychology</span>
<span className="text-xs text-[var(--color-text-secondary)]">{t('settings.agents.model')}: <strong>{agent.model}</strong></span>
</div>
)}
{agent.tools && agent.tools.length > 0 && (
<div className="flex items-center gap-1.5">
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">build</span>
<span className="text-xs text-[var(--color-text-secondary)]">{t('settings.agents.tools')}: <strong>{agent.tools.join(', ')}</strong></span>
</div>
)}
</div>
{/* System Prompt */}
<div>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">{t('settings.agents.systemPrompt')}</h3>
{agent.systemPrompt ? (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-4 max-h-[60vh] overflow-y-auto">
<MarkdownRenderer content={agent.systemPrompt} />
</div>
) : (
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.agents.noSystemPrompt')}</p>
)}
</div>
</div>
)
}

View File

@ -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<void>
selectAgent: (agent: AgentDefinition | null) => void
}
export const useAgentStore = create<AgentStore>((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 }),
}))