Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <Home size={16} /> },
{ key: 'agents', label: 'Agents', icon: <Bot size={16} /> },
{ key: 'profiles', label: 'Profiles', icon: <Package size={16} /> },
{ key: 'flows', label: 'Flows', icon: <Clock size={16} /> },
{ key: 'settings', label: 'Settings', icon: <Settings size={16} /> },
]
Expand Down Expand Up @@ -132,6 +134,7 @@ export default function App() {
<Suspense fallback={<div className="text-gray-500 text-sm py-12 text-center">Loading...</div>}>
{tab === 'home' && <DashboardHome onNavigate={(t) => setTab(t as TabKey)} />}
{tab === 'agents' && <AgentPanel />}
{tab === 'profiles' && <ProfilesPanel />}
{tab === 'flows' && <FlowsPanel />}
{tab === 'settings' && <SettingsPanel />}
</Suspense>
Expand Down
91 changes: 91 additions & 0 deletions web/src/components/ProfilesPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<AgentProfileInfo[]>([])
const [loading, setLoading] = useState(true)
const [expanded, setExpanded] = useState<string | null>(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 <div className="text-gray-500 text-sm py-8 text-center">Loading profiles...</div>
}

return (
<div className="space-y-6">
<div className="bg-gray-800/60 border border-gray-700/50 rounded-xl p-5">
<h3 className="text-sm font-semibold text-gray-300 uppercase tracking-wide mb-4">
Agent Profiles ({profiles.length})
</h3>

{profiles.length === 0 ? (
<div className="text-center py-8">
<Package size={32} className="mx-auto text-gray-600 mb-3" />
<p className="text-gray-500 text-sm">No profiles found.</p>
<p className="text-gray-600 text-xs mt-1">
Install profiles via CLI: <code className="text-emerald-400">cao install &lt;profile_name&gt;</code>
</p>
</div>
) : (
<div className="space-y-2">
{profiles.map(p => (
<div key={p.name} className="bg-gray-900/50 border border-gray-700/30 rounded-lg">
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-800/50 transition-colors"
onClick={() => setExpanded(expanded === p.name ? null : p.name)}
>
<div className="flex items-center gap-3 min-w-0">
{expanded === p.name ? (
<ChevronDown size={14} className="text-gray-400 shrink-0" />
) : (
<ChevronRight size={14} className="text-gray-400 shrink-0" />
)}
<Package size={14} className="text-blue-400 shrink-0" />
<span className="text-sm text-gray-200 font-medium truncate">{p.name}</span>
<span className={`text-xs px-2 py-0.5 rounded-full shrink-0 ${p.source === 'built-in' ? SOURCE_BADGE.blue : SOURCE_BADGE.green}`}>
{p.source}
</span>
</div>
</div>

{expanded === p.name && (
<div className="px-4 pb-4 border-t border-gray-700/30 pt-3">
<div className="space-y-2 text-sm">
<div>
<span className="text-gray-500">Description: </span>
<span className="text-gray-200">{p.description || '—'}</span>
</div>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)
}
93 changes: 92 additions & 1 deletion web/src/test/components.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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(<ProfilesPanel />)
expect(screen.getByText('Loading profiles...')).toBeInTheDocument()
})

it('shows empty state when API returns []', async () => {
vi.mocked(api.listProfiles).mockResolvedValue([])
render(<ProfilesPanel />)
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(<ProfilesPanel />)
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(<ProfilesPanel />)
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(<ProfilesPanel />)
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(<ProfilesPanel />)
await waitFor(() => {
expect(screen.getByText('code_supervisor')).toBeInTheDocument()
})
expect(screen.queryByText('aim_agent')).not.toBeInTheDocument()
})
})