/ src / components / knowledge / knowledge-list.tsx
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&apos;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  }