/ src / components / agents / agent-chat-list.tsx
agent-chat-list.tsx
  1  'use client'
  2  
  3  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
  4  import { useShallow } from 'zustand/react/shallow'
  5  import { useAppStore } from '@/stores/use-app-store'
  6  import { useChatStore } from '@/stores/use-chat-store'
  7  import { useChatroomStore } from '@/stores/use-chatroom-store'
  8  import { useNow } from '@/hooks/use-now'
  9  import { useMountedRef } from '@/hooks/use-mounted-ref'
 10  import { useWs } from '@/hooks/use-ws'
 11  import { useNavigate } from '@/lib/app/navigation'
 12  import { api } from '@/lib/app/api-client'
 13  import { ConfirmDialog } from '@/components/shared/confirm-dialog'
 14  import type { Agent, Session } from '@/types'
 15  import { AgentAvatar } from './agent-avatar'
 16  import { SearchInput } from '@/components/ui/search-input'
 17  import { Button } from '@/components/ui/button'
 18  import { toast } from 'sonner'
 19  import { getEnabledCapabilityIds } from '@/lib/capability-selection'
 20  
 21  interface Props {
 22    inSidebar?: boolean
 23    onSelect?: () => void
 24  }
 25  
 26  export function AgentChatList({ inSidebar, onSelect }: Props) {
 27    const mountedRef = useMountedRef()
 28    const navigateTo = useNavigate()
 29    const now = useNow()
 30    const agents = useAppStore((s) => s.agents)
 31    const sessions = useAppStore((s) => s.sessions)
 32    const loadAgents = useAppStore((s) => s.loadAgents)
 33    const currentAgentId = useAppStore((s) => s.currentAgentId)
 34    const setAgentSheetOpen = useAppStore((s) => s.setAgentSheetOpen)
 35    const tasks = useAppStore((s) => s.tasks)
 36    const togglePinAgent = useAppStore((s) => s.togglePinAgent)
 37    const appSettings = useAppStore((s) => s.appSettings)
 38    const updateSettings = useAppStore((s) => s.updateSettings)
 39    const { streamingSessionId, streamPhase, streamToolName } = useChatStore(
 40      useShallow((s) => ({
 41        streamingSessionId: s.streamingSessionId,
 42        streamPhase: s.streamPhase,
 43        streamToolName: s.streamToolName,
 44      })),
 45    )
 46    const chatFilter = useAppStore((s) => s.chatFilter ?? 'all')
 47    const setChatFilter = useAppStore((s) => s.setChatFilter)
 48    const chatrooms = useChatroomStore((s) => s.chatrooms)
 49    const chatroomStreaming = useChatroomStore((s) => s.streamingAgents)
 50    const [search, setSearch] = useState('')
 51    const [bulkMode, setBulkMode] = useState(false)
 52    const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
 53    const [confirmBulkDelete, setConfirmBulkDelete] = useState(false)
 54    const loadSessions = useAppStore((s) => s.loadSessions)
 55  
 56    const toggleSelected = useCallback((id: string) => {
 57      setSelectedIds((prev) => {
 58        const next = new Set(prev)
 59        if (next.has(id)) next.delete(id)
 60        else next.add(id)
 61        return next
 62      })
 63    }, [])
 64  
 65    const handleBulkDelete = useCallback(async () => {
 66      // Collect session IDs for selected agents
 67      const sessionIds = [...selectedIds]
 68        .map((agentId) => {
 69          const agent = agents[agentId]
 70          return agent?.threadSessionId
 71        })
 72        .filter(Boolean) as string[]
 73      if (!sessionIds.length) { toast.error('No chats to delete'); return }
 74      try {
 75        await api('DELETE', '/chats', { ids: sessionIds })
 76        await loadSessions()
 77        if (!mountedRef.current) return
 78        toast.success(`Deleted ${sessionIds.length} chat(s)`)
 79        setBulkMode(false)
 80        setSelectedIds(new Set())
 81      } catch {
 82        toast.error('Failed to delete chats')
 83      }
 84    }, [selectedIds, agents, loadSessions, mountedRef])
 85  
 86    // FLIP animation refs
 87    const rowRefs = useRef<Map<string, HTMLElement>>(new Map())
 88    const previousTopRef = useRef<Map<string, number>>(new Map())
 89    const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
 90  
 91    const setRowRef = useCallback((id: string, el: HTMLElement | null) => {
 92      if (el) rowRefs.current.set(id, el)
 93      else rowRefs.current.delete(id)
 94    }, [])
 95  
 96    useEffect(() => { loadAgents() }, [loadAgents])
 97    useWs('agents', loadAgents, 60_000)
 98    useWs('sessions', loadSessions, 15_000)
 99    useWs('runs', loadSessions, 5_000)
100  
101    useEffect(() => {
102      return () => {
103        if (scrollTimerRef.current) {
104          clearTimeout(scrollTimerRef.current)
105        }
106      }
107    }, [])
108  
109    // Build agent list sorted by last activity in their thread session
110    const sortedAgents = useMemo(() => {
111      return Object.values(agents)
112        .filter((a) => {
113          if (search && !a.name.toLowerCase().includes(search.toLowerCase())) return false
114          return true
115        })
116        .sort((a, b) => {
117          const aSession = a.threadSessionId ? sessions[a.threadSessionId] : null
118          const bSession = b.threadSessionId ? sessions[b.threadSessionId] : null
119          const aTime = (aSession as Session | null)?.lastActiveAt || a.updatedAt
120          const bTime = (bSession as Session | null)?.lastActiveAt || b.updatedAt
121          return bTime - aTime
122        })
123    }, [agents, sessions, search])
124  
125    // Compute agents active in chatrooms (message in last 30min or currently streaming)
126    const chatroomActiveAgentIds = useMemo(() => {
127      const set = new Set<string>()
128      const cutoff = now ? now - 30 * 60 * 1000 : Number.POSITIVE_INFINITY
129      for (const chatroom of Object.values(chatrooms)) {
130        for (let i = chatroom.messages.length - 1; i >= 0; i--) {
131          const msg = chatroom.messages[i]
132          if (msg.time < cutoff) break
133          if (msg.role === 'assistant' && msg.senderId !== 'user') set.add(msg.senderId)
134        }
135      }
136      for (const agentId of chatroomStreaming.keys()) set.add(agentId)
137      return set
138    }, [chatrooms, chatroomStreaming, now])
139  
140    // Compute running tasks per agent
141    const runningAgentIds = useMemo(() => {
142      const set = new Set<string>()
143      for (const task of Object.values(tasks)) {
144        if (task.status === 'running' && task.agentId) set.add(task.agentId)
145      }
146      return set
147    }, [tasks])
148  
149    // Apply chatFilter
150    const filteredAgents = useMemo(() => {
151      if (chatFilter === 'all') return sortedAgents
152      if (!now) return sortedAgents
153      return sortedAgents.filter((a) => {
154        const threadSession = a.threadSessionId ? sessions[a.threadSessionId] as unknown as Session | undefined : undefined
155        const isRunning = runningAgentIds.has(a.id) || (threadSession?.active ?? false)
156        const isStreaming = streamingSessionId === a.threadSessionId
157        const isChatroomActive = chatroomActiveAgentIds.has(a.id)
158        if (chatFilter === 'active') return isRunning || isStreaming || isChatroomActive
159        // 'recent' — activity within 24h
160        const lastActive = threadSession?.lastActiveAt || a.updatedAt
161        return now - lastActive < 86_400_000
162      })
163    }, [sortedAgents, chatFilter, sessions, runningAgentIds, streamingSessionId, chatroomActiveAgentIds, now])
164  
165    const defaultAgent = useMemo(() => {
166      const id = appSettings.defaultAgentId
167      return id ? agents[id] || null : null
168    }, [appSettings.defaultAgentId, agents])
169  
170    const defaultAgentVisible = !!defaultAgent && filteredAgents.some((agent) => agent.id === defaultAgent.id)
171    const listAgents = useMemo(
172      () => (defaultAgentVisible ? filteredAgents.filter((agent) => agent.id !== defaultAgent?.id) : filteredAgents),
173      [defaultAgent?.id, defaultAgentVisible, filteredAgents],
174    )
175  
176    // FLIP: animate row position changes
177    useLayoutEffect(() => {
178      const prevTop = previousTopRef.current
179      for (const agent of filteredAgents) {
180        const el = rowRefs.current.get(agent.id)
181        if (!el) continue
182        const newTop = el.getBoundingClientRect().top
183        const oldTop = prevTop.get(agent.id)
184        if (oldTop !== undefined && oldTop !== newTop) {
185          const delta = oldTop - newTop
186          el.animate(
187            [{ transform: `translateY(${delta}px)` }, { transform: 'translateY(0)' }],
188            { duration: 300, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' },
189          )
190        }
191        prevTop.set(agent.id, newTop)
192      }
193      // eslint-disable-next-line react-hooks/exhaustive-deps
194    }, [filteredAgents.map((a) => a.id).join(',')])
195  
196    const [enableAgentTarget, setEnableAgentTarget] = useState<Agent | null>(null)
197  
198    const handleSelect = async (agent: Agent) => {
199      if (agent.disabled === true && !agent.threadSessionId) {
200        setEnableAgentTarget(agent)
201        return
202      }
203      navigateTo('agents', agent.id)
204      onSelect?.()
205      // Delay scroll so React renders the new messages first
206      if (mountedRef.current && typeof window !== 'undefined') {
207        if (scrollTimerRef.current) {
208          clearTimeout(scrollTimerRef.current)
209        }
210        scrollTimerRef.current = setTimeout(() => {
211          window.dispatchEvent(new CustomEvent('swarmclaw:scroll-bottom'))
212        }, 100)
213      }
214    }
215  
216    if (!sortedAgents.length && !search) {
217      return (
218        <div className="flex-1 flex flex-col items-center justify-center gap-4 text-text-3 p-8 text-center">
219          <div className="w-12 h-12 rounded-[14px] bg-accent-soft flex items-center justify-center mb-1">
220            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-accent-bright">
221              <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
222              <circle cx="12" cy="7" r="4" />
223            </svg>
224          </div>
225          <p className="font-display text-[15px] font-600 text-text-2">No agents yet</p>
226          <p className="text-[13px] text-text-3/50">Create agents to start chatting</p>
227          {!inSidebar && (
228            <Button
229              variant="accent"
230              onClick={() => setAgentSheetOpen(true)}
231              className="mt-3 px-8 py-3 rounded-[14px] text-[14px] cursor-pointer active:scale-95 shadow-[0_4px_16px_rgba(99,102,241,0.2)]"
232            >
233              + New Agent
234            </Button>
235          )}
236        </div>
237      )
238    }
239  
240    return (
241      <div className="flex-1 overflow-y-auto" data-testid="agent-chat-list">
242        {/* Filter control + bulk mode toggle */}
243        {sortedAgents.length > 2 && (
244          <div className="flex items-center gap-1 px-4 pt-2.5 pb-1">
245            {(['all', 'active', 'recent'] as const).map((f) => (
246              <button
247                key={f}
248                type="button"
249                onClick={() => setChatFilter(f)}
250                data-active={chatFilter === f || undefined}
251                className="label-mono px-2.5 py-1 rounded-[6px] border-none cursor-pointer transition-colors
252                  data-[active]:bg-accent-soft data-[active]:text-accent-bright
253                  bg-transparent text-text-3 hover:text-text-2 hover:bg-white/[0.04]"
254              >
255                {f}
256              </button>
257            ))}
258            <button
259              type="button"
260              onClick={() => { setBulkMode(!bulkMode); setSelectedIds(new Set()) }}
261              aria-label={bulkMode ? 'Exit selection mode' : 'Select chats'}
262              className={`ml-auto label-mono px-2.5 py-1 rounded-[6px] border-none cursor-pointer transition-colors
263                ${bulkMode ? 'bg-accent-soft text-accent-bright' : 'bg-transparent text-text-3 hover:text-text-2 hover:bg-white/[0.04]'}`}
264            >
265              {bulkMode ? 'Cancel' : 'Select'}
266            </button>
267          </div>
268        )}
269        {/* Bulk action bar */}
270        {bulkMode && selectedIds.size > 0 && (
271          <div className="flex items-center gap-2 px-4 py-2 bg-white/[0.02] border-b border-white/[0.04]">
272            <span className="text-[12px] text-text-2 font-500 flex-1">{selectedIds.size} selected</span>
273            <button
274              onClick={() => setConfirmBulkDelete(true)}
275              className="px-3 py-1.5 rounded-[8px] border-none bg-red-500/10 text-red-400 text-[12px] font-600 cursor-pointer hover:bg-red-500/20 transition-colors"
276              style={{ fontFamily: 'inherit' }}
277            >
278              Delete
279            </button>
280          </div>
281        )}
282        {(sortedAgents.length > 5 || search) && (
283          <div className="px-4 py-2.5">
284            <SearchInput
285              size="sm"
286              value={search}
287              onChange={(e) => setSearch(e.target.value)}
288              onClear={() => setSearch('')}
289              placeholder="Search agents..."
290              aria-label="Search agents"
291              data-testid="agent-search"
292            />
293          </div>
294        )}
295        <div className="flex flex-col gap-0.5 px-2 pb-4">
296          {defaultAgentVisible && defaultAgent && (() => {
297            const threadSession = defaultAgent.threadSessionId ? sessions[defaultAgent.threadSessionId] as unknown as Session | undefined : undefined
298            const lastMsg = threadSession?.messages?.at(-1)
299            const heartbeatOn = defaultAgent.heartbeatEnabled === true && getEnabledCapabilityIds(defaultAgent).length > 0
300            const recentlyActive = !!now && (threadSession?.lastActiveAt ?? 0) > now - 30 * 60 * 1000
301            const isDisabled = defaultAgent.disabled === true
302            const isWorking = !isDisabled && (runningAgentIds.has(defaultAgent.id) || (threadSession?.active ?? false) || heartbeatOn || recentlyActive || chatroomActiveAgentIds.has(defaultAgent.id))
303            const isTyping = streamingSessionId === defaultAgent.threadSessionId
304            const preview = lastMsg?.text?.slice(0, 100)?.replace(/\n/g, ' ') || 'Your primary shortcut chat.'
305            const isActive = currentAgentId === defaultAgent.id
306  
307            return (
308              <div className="mb-2 px-2">
309                <div className="px-2 pb-1 text-[10px] font-700 uppercase tracking-[0.12em] text-accent-bright/65">
310                  Default Agent
311                </div>
312                <div
313                  className={`group/row relative w-full text-left py-3.5 px-4 rounded-[14px] cursor-pointer transition-all duration-150 border
314                    ${isActive
315                      ? 'bg-accent-soft border-accent-bright/25'
316                      : 'bg-accent-soft/40 border-accent-bright/15 hover:bg-accent-soft/55'}`}
317                  role="button"
318                  tabIndex={0}
319                  data-testid="agent-row"
320                  data-agent-id={defaultAgent.id}
321                  data-agent-name={defaultAgent.name}
322                  aria-label={`Open agent chat ${defaultAgent.name}`}
323                  onClick={() => bulkMode ? toggleSelected(defaultAgent.id) : handleSelect(defaultAgent)}
324                  onKeyDown={(event) => {
325                    if (event.key === 'Enter' || event.key === ' ') {
326                      event.preventDefault()
327                      if (bulkMode) toggleSelected(defaultAgent.id)
328                      else void handleSelect(defaultAgent)
329                    }
330                  }}
331                >
332                  <div className="flex items-center gap-3">
333                    {bulkMode && (
334                      <div className={`w-5 h-5 rounded-[6px] border-2 flex items-center justify-center shrink-0 transition-colors
335                        ${selectedIds.has(defaultAgent.id) ? 'bg-accent-bright border-accent-bright' : 'border-white/20 bg-transparent'}`}>
336                        {selectedIds.has(defaultAgent.id) && (
337                          <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
338                            <polyline points="20 6 9 17 4 12" />
339                          </svg>
340                        )}
341                      </div>
342                    )}
343                    <div className="relative shrink-0">
344                      <AgentAvatar seed={defaultAgent.avatarSeed || null} avatarUrl={defaultAgent.avatarUrl} name={defaultAgent.name} size={38} />
345                      <div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-bg ${
346                        isWorking ? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.4)]' : 'bg-text-3/30'
347                      }`} />
348                    </div>
349                    <div className="flex-1 min-w-0">
350                      <div className="flex items-center gap-2">
351                        <span className="font-display text-[14px] font-700 truncate text-text tracking-[-0.01em]">
352                          {defaultAgent.name}
353                        </span>
354                        {isDisabled && (
355                          <span className="px-1.5 py-0.5 rounded-[6px] bg-amber-400/[0.08] text-amber-300 text-[9px] font-700 uppercase tracking-[0.08em]">
356                            Disabled
357                          </span>
358                        )}
359                        <span className="px-1.5 py-0.5 rounded-[6px] bg-accent-bright/12 text-accent-bright text-[9px] font-700 uppercase tracking-[0.08em]">
360                          Shortcut
361                        </span>
362                      </div>
363                      {isTyping ? (
364                        <div className={`text-[12px] mt-1 flex items-center gap-1.5 ${streamPhase === 'queued' ? 'text-amber-300/80' : 'text-accent-bright/80'}`}>
365                          <span className="flex gap-0.5">
366                            <span className={`w-1 h-1 rounded-full animate-bounce [animation-delay:0ms] ${streamPhase === 'queued' ? 'bg-amber-400/70' : 'bg-accent-bright/70'}`} />
367                            <span className={`w-1 h-1 rounded-full animate-bounce [animation-delay:150ms] ${streamPhase === 'queued' ? 'bg-amber-400/70' : 'bg-accent-bright/70'}`} />
368                            <span className={`w-1 h-1 rounded-full animate-bounce [animation-delay:300ms] ${streamPhase === 'queued' ? 'bg-amber-400/70' : 'bg-accent-bright/70'}`} />
369                          </span>
370                          {streamPhase === 'queued' ? 'Queued...'
371                            : streamPhase === 'tool' && streamToolName ? `Using ${streamToolName}...`
372                            : streamPhase === 'responding' ? 'Responding...'
373                            : streamPhase === 'connecting' ? 'Reconnecting...'
374                            : 'Thinking...'}
375                        </div>
376                      ) : (
377                        <div className="text-[12px] text-text-3/70 mt-1 truncate">
378                          {preview}
379                        </div>
380                      )}
381                    </div>
382                    <button
383                      onClick={async (e) => {
384                        e.stopPropagation()
385                        await updateSettings({ defaultAgentId: null })
386                        toast.success('Default agent cleared')
387                      }}
388                      aria-label="Remove as default agent"
389                      title="Default agent — click to clear"
390                      className="shrink-0 p-1 rounded-[6px] transition-all bg-transparent border-none cursor-pointer hover:bg-white/[0.06] text-accent-bright"
391                      style={{ fontFamily: 'inherit' }}
392                    >
393                      <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
394                        <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
395                        <path d="M9 22V12h6v10" fill="rgba(0,0,0,0.3)" stroke="none" />
396                      </svg>
397                    </button>
398                  </div>
399                </div>
400              </div>
401            )
402          })()}
403          {listAgents.map((agent) => {
404            const threadSession = agent.threadSessionId ? sessions[agent.threadSessionId] as unknown as Session | undefined : undefined
405            const lastMsg = threadSession?.messages?.at(-1)
406            const isActive = currentAgentId === agent.id
407            const heartbeatOn = agent.heartbeatEnabled === true && getEnabledCapabilityIds(agent).length > 0
408            const recentlyActive = !!now && (threadSession?.lastActiveAt ?? 0) > now - 30 * 60 * 1000
409            const isDisabled = agent.disabled === true
410            const isWorking = !isDisabled && (runningAgentIds.has(agent.id) || (threadSession?.active ?? false) || heartbeatOn || recentlyActive || chatroomActiveAgentIds.has(agent.id))
411            const isTyping = streamingSessionId === agent.threadSessionId
412            const preview = lastMsg?.text?.slice(0, 80)?.replace(/\n/g, ' ') || ''
413  
414            return (
415              <div
416                key={agent.id}
417                ref={(el) => setRowRef(agent.id, el)}
418                className={`group/row relative w-full text-left py-3 px-4 rounded-[12px] cursor-pointer transition-all duration-150 border-none
419                  ${isActive
420                    ? 'bg-accent-soft/80 border border-accent-bright/20'
421                    : 'bg-transparent hover:bg-white/[0.02]'}`}
422                role="button"
423                tabIndex={0}
424                data-testid="agent-row"
425                data-agent-id={agent.id}
426                data-agent-name={agent.name}
427                aria-label={`Open agent chat ${agent.name}`}
428                onClick={() => bulkMode ? toggleSelected(agent.id) : handleSelect(agent)}
429                onKeyDown={(event) => {
430                  if (event.key === 'Enter' || event.key === ' ') {
431                    event.preventDefault()
432                    if (bulkMode) toggleSelected(agent.id)
433                    else void handleSelect(agent)
434                  }
435                }}
436              >
437                <div className="flex items-center gap-2.5">
438                  {bulkMode && (
439                    <div className={`w-5 h-5 rounded-[6px] border-2 flex items-center justify-center shrink-0 transition-colors
440                      ${selectedIds.has(agent.id) ? 'bg-accent-bright border-accent-bright' : 'border-white/20 bg-transparent'}`}>
441                      {selectedIds.has(agent.id) && (
442                        <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
443                          <polyline points="20 6 9 17 4 12" />
444                        </svg>
445                      )}
446                    </div>
447                  )}
448                  <div className="relative shrink-0">
449                    <AgentAvatar seed={agent.avatarSeed || null} avatarUrl={agent.avatarUrl} name={agent.name} size={36} />
450                    <div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-bg ${
451                      isWorking ? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.4)]' : 'bg-text-3/30'
452                    }`} />
453                  </div>
454                  <div className="flex flex-col flex-1 min-w-0">
455                    <div className="flex items-center gap-2 min-w-0">
456                      <span className="font-display text-[13.5px] font-600 truncate flex-1 tracking-[-0.01em]">
457                        {agent.name}
458                      </span>
459                      {isDisabled && (
460                        <span className="px-1.5 py-0.5 rounded-[6px] bg-amber-400/[0.08] text-amber-300 text-[9px] font-700 uppercase tracking-[0.08em] shrink-0">
461                          Disabled
462                        </span>
463                      )}
464                      {appSettings.defaultAgentId === agent.id && (
465                        <span className="px-1.5 py-0.5 rounded-[6px] bg-accent-bright/10 text-accent-bright text-[9px] font-700 uppercase tracking-[0.08em] shrink-0">
466                          Default
467                        </span>
468                      )}
469                      <span className="text-[10px] text-text-3/60 font-mono shrink-0 max-w-[30%] truncate">
470                        {(threadSession?.model || agent.model)
471                          ? (threadSession?.model || agent.model)!.split('/').pop()?.split(':')[0]
472                          : agent.provider}
473                      </span>
474                      {/* Set as default agent */}
475                      {(() => {
476                        const isDefault = appSettings.defaultAgentId === agent.id
477                        return (
478                          <button
479                            onClick={async (e) => {
480                              e.stopPropagation()
481                              if (isDefault) {
482                                await updateSettings({ defaultAgentId: null })
483                                toast.success('Default agent cleared')
484                              } else {
485                                await updateSettings({ defaultAgentId: agent.id })
486                                toast.success(`${agent.name} set as default`)
487                              }
488                            }}
489                            aria-label={isDefault ? 'Remove as default' : 'Set as default agent'}
490                            title={isDefault ? 'Default agent — click to clear' : 'Set as default agent'}
491                            className={`shrink-0 p-1 rounded-[6px] transition-all bg-transparent border-none cursor-pointer hover:bg-white/[0.06]
492                              ${isDefault ? 'opacity-100 text-accent-bright' : 'opacity-0 group-hover/row:opacity-60 hover:!opacity-100 text-text-3'}`}
493                            style={{ fontFamily: 'inherit' }}
494                          >
495                            <svg width="11" height="11" viewBox="0 0 24 24" fill={isDefault ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
496                              <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
497                              {isDefault && <path d="M9 22V12h6v10" fill="rgba(0,0,0,0.3)" stroke="none" />}
498                            </svg>
499                          </button>
500                        )
501                      })()}
502                      {/* Pin button — inline after model label */}
503                      <button
504                        onClick={(e) => {
505                          e.stopPropagation()
506                          togglePinAgent(agent.id)
507                          toast.success(agent.pinned ? 'Agent unpinned' : 'Agent pinned')
508                        }}
509                        aria-label={agent.pinned ? 'Unpin agent' : 'Pin agent'}
510                        className={`shrink-0 p-1 rounded-[6px] transition-all bg-transparent border-none cursor-pointer hover:bg-white/[0.06]
511                          ${agent.pinned ? 'opacity-100 text-amber-400' : 'opacity-0 group-hover/row:opacity-60 hover:!opacity-100 text-text-3'}`}
512                        style={{ fontFamily: 'inherit' }}
513                      >
514                        <svg width="11" height="11" viewBox="0 0 24 24" fill={agent.pinned ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
515                          <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
516                        </svg>
517                      </button>
518                    </div>
519                    {isTyping ? (
520                      <div className={`text-[12px] mt-0.5 flex items-center gap-1.5 ${streamPhase === 'queued' ? 'text-amber-300/70' : 'text-accent-bright/70'}`}>
521                        <span className="flex gap-0.5">
522                          <span className={`w-1 h-1 rounded-full animate-bounce [animation-delay:0ms] ${streamPhase === 'queued' ? 'bg-amber-400/70' : 'bg-accent-bright/70'}`} />
523                          <span className={`w-1 h-1 rounded-full animate-bounce [animation-delay:150ms] ${streamPhase === 'queued' ? 'bg-amber-400/70' : 'bg-accent-bright/70'}`} />
524                          <span className={`w-1 h-1 rounded-full animate-bounce [animation-delay:300ms] ${streamPhase === 'queued' ? 'bg-amber-400/70' : 'bg-accent-bright/70'}`} />
525                        </span>
526                        {streamPhase === 'queued' ? 'Queued...'
527                          : streamPhase === 'tool' && streamToolName ? `Using ${streamToolName}...`
528                          : streamPhase === 'responding' ? 'Responding...'
529                          : streamPhase === 'connecting' ? 'Reconnecting...'
530                          : 'Thinking...'}
531                      </div>
532                    ) : preview ? (
533                      <div className="text-[12px] text-text-3/70 mt-0.5 truncate">
534                        {preview}
535                      </div>
536                    ) : null}
537                  </div>
538                </div>
539              </div>
540            )
541          })}
542        </div>
543        <ConfirmDialog
544          open={confirmBulkDelete}
545          title="Delete Chats"
546          message={`Delete ${selectedIds.size} chat(s)? This cannot be undone.`}
547          confirmLabel="Delete"
548          danger
549          onConfirm={() => { setConfirmBulkDelete(false); handleBulkDelete() }}
550          onCancel={() => setConfirmBulkDelete(false)}
551        />
552        <ConfirmDialog
553          open={!!enableAgentTarget}
554          title={`Enable ${enableAgentTarget?.name ?? 'Agent'}?`}
555          message={`${enableAgentTarget?.name ?? 'This agent'} is currently disabled. Enable it to start a new chat.`}
556          confirmLabel="Enable"
557          onConfirm={async () => {
558            if (!enableAgentTarget) return
559            try {
560              await api('PUT', `/agents/${enableAgentTarget.id}`, { disabled: false })
561              await loadAgents()
562              const agent = enableAgentTarget
563              setEnableAgentTarget(null)
564              handleSelect(agent)
565            } catch {
566              toast.error('Failed to enable agent')
567              setEnableAgentTarget(null)
568            }
569          }}
570          onCancel={() => setEnableAgentTarget(null)}
571        />
572      </div>
573    )
574  }