/ src / components / agents / agent-list.tsx
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  }