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 }