knowledge-list.tsx
1 'use client' 2 3 import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 4 import { api } from '@/lib/app/api-client' 5 import { useAppStore } from '@/stores/use-app-store' 6 import { Badge } from '@/components/ui/badge' 7 import { AgentAvatar } from '@/components/agents/agent-avatar' 8 import { EmptyState } from '@/components/shared/empty-state' 9 import { PageLoader } from '@/components/ui/page-loader' 10 import { SearchInput } from '@/components/ui/search-input' 11 import type { KnowledgeHygieneSummary, KnowledgeSearchHit, KnowledgeSourceSummary } from '@/types' 12 import { toast } from 'sonner' 13 14 export function KnowledgeList() { 15 const [search, setSearch] = useState('') 16 const [sources, setSources] = useState<KnowledgeSourceSummary[]>([]) 17 const [hits, setHits] = useState<KnowledgeSearchHit[]>([]) 18 const [loaded, setLoaded] = useState(false) 19 const [error, setError] = useState<string | null>(null) 20 const [activeTag, setActiveTag] = useState<string | null>(null) 21 const [includeArchived, setIncludeArchived] = useState(false) 22 const [hygiene, setHygiene] = useState<KnowledgeHygieneSummary | null>(null) 23 const [maintaining, setMaintaining] = useState(false) 24 const searchRef = useRef(search) 25 26 const agents = useAppStore((state) => state.agents) 27 const loadAgents = useAppStore((state) => state.loadAgents) 28 const refreshKey = useAppStore((state) => state.knowledgeRefreshKey) 29 const openKnowledgeSheet = useAppStore((state) => state.setKnowledgeSheetOpen) 30 const setEditingKnowledgeId = useAppStore((state) => state.setEditingKnowledgeId) 31 const selectedKnowledgeSourceId = useAppStore((state) => state.selectedKnowledgeSourceId) 32 const setSelectedKnowledgeSourceId = useAppStore((state) => state.setSelectedKnowledgeSourceId) 33 const triggerKnowledgeRefresh = useAppStore((state) => state.triggerKnowledgeRefresh) 34 35 const openSheet = useCallback((id?: string) => { 36 setEditingKnowledgeId(id ?? null) 37 openKnowledgeSheet(true) 38 }, [openKnowledgeSheet, setEditingKnowledgeId]) 39 40 const load = useCallback(async (query: string, tag?: string | null) => { 41 try { 42 const params = new URLSearchParams() 43 if (tag) params.set('tags', tag) 44 if (includeArchived) params.set('includeArchived', 'true') 45 const currentSelectedId = useAppStore.getState().selectedKnowledgeSourceId 46 47 if (query.trim()) { 48 params.set('q', query.trim()) 49 const results = await api<KnowledgeSearchHit[]>('GET', `/knowledge?${params.toString()}`) 50 const nextHits = Array.isArray(results) ? results : [] 51 setHits(nextHits) 52 setSources([]) 53 if (!currentSelectedId || !nextHits.some((hit) => hit.sourceId === currentSelectedId)) { 54 setSelectedKnowledgeSourceId(nextHits[0]?.sourceId || null) 55 } 56 } else { 57 const qs = params.toString() 58 const results = await api<KnowledgeSourceSummary[]>('GET', `/knowledge/sources${qs ? `?${qs}` : ''}`) 59 const nextSources = Array.isArray(results) ? results : [] 60 setSources(nextSources) 61 setHits([]) 62 if (!currentSelectedId || !nextSources.some((source) => source.id === currentSelectedId)) { 63 setSelectedKnowledgeSourceId(nextSources[0]?.id || null) 64 } 65 } 66 setError(null) 67 } catch { 68 setError('Unable to load knowledge sources.') 69 } 70 setLoaded(true) 71 }, [includeArchived, setSelectedKnowledgeSourceId]) 72 73 const loadHygiene = useCallback(async () => { 74 try { 75 const summary = await api<KnowledgeHygieneSummary>('GET', '/knowledge/hygiene') 76 setHygiene(summary) 77 } catch { 78 setHygiene(null) 79 } 80 }, []) 81 82 useEffect(() => { 83 searchRef.current = search 84 }, [search]) 85 86 useEffect(() => { 87 loadAgents() 88 }, [loadAgents]) 89 90 useEffect(() => { 91 const timer = setTimeout(() => { 92 void load(searchRef.current, activeTag) 93 }, 0) 94 return () => clearTimeout(timer) 95 }, [activeTag, load, refreshKey]) 96 97 useEffect(() => { 98 void loadHygiene() 99 }, [loadHygiene, refreshKey]) 100 101 useEffect(() => { 102 const timer = setTimeout(() => { 103 void load(search, activeTag) 104 }, 250) 105 return () => clearTimeout(timer) 106 }, [activeTag, includeArchived, load, search]) 107 108 const uniqueTags = useMemo(() => { 109 const tags = new Set<string>() 110 const items = search.trim() ? hits : sources 111 for (const item of items) { 112 for (const tag of item.tags) tags.add(tag) 113 } 114 return Array.from(tags).sort((left, right) => left.localeCompare(right)) 115 }, [hits, search, sources]) 116 117 const handleDelete = useCallback(async (id: string) => { 118 try { 119 await api('DELETE', `/knowledge/sources/${id}`) 120 if (selectedKnowledgeSourceId === id) { 121 setSelectedKnowledgeSourceId(null) 122 } 123 triggerKnowledgeRefresh() 124 } catch { 125 // Best-effort delete; caller can retry from refreshed list. 126 } 127 }, [selectedKnowledgeSourceId, setSelectedKnowledgeSourceId, triggerKnowledgeRefresh]) 128 129 const runMaintenance = useCallback(async () => { 130 setMaintaining(true) 131 try { 132 await api('POST', '/knowledge/hygiene') 133 triggerKnowledgeRefresh() 134 void loadHygiene() 135 toast.success('Knowledge maintenance completed') 136 } catch { 137 toast.error('Knowledge maintenance failed') 138 } finally { 139 setMaintaining(false) 140 } 141 }, [loadHygiene, triggerKnowledgeRefresh]) 142 143 const formatDate = (timestamp?: number | null) => { 144 if (!timestamp) return 'Not indexed' 145 return new Date(timestamp).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) 146 } 147 148 const scopedAgentsFor = (agentIds: string[]) => agentIds.map((id) => agents[id]).filter(Boolean) 149 150 if (!loaded) { 151 return <PageLoader label="Loading knowledge..." /> 152 } 153 154 const showingHits = search.trim().length > 0 155 const items = showingHits ? hits : sources 156 157 return ( 158 <div className="flex-1 flex flex-col overflow-y-auto"> 159 <div className="px-5 py-2 shrink-0" style={{ animation: 'fade-up 0.4s var(--ease-spring)' }}> 160 <SearchInput 161 size="sm" 162 value={search} 163 onChange={(event) => setSearch(event.target.value)} 164 onClear={() => setSearch('')} 165 placeholder="Search knowledge..." 166 /> 167 </div> 168 169 {hygiene && ( 170 <div className="px-5 pb-2 shrink-0"> 171 <div className="rounded-[12px] border border-white/[0.06] bg-white/[0.03] p-3"> 172 <div className="flex items-center justify-between gap-3"> 173 <div> 174 <div className="text-[10px] font-700 uppercase tracking-[0.12em] text-text-3/55">Hygiene</div> 175 <div className="mt-1 flex flex-wrap gap-2 text-[11px] text-text-2/80"> 176 <span>stale {hygiene.counts.stale}</span> 177 <span>duplicates {hygiene.counts.duplicate}</span> 178 <span>broken {hygiene.counts.broken}</span> 179 <span>archived {hygiene.counts.archived}</span> 180 <span>superseded {hygiene.counts.superseded}</span> 181 </div> 182 </div> 183 <button 184 onClick={() => { void runMaintenance() }} 185 disabled={maintaining} 186 className="rounded-[9px] border border-white/[0.08] bg-white/[0.04] px-2.5 py-1.5 text-[11px] font-600 text-text-2 transition-all cursor-pointer disabled:opacity-50" 187 > 188 {maintaining ? 'Running…' : 'Maintain'} 189 </button> 190 </div> 191 <div className="mt-3 flex items-center justify-between gap-3"> 192 <div className="text-[10px] text-text-3/55"> 193 Last scan {new Date(hygiene.scannedAt).toLocaleTimeString()} 194 </div> 195 <button 196 onClick={() => setIncludeArchived((current) => !current)} 197 className={`rounded-[8px] px-2 py-1 text-[10px] font-700 uppercase tracking-[0.08em] cursor-pointer ${ 198 includeArchived ? 'bg-amber-500/12 text-amber-200' : 'bg-white/[0.04] text-text-3/75' 199 }`} 200 > 201 {includeArchived ? 'Showing archived' : 'Hide archived'} 202 </button> 203 </div> 204 </div> 205 </div> 206 )} 207 208 {uniqueTags.length > 0 && ( 209 <div className="px-5 pb-1.5 shrink-0" style={{ animation: 'fade-up 0.4s var(--ease-spring) 0.05s both' }}> 210 <div className="flex gap-1 flex-wrap"> 211 <button 212 onClick={() => setActiveTag(null)} 213 className={`px-2 py-0.5 rounded-[6px] text-[9px] font-600 cursor-pointer transition-all uppercase tracking-wider ${ 214 !activeTag ? 'bg-white/[0.06] text-text-2' : 'bg-transparent text-text-3/70 hover:text-text-3' 215 }`} 216 style={{ fontFamily: 'inherit' }} 217 > 218 all 219 </button> 220 {uniqueTags.map((tag) => ( 221 <button 222 key={tag} 223 onClick={() => setActiveTag(activeTag === tag ? null : tag)} 224 className={`px-2 py-0.5 rounded-[6px] text-[9px] font-600 cursor-pointer transition-all uppercase tracking-wider ${ 225 activeTag === tag ? 'bg-white/[0.06] text-text-2' : 'bg-transparent text-text-3/70 hover:text-text-3' 226 }`} 227 style={{ fontFamily: 'inherit' }} 228 > 229 {tag} 230 </button> 231 ))} 232 </div> 233 </div> 234 )} 235 236 {items.length > 0 ? ( 237 <div className="grid grid-cols-1 gap-3 px-5 pb-6"> 238 {showingHits 239 ? hits.map((hit, idx) => { 240 const scopedAgents = scopedAgentsFor(hit.agentIds) 241 const active = selectedKnowledgeSourceId === hit.sourceId 242 return ( 243 <div 244 key={hit.id} 245 onClick={() => setSelectedKnowledgeSourceId(hit.sourceId)} 246 className={`p-3 rounded-[12px] border transition-all relative group cursor-pointer ${ 247 active 248 ? 'border-accent-bright/25 bg-accent-soft/10' 249 : 'border-white/[0.04] bg-transparent hover:bg-surface-2 hover:border-white/[0.1]' 250 }`} 251 style={{ 252 animation: 'spring-in 0.5s var(--ease-spring) both', 253 animationDelay: `${0.08 + idx * 0.02}s`, 254 }} 255 > 256 <div className="flex items-start justify-between gap-2 mb-1.5"> 257 <div className="min-w-0"> 258 <div className="flex items-center gap-1.5 mb-1"> 259 <span className="font-display text-[13px] font-600 text-text truncate">{hit.sourceTitle}</span> 260 <Badge variant="secondary" className="text-[9px] px-1.5 py-0 uppercase">{hit.sourceKind}</Badge> 261 </div> 262 <p className="text-[10px] text-text-3/55"> 263 Chunk {hit.chunkIndex + 1} of {hit.chunkCount} 264 {hit.sectionLabel ? ` • ${hit.sectionLabel}` : ''} 265 </p> 266 {hit.whyMatched && ( 267 <p className="mt-1 text-[10px] text-sky-200/70">{hit.whyMatched}</p> 268 )} 269 </div> 270 <button 271 onClick={(event) => { 272 event.stopPropagation() 273 openSheet(hit.sourceId) 274 }} 275 className="text-text-3/40 hover:text-accent-bright transition-colors p-0.5 cursor-pointer" 276 title="Edit" 277 > 278 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 279 <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /> 280 <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /> 281 </svg> 282 </button> 283 </div> 284 285 <p className="text-[11px] text-text-2/80 line-clamp-4">{hit.snippet}</p> 286 287 <div className="flex items-center gap-2 mt-2.5 flex-wrap"> 288 {hit.tags.map((tag) => ( 289 <Badge key={`${hit.id}-${tag}`} variant="secondary" className="text-[9px] px-1.5 py-0">{tag}</Badge> 290 ))} 291 </div> 292 293 <div className="flex items-center gap-2 mt-2.5"> 294 <span className={`text-[10px] font-600 ${hit.scope === 'global' ? 'text-emerald-400' : 'text-amber-400'}`}> 295 {hit.scope === 'global' ? 'Global' : `${hit.agentIds.length} agent(s)`} 296 </span> 297 {scopedAgents.length > 0 && ( 298 <div className="flex items-center -space-x-1.5"> 299 {scopedAgents.slice(0, 5).map((agent) => ( 300 <AgentAvatar 301 key={agent.id} 302 seed={agent.avatarSeed} 303 avatarUrl={agent.avatarUrl} 304 name={agent.name} 305 size={16} 306 className="ring-1 ring-surface" 307 /> 308 ))} 309 </div> 310 )} 311 </div> 312 </div> 313 ) 314 }) 315 : sources.map((source, idx) => { 316 const scopedAgents = scopedAgentsFor(source.agentIds) 317 const active = selectedKnowledgeSourceId === source.id 318 return ( 319 <div 320 key={source.id} 321 onClick={() => setSelectedKnowledgeSourceId(source.id)} 322 className={`p-3 rounded-[12px] border transition-all relative group cursor-pointer ${ 323 active 324 ? 'border-accent-bright/25 bg-accent-soft/10' 325 : 'border-white/[0.04] bg-transparent hover:bg-surface-2 hover:border-white/[0.1]' 326 }`} 327 style={{ 328 animation: 'spring-in 0.5s var(--ease-spring) both', 329 animationDelay: `${0.08 + idx * 0.02}s`, 330 }} 331 > 332 <div className="flex items-start justify-between gap-2 mb-1"> 333 <div className="min-w-0"> 334 <div className="flex items-center gap-1.5 mb-1"> 335 <span className="font-display text-[13px] font-600 text-text truncate">{source.title}</span> 336 <Badge variant="secondary" className="text-[9px] px-1.5 py-0 uppercase">{source.kind}</Badge> 337 {source.archivedAt ? ( 338 <Badge variant="secondary" className="text-[9px] px-1.5 py-0 uppercase text-amber-200">archived</Badge> 339 ) : source.supersededBySourceId ? ( 340 <Badge variant="secondary" className="text-[9px] px-1.5 py-0 uppercase text-text-3">superseded</Badge> 341 ) : null} 342 </div> 343 <p className="text-[10px] text-text-3/55"> 344 {source.chunkCount} chunk{source.chunkCount === 1 ? '' : 's'} 345 {' • '} 346 {formatDate(source.lastIndexedAt)} 347 </p> 348 </div> 349 350 <div className="flex items-center gap-1 shrink-0"> 351 <button 352 onClick={(event) => { 353 event.stopPropagation() 354 openSheet(source.id) 355 }} 356 className="text-text-3/40 hover:text-accent-bright transition-colors p-0.5 cursor-pointer" 357 title="Edit" 358 > 359 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 360 <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /> 361 <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /> 362 </svg> 363 </button> 364 <button 365 onClick={(event) => { 366 event.stopPropagation() 367 void handleDelete(source.id) 368 }} 369 className="text-text-3/40 hover:text-red-400 transition-colors p-0.5 cursor-pointer" 370 title="Delete" 371 > 372 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 373 <path d="M3 6h18M19 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" /> 374 </svg> 375 </button> 376 </div> 377 </div> 378 379 {source.topSnippet && ( 380 <p className="text-[11px] text-text-3/70 line-clamp-3 mb-2">{source.topSnippet}</p> 381 )} 382 383 <div className="flex items-center gap-2 flex-wrap"> 384 <span className={`text-[10px] font-600 ${ 385 source.syncStatus === 'error' 386 ? 'text-red-300' 387 : source.stale 388 ? 'text-amber-300' 389 : 'text-emerald-300' 390 }`} 391 > 392 {source.syncStatus === 'error' ? 'Sync error' : source.stale ? 'Stale' : 'Ready'} 393 </span> 394 <span className={`text-[10px] font-600 ${source.scope === 'global' ? 'text-emerald-400' : 'text-amber-400'}`}> 395 {source.scope === 'global' ? 'Global' : `${source.agentIds.length} agent(s)`} 396 </span> 397 {source.sourceLabel && ( 398 <span className="text-[10px] text-text-3/55 truncate">{source.sourceLabel}</span> 399 )} 400 </div> 401 402 {source.tags.length > 0 && ( 403 <div className="flex items-center gap-1 mt-2 flex-wrap"> 404 {source.tags.map((tag) => ( 405 <Badge key={`${source.id}-${tag}`} variant="secondary" className="text-[9px] px-1.5 py-0">{tag}</Badge> 406 ))} 407 </div> 408 )} 409 410 {scopedAgents.length > 0 && ( 411 <div className="flex items-center gap-1.5 mt-2"> 412 <div className="flex items-center -space-x-1.5"> 413 {scopedAgents.slice(0, 5).map((agent) => ( 414 <AgentAvatar 415 key={agent.id} 416 seed={agent.avatarSeed} 417 avatarUrl={agent.avatarUrl} 418 name={agent.name} 419 size={16} 420 className="ring-1 ring-surface" 421 /> 422 ))} 423 </div> 424 {scopedAgents.length > 5 && ( 425 <span className="text-[10px] font-600 text-text-3/60">+{scopedAgents.length - 5}</span> 426 )} 427 </div> 428 )} 429 </div> 430 ) 431 })} 432 </div> 433 ) : error ? ( 434 <div className="flex-1 flex flex-col items-center justify-center gap-3 text-text-3 p-8 text-center" style={{ animation: 'fade-up 0.5s var(--ease-spring)' }}> 435 <p className="font-display text-[14px] font-600 text-text-2">Couldn't load knowledge</p> 436 <p className="text-[12px] text-text-3/60">{error}</p> 437 <button 438 onClick={() => { void load(search, activeTag) }} 439 className="px-3 py-1.5 rounded-[8px] bg-accent-soft text-accent-bright text-[12px] font-600 cursor-pointer border-none" 440 style={{ fontFamily: 'inherit' }} 441 > 442 Retry 443 </button> 444 </div> 445 ) : ( 446 <EmptyState 447 icon={ 448 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-accent-bright"> 449 <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" /> 450 <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" /> 451 </svg> 452 } 453 title={showingHits ? 'No matching knowledge chunks' : 'No knowledge sources yet'} 454 subtitle={showingHits ? 'Try a broader query or clear filters' : 'Add a manual note, upload a file, or import a URL'} 455 action={{ label: '+ Add Knowledge', onClick: () => openSheet() }} 456 /> 457 )} 458 </div> 459 ) 460 }