From 0497f8a764ce0136790ad938fc5f1851e8c5e229 Mon Sep 17 00:00:00 2001 From: mullerpa Date: Thu, 30 Apr 2026 17:48:01 +0000 Subject: [PATCH 1/2] feat(web): add Profiles tab to view agent profiles --- web/src/App.tsx | 7 ++- web/src/components/ProfilesPanel.tsx | 85 ++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 web/src/components/ProfilesPanel.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index b61df2ea3..64a6f5ae4 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 000000000..9d3bda9ff --- /dev/null +++ b/web/src/components/ProfilesPanel.tsx @@ -0,0 +1,85 @@ +import { useState, useEffect } from 'react' +import { api, AgentProfileInfo } from '../api' +import { Package, ChevronDown, ChevronRight } from 'lucide-react' + +export function ProfilesPanel() { + const [profiles, setProfiles] = useState([]) + const [loading, setLoading] = useState(true) + const [expanded, setExpanded] = useState(null) + + useEffect(() => { + api.listProfiles() + .then(all => setProfiles(all.filter(p => !p.description.includes('managed by AIM')))) + .catch(() => {}) + .finally(() => setLoading(false)) + }, []) + + 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 && ( +
+
+
+ Name: + {p.name} +
+
+ Description: + {p.description || '—'} +
+
+ Source: + {p.source} +
+
+
+ )} +
+ ))} +
+ )} +
+
+ ) +} From c39644b42c15b0db52f157ff3b276b19ec2f7ef8 Mon Sep 17 00:00:00 2001 From: mullerpa Date: Sat, 2 May 2026 03:06:12 +0000 Subject: [PATCH 2/2] fix(web): address PR #222 review feedback - Add vitest tests for ProfilesPanel in components.test.tsx - Remove redundant Name/Source from expanded row (keep Description only) - Use per-source badge colors via SOURCE_BADGE map - Align with project conventions: useStore for errors, reusable fetchProfiles --- web/src/components/ProfilesPanel.tsx | 36 ++++++----- web/src/test/components.test.tsx | 93 +++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 16 deletions(-) diff --git a/web/src/components/ProfilesPanel.tsx b/web/src/components/ProfilesPanel.tsx index 9d3bda9ff..ed4bdc8ee 100644 --- a/web/src/components/ProfilesPanel.tsx +++ b/web/src/components/ProfilesPanel.tsx @@ -1,17 +1,33 @@ 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(() => { - api.listProfiles() - .then(all => setProfiles(all.filter(p => !p.description.includes('managed by AIM')))) - .catch(() => {}) - .finally(() => setLoading(false)) + fetchProfiles() }, []) if (loading) { @@ -49,9 +65,7 @@ export function ProfilesPanel() { )} {p.name} - + {p.source} @@ -60,18 +74,10 @@ export function ProfilesPanel() { {expanded === p.name && (
-
- Name: - {p.name} -
Description: {p.description || '—'}
-
- Source: - {p.source} -
)} diff --git a/web/src/test/components.test.tsx b/web/src/test/components.test.tsx index 9b48da204..e36bbb08e 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() + }) +})