mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
2c19bb77ac
commit
3aec79a066
207
desktop/src/__tests__/agentsSettings.test.tsx
Normal file
207
desktop/src/__tests__/agentsSettings.test.tsx
Normal 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
14
desktop/src/api/agents.ts
Normal 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'),
|
||||
}
|
||||
@ -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.',
|
||||
|
||||
@ -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': '选择应用程序的显示语言。',
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
32
desktop/src/stores/agentStore.ts
Normal file
32
desktop/src/stores/agentStore.ts
Normal 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 }),
|
||||
}))
|
||||
Loading…
x
Reference in New Issue
Block a user