agent-list.tsx
1 'use client' 2 3 import { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from 'react' 4 import { useAppStore } from '@/stores/use-app-store' 5 import { AgentCard } from './agent-card' 6 import { TrashList } from './trash-list' 7 import { useApprovalStore } from '@/stores/use-approval-store' 8 import { Skeleton } from '@/components/shared/skeleton' 9 import { EmptyState } from '@/components/shared/empty-state' 10 import { getEnabledCapabilityIds } from '@/lib/capability-selection' 11 import { useWs } from '@/hooks/use-ws' 12 13 interface Props { 14 inSidebar?: boolean 15 } 16 17 export function AgentList({ inSidebar }: Props) { 18 const agents = useAppStore((s) => s.agents) 19 const loadAgents = useAppStore((s) => s.loadAgents) 20 const sessions = useAppStore((s) => s.sessions) 21 const setAgentSheetOpen = useAppStore((s) => s.setAgentSheetOpen) 22 const activeProjectFilter = useAppStore((s) => s.activeProjectFilter) 23 const showTrash = useAppStore((s) => s.showTrash) 24 const setShowTrash = useAppStore((s) => s.setShowTrash) 25 const fleetFilter = useAppStore((s) => s.fleetFilter) 26 const setFleetFilter = useAppStore((s) => s.setFleetFilter) 27 const currentAgentId = useAppStore((s) => s.currentAgentId) 28 const approvals = useApprovalStore((s) => s.approvals) 29 const [search, setSearch] = useState('') 30 const [filter, setFilter] = useState<'all' | 'delegating' | 'solo'>('all') 31 32 // FLIP animation refs 33 const flipPositions = useRef<Map<string, number>>(new Map()) 34 const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map()) 35 36 const selectedAgentId = currentAgentId 37 38 const appSettings = useAppStore((s) => s.appSettings) 39 const updateSettings = useAppStore((s) => s.updateSettings) 40 const defaultAgentId = (appSettings.defaultAgentId && agents[appSettings.defaultAgentId]) 41 ? appSettings.defaultAgentId 42 : Object.values(agents)[0]?.id || 'default' 43 44 const handleSetDefault = useCallback(async (agentId: string) => { 45 try { 46 await updateSettings({ defaultAgentId: agentId }) 47 } catch { /* ignore */ } 48 }, [updateSettings]) 49 50 const [loaded, setLoaded] = useState(Object.keys(agents).length > 0) 51 useEffect(() => { loadAgents().then(() => setLoaded(true)) }, [loadAgents]) 52 useWs('agents', loadAgents, 60_000) 53 54 // Compute which agents are "running" (have active sessions) 55 const runningAgentIds = useMemo(() => { 56 const ids = new Set<string>() 57 for (const s of Object.values(sessions)) { 58 if (s.agentId && s.active) ids.add(s.agentId) 59 } 60 return ids 61 }, [sessions]) 62 63 // Compute online agent IDs — derives from agents and sessions data changes. 64 // The 30-minute threshold uses a callback to avoid calling Date.now() during render. 65 const computeOnlineIds = useCallback(() => { 66 const ids = new Set<string>() 67 const recentThreshold = Date.now() - 30 * 60 * 1000 68 for (const a of Object.values(agents)) { 69 if (a.disabled === true) continue 70 if (a.heartbeatEnabled === true && getEnabledCapabilityIds(a).length > 0) { ids.add(a.id); continue } 71 for (const s of Object.values(sessions)) { 72 if (s.agentId === a.id && (s.lastActiveAt ?? 0) > recentThreshold) { ids.add(a.id); break } 73 } 74 } 75 return ids 76 }, [agents, sessions]) 77 const [onlineAgentIds, setOnlineAgentIds] = useState(() => computeOnlineIds()) 78 useEffect(() => { setOnlineAgentIds(computeOnlineIds()) }, [computeOnlineIds]) 79 80 // Approval counts per agent 81 const approvalsByAgent = useMemo(() => { 82 const counts: Record<string, number> = {} 83 for (const a of Object.values(approvals)) { 84 counts[a.agentId] = (counts[a.agentId] || 0) + 1 85 } 86 return counts 87 }, [approvals]) 88 89 const delegatingCount = useMemo( 90 () => Object.values(agents).filter((agent) => agent.delegationEnabled === true && !agent.trashedAt).length, 91 [agents], 92 ) 93 const soloCount = useMemo( 94 () => Object.values(agents).filter((agent) => agent.delegationEnabled !== true && !agent.trashedAt).length, 95 [agents], 96 ) 97 98 const filtered = useMemo(() => { 99 return Object.values(agents) 100 .filter((p) => { 101 if (search && !p.name.toLowerCase().includes(search.toLowerCase())) return false 102 const canDelegateToAgents = p.delegationEnabled === true 103 if (filter === 'delegating' && !canDelegateToAgents) return false 104 if (filter === 'solo' && canDelegateToAgents) return false 105 if (activeProjectFilter && p.projectId !== activeProjectFilter) return false 106 // Fleet filter 107 if (fleetFilter === 'running' && !runningAgentIds.has(p.id)) return false 108 if (fleetFilter === 'approvals' && !(approvalsByAgent[p.id] > 0)) return false 109 return true 110 }) 111 .sort((a, b) => b.updatedAt - a.updatedAt) 112 }, [agents, search, filter, activeProjectFilter, fleetFilter, runningAgentIds, approvalsByAgent]) 113 114 // FLIP animation: animate agent cards when order changes 115 useLayoutEffect(() => { 116 const newPositions = new Map<string, number>() 117 for (const [id, el] of cardRefs.current) { 118 const newTop = el.getBoundingClientRect().top 119 newPositions.set(id, newTop) 120 const prevTop = flipPositions.current.get(id) 121 if (prevTop != null) { 122 const delta = prevTop - newTop 123 if (Math.abs(delta) > 1) { 124 el.animate( 125 [{ transform: `translateY(${delta}px)` }, { transform: 'translateY(0)' }], 126 { duration: 300, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' } 127 ) 128 } 129 } 130 } 131 flipPositions.current = newPositions 132 }, [filtered]) 133 134 if (showTrash) { 135 return ( 136 <div className="flex-1 flex flex-col overflow-hidden"> 137 <div className="px-4 py-2.5 flex items-center gap-2"> 138 <button 139 onClick={() => setShowTrash(false)} 140 className="px-3 py-1.5 rounded-[8px] text-[12px] font-600 text-text-3 bg-transparent border-none cursor-pointer hover:text-text-2 transition-all flex items-center gap-1.5" 141 style={{ fontFamily: 'inherit' }} 142 > 143 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 144 <path d="M19 12H5" /><polyline points="12 19 5 12 12 5" /> 145 </svg> 146 Back to Agents 147 </button> 148 <span className="text-[13px] font-600 text-text-2">Trash</span> 149 </div> 150 <TrashList /> 151 </div> 152 ) 153 } 154 155 if (!filtered.length && !search) { 156 // Show skeleton cards while loading 157 if (!loaded) { 158 return ( 159 <div className="flex-1 flex flex-col gap-1 px-2 pt-4"> 160 {Array.from({ length: 4 }).map((_, i) => ( 161 <div key={i} className="py-3.5 px-4 rounded-[14px] border border-transparent"> 162 <div className="flex items-center gap-2.5"> 163 <Skeleton className="rounded-full" width={28} height={28} /> 164 <Skeleton className="rounded-[6px]" width={120} height={14} /> 165 </div> 166 <Skeleton className="rounded-[6px] mt-2" width="80%" height={12} /> 167 <Skeleton className="rounded-[6px] mt-1.5" width={80} height={11} /> 168 </div> 169 ))} 170 </div> 171 ) 172 } 173 return ( 174 <EmptyState 175 icon={ 176 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-accent-bright"> 177 <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /> 178 <circle cx="12" cy="7" r="4" /> 179 </svg> 180 } 181 title="No agents yet" 182 subtitle="Create AI agents and enable delegation where needed" 183 action={!inSidebar ? { label: '+ New Agent', onClick: () => setAgentSheetOpen(true) } : undefined} 184 /> 185 ) 186 } 187 188 return ( 189 <div className="flex-1 overflow-y-auto fade-up"> 190 {(filtered.length > 3 || search) && ( 191 <div className="px-4 py-2.5"> 192 <input 193 type="text" 194 value={search} 195 onChange={(e) => setSearch(e.target.value)} 196 placeholder="Search agents..." 197 className="w-full px-4 py-2.5 rounded-[12px] border border-white/[0.04] bg-surface text-text 198 text-[13px] outline-none transition-all duration-200 placeholder:text-text-3/70 focus-glow" 199 style={{ fontFamily: 'inherit' }} 200 /> 201 </div> 202 )} 203 {/* Fleet filter: All / Running / Approvals */} 204 <div className="flex gap-1 px-4 pb-1 items-center"> 205 {(['all', 'running', 'approvals'] as const).map((f) => { 206 const count = f === 'running' ? runningAgentIds.size 207 : f === 'approvals' ? Object.keys(approvalsByAgent).length 208 : null 209 return ( 210 <button 211 key={f} 212 onClick={() => setFleetFilter(f)} 213 className={`px-3 py-1.5 rounded-[8px] text-[11px] font-600 capitalize cursor-pointer transition-all 214 ${fleetFilter === f ? 'bg-accent-soft text-accent-bright' : 'bg-transparent text-text-3 hover:text-text-2'}`} 215 style={{ fontFamily: 'inherit' }} 216 > 217 {f}{count ? ` (${count})` : ''} 218 </button> 219 ) 220 })} 221 </div> 222 <div className="flex gap-1 px-4 pb-2 items-center"> 223 {([ 224 ['all', `all (${delegatingCount + soloCount})`], 225 ['delegating', `delegating (${delegatingCount})`], 226 ['solo', `solo (${soloCount})`], 227 ] as const).map(([value, label]) => ( 228 <button 229 key={value} 230 onClick={() => setFilter(value)} 231 className={`px-3 py-1.5 rounded-[8px] text-[11px] font-600 capitalize cursor-pointer transition-all 232 ${filter === value ? 'bg-accent-soft text-accent-bright' : 'bg-transparent text-text-3 hover:text-text-2'}`} 233 style={{ fontFamily: 'inherit' }} 234 > 235 {label} 236 </button> 237 ))} 238 <div className="flex-1" /> 239 <button 240 onClick={() => setShowTrash(true)} 241 aria-label="View trash" 242 className="p-1.5 rounded-[6px] text-text-3/50 hover:text-text-3 bg-transparent border-none cursor-pointer transition-all hover:bg-white/[0.04]" 243 > 244 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 245 <polyline points="3 6 5 6 21 6" /> 246 <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> 247 </svg> 248 </button> 249 </div> 250 {!inSidebar && ( 251 <div className="mx-4 mb-3 rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-3"> 252 <div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between"> 253 <div> 254 <h3 className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">Fleet Roles</h3> 255 <p className="text-[12px] text-text-3/65 mt-1"> 256 Delegating agents can hand work to other agents. Solo agents stay on their own thread and tools. 257 </p> 258 </div> 259 <div className="flex flex-wrap gap-2"> 260 <span className="px-2.5 py-1 rounded-[8px] bg-white/[0.04] text-[11px] font-600 text-text-2"> 261 Default: {agents[defaultAgentId]?.name || 'Unset'} 262 </span> 263 <span className="px-2.5 py-1 rounded-[8px] bg-sky-500/10 text-[11px] font-600 text-sky-400"> 264 {delegatingCount} delegating 265 </span> 266 <span className="px-2.5 py-1 rounded-[8px] bg-white/[0.04] text-[11px] font-600 text-text-2"> 267 {soloCount} solo 268 </span> 269 </div> 270 </div> 271 </div> 272 )} 273 <div className="flex flex-col gap-1 px-2 pb-4"> 274 {filtered.map((p) => ( 275 <div key={p.id} ref={(el) => { if (el) cardRefs.current.set(p.id, el); else cardRefs.current.delete(p.id) }}> 276 <AgentCard agent={p} isDefault={p.id === defaultAgentId} isRunning={runningAgentIds.has(p.id)} isOnline={onlineAgentIds.has(p.id)} isSelected={p.id === selectedAgentId} onSetDefault={handleSetDefault} /> 277 </div> 278 ))} 279 </div> 280 </div> 281 ) 282 }