diff --git a/web/src/App.tsx b/web/src/App.tsx index b61df2ea..64a6f5ae 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,15 +3,17 @@ import { useStore } from './store' import { ErrorBoundary } from './components/ErrorBoundary' import { DashboardHome } from './components/DashboardHome' import { AgentPanel } from './components/AgentPanel' +import { ProfilesPanel } from './components/ProfilesPanel' import { FlowsPanel } from './components/FlowsPanel' import { SettingsPanel } from './components/SettingsPanel' -import { Bot, Home, Clock, Settings, CheckCircle, XCircle, Info, Wifi, WifiOff } from 'lucide-react' +import { Bot, Home, Clock, Settings, CheckCircle, XCircle, Info, Wifi, WifiOff, Package } from 'lucide-react' -type TabKey = 'home' | 'agents' | 'flows' | 'settings' +type TabKey = 'home' | 'agents' | 'profiles' | 'flows' | 'settings' const TABS: { key: TabKey; label: string; icon: React.ReactNode }[] = [ { key: 'home', label: 'Home', icon: }, { key: 'agents', label: 'Agents', icon: }, + { key: 'profiles', label: 'Profiles', icon: }, { key: 'flows', label: 'Flows', icon: }, { key: 'settings', label: 'Settings', icon: }, ] @@ -132,6 +134,7 @@ export default function App() { Loading...}> {tab === 'home' && setTab(t as TabKey)} />} {tab === 'agents' && } + {tab === 'profiles' && } {tab === 'flows' && } {tab === 'settings' && } diff --git a/web/src/components/ProfilesPanel.tsx b/web/src/components/ProfilesPanel.tsx new file mode 100644 index 00000000..ed4bdc8e --- /dev/null +++ b/web/src/components/ProfilesPanel.tsx @@ -0,0 +1,91 @@ +import { useState, useEffect } from 'react' +import { api, AgentProfileInfo } from '../api' +import { useStore } from '../store' +import { Package, ChevronDown, ChevronRight } from 'lucide-react' + +const SOURCE_BADGE = { + blue: 'bg-blue-900/50 text-blue-400', + green: 'bg-emerald-900/50 text-emerald-400', +} + +export function ProfilesPanel() { + const { showSnackbar } = useStore() + const [profiles, setProfiles] = useState([]) + const [loading, setLoading] = useState(true) + const [expanded, setExpanded] = useState(null) + + const fetchProfiles = async () => { + try { + const all = await api.listProfiles() + setProfiles(all.filter(p => !p.description.includes('managed by AIM'))) + } catch { + showSnackbar({ type: 'error', message: 'Failed to load profiles' }) + setProfiles([]) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchProfiles() + }, []) + + if (loading) { + return Loading profiles... + } + + return ( + + + + Agent Profiles ({profiles.length}) + + + {profiles.length === 0 ? ( + + + No profiles found. + + Install profiles via CLI: cao install <profile_name> + + + ) : ( + + {profiles.map(p => ( + + setExpanded(expanded === p.name ? null : p.name)} + > + + {expanded === p.name ? ( + + ) : ( + + )} + + {p.name} + + {p.source} + + + + + {expanded === p.name && ( + + + + Description: + {p.description || '—'} + + + + )} + + ))} + + )} + + + ) +} diff --git a/web/src/test/components.test.tsx b/web/src/test/components.test.tsx index 9b48da20..e36bbb08 100644 --- a/web/src/test/components.test.tsx +++ b/web/src/test/components.test.tsx @@ -1,9 +1,23 @@ import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { StatusBadge } from '../components/StatusBadge' import { ErrorBoundary } from '../components/ErrorBoundary' import { ConfirmModal } from '../components/ConfirmModal' import { FALLBACK_PROVIDERS } from '../components/AgentPanel' +import { ProfilesPanel } from '../components/ProfilesPanel' +import { api } from '../api' + +vi.mock('../api', () => ({ + api: { + listProfiles: vi.fn(), + }, +})) + +const mockProfiles = [ + { name: 'code_supervisor', description: 'Supervisor Agent', source: 'built-in' }, + { name: 'developer', description: 'Developer Agent', source: 'installed' }, + { name: 'reviewer', description: 'Reviewer Agent', source: 'kiro' }, +] describe('StatusBadge', () => { it('renders idle status', () => { @@ -180,3 +194,80 @@ describe('FALLBACK_PROVIDERS', () => { expect(names).toContain('opencode_cli') }) }) + +describe('ProfilesPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows loading state before data arrives', () => { + vi.mocked(api.listProfiles).mockReturnValue(new Promise(() => {})) + render() + expect(screen.getByText('Loading profiles...')).toBeInTheDocument() + }) + + it('shows empty state when API returns []', async () => { + vi.mocked(api.listProfiles).mockResolvedValue([]) + render() + await waitFor(() => { + expect(screen.getByText('No profiles found.')).toBeInTheDocument() + }) + expect(screen.getByText(/cao install/)).toBeInTheDocument() + }) + + it('renders each profile with name, description, and source badge', async () => { + vi.mocked(api.listProfiles).mockResolvedValue(mockProfiles) + render() + await waitFor(() => { + expect(screen.getByText('code_supervisor')).toBeInTheDocument() + }) + expect(screen.getByText('developer')).toBeInTheDocument() + expect(screen.getByText('reviewer')).toBeInTheDocument() + expect(screen.getByText('built-in')).toBeInTheDocument() + expect(screen.getByText('installed')).toBeInTheDocument() + expect(screen.getByText('kiro')).toBeInTheDocument() + }) + + it('clicking a row expands it; clicking again collapses; only one expanded at a time', async () => { + vi.mocked(api.listProfiles).mockResolvedValue(mockProfiles) + render() + await waitFor(() => { + expect(screen.getByText('code_supervisor')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('code_supervisor')) + expect(screen.getByText('Supervisor Agent')).toBeInTheDocument() + + fireEvent.click(screen.getByText('developer')) + expect(screen.getByText('Developer Agent')).toBeInTheDocument() + expect(screen.queryByText('Supervisor Agent')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('developer')) + expect(screen.queryByText('Developer Agent')).not.toBeInTheDocument() + }) + + it('source badge uses blue for built-in and emerald otherwise', async () => { + vi.mocked(api.listProfiles).mockResolvedValue(mockProfiles) + render() + await waitFor(() => { + expect(screen.getByText('code_supervisor')).toBeInTheDocument() + }) + + expect(screen.getByText('built-in').className).toMatch(/bg-blue/) + expect(screen.getByText('installed').className).toMatch(/bg-emerald/) + expect(screen.getByText('kiro').className).toMatch(/bg-emerald/) + }) + + it('filters out profiles with "managed by AIM" in description', async () => { + const profilesWithAIM = [ + ...mockProfiles, + { name: 'aim_agent', description: 'This agent is managed by AIM', source: 'built-in' }, + ] + vi.mocked(api.listProfiles).mockResolvedValue(profilesWithAIM) + render() + await waitFor(() => { + expect(screen.getByText('code_supervisor')).toBeInTheDocument() + }) + expect(screen.queryByText('aim_agent')).not.toBeInTheDocument() + }) +})
No profiles found.
+ Install profiles via CLI: cao install <profile_name> +
cao install <profile_name>