/ src / components / extensions / extension-list.tsx
extension-list.tsx
  1  'use client'
  2  
  3  import { useEffect, useState, useCallback, useMemo } from 'react'
  4  import { useAppStore } from '@/stores/use-app-store'
  5  import { useNavigate } from '@/lib/app/navigation'
  6  import { api } from '@/lib/app/api-client'
  7  import { getExtensionSourceLabel } from '@/lib/extension-sources'
  8  import { toast } from 'sonner'
  9  import { useMountedRef } from '@/hooks/use-mounted-ref'
 10  import type { Agent, MarketplaceExtension, ExtensionMeta } from '@/types'
 11  import { AgentAvatar } from '@/components/agents/agent-avatar'
 12  import { ConfirmDialog } from '@/components/shared/confirm-dialog'
 13  import { dedup } from '@/lib/shared-utils'
 14  
 15  type TopTab = 'extensions' | 'marketplace'
 16  
 17  export function ExtensionList({ inSidebar }: { inSidebar?: boolean }) {
 18    const extensions = useAppStore((s) => s.extensions)
 19    const loadExtensions = useAppStore((s) => s.loadExtensions)
 20    const setExtensionSheetOpen = useAppStore((s) => s.setExtensionSheetOpen)
 21    const setEditingExtensionFilename = useAppStore((s) => s.setEditingExtensionFilename)
 22    const agents = useAppStore((s) => s.agents)
 23    const setCurrentAgent = useAppStore((s) => s.setCurrentAgent)
 24    const navigateTo = useNavigate()
 25  
 26    const navigateToAgentChat = useCallback((agentId: string) => {
 27      void setCurrentAgent(agentId)
 28      navigateTo('agents')
 29      // eslint-disable-next-line react-hooks/exhaustive-deps
 30    }, [])
 31  
 32    const [tab, setTab] = useState<TopTab>('extensions')
 33    const [marketplace, setMarketplace] = useState<MarketplaceExtension[]>([])
 34    const [mpLoading, setMpLoading] = useState(false)
 35    const [installing, setInstalling] = useState<string | null>(null)
 36    const [deleting, setDeleting] = useState(false)
 37    const [confirmDelete, setConfirmDelete] = useState<{ filename: string; name: string } | null>(null)
 38    const [search, setSearch] = useState('')
 39    const [activeTag, setActiveTag] = useState<string | null>(null)
 40    const [sort, setSort] = useState<'name' | 'downloads'>('downloads')
 41    const mountedRef = useMountedRef()
 42  
 43    useEffect(() => {
 44      void loadExtensions()
 45    }, [loadExtensions])
 46  
 47    const loadMarketplace = useCallback(async () => {
 48      if (!mountedRef.current) return
 49      setMpLoading(true)
 50      try {
 51        const data = await api<MarketplaceExtension[]>('GET', '/extensions/marketplace')
 52        if (mountedRef.current && Array.isArray(data)) setMarketplace(data)
 53      } catch { /* ignore */ }
 54      if (mountedRef.current) setMpLoading(false)
 55    }, [mountedRef])
 56  
 57    useEffect(() => {
 58      if (inSidebar || tab !== 'marketplace') return
 59      const timer = setTimeout(() => { void loadMarketplace() }, 0)
 60      return () => clearTimeout(timer)
 61    }, [tab, inSidebar, loadMarketplace])
 62  
 63    const extensionList = Object.values(extensions)
 64    const filteredExtensionList = useMemo(() => extensionList, [extensionList])
 65  
 66    // Search filtering for installed extensions
 67    const filterInstalled = useCallback((list: ExtensionMeta[]) => {
 68      if (!search.trim()) return list
 69      const q = search.toLowerCase()
 70      return list.filter((p) =>
 71        p.name.toLowerCase().includes(q) ||
 72        (p.description || '').toLowerCase().includes(q) ||
 73        p.filename.toLowerCase().includes(q)
 74      )
 75    }, [search])
 76  
 77    const filteredExtensions = useMemo(() => filterInstalled(filteredExtensionList), [filterInstalled, filteredExtensionList])
 78  
 79    const handleEdit = (filename: string) => {
 80      setEditingExtensionFilename(filename)
 81      setExtensionSheetOpen(true)
 82    }
 83  
 84    const handleToggle = async (e: React.MouseEvent, filename: string, enabled: boolean) => {
 85      e.stopPropagation()
 86      try {
 87        await api('POST', '/extensions', { filename, enabled: !enabled })
 88        toast.success(!enabled ? 'Extension enabled' : 'Extension disabled')
 89        loadExtensions()
 90      } catch (err: unknown) {
 91        toast.error(err instanceof Error ? err.message : 'Failed to toggle extension')
 92      }
 93    }
 94  
 95    const handleDeleteClick = (e: React.MouseEvent, filename: string, name: string) => {
 96      e.stopPropagation()
 97      setConfirmDelete({ filename, name })
 98    }
 99  
100    const handleDeleteConfirm = async () => {
101      if (!confirmDelete) return
102      setDeleting(true)
103      try {
104        await api('DELETE', `/extensions?filename=${encodeURIComponent(confirmDelete.filename)}`)
105        toast.success('Extension deleted')
106        await loadExtensions()
107      } catch (err: unknown) {
108        toast.error(err instanceof Error ? err.message : 'Delete failed')
109      } finally {
110        setDeleting(false)
111        setConfirmDelete(null)
112      }
113    }
114  
115    const installFromMarketplace = async (p: MarketplaceExtension) => {
116      setInstalling(p.id)
117      const toastId = toast.loading(`Installing ${p.name}...`)
118      try {
119        const safeFilename = `${p.id.replace(/[^a-zA-Z0-9.-]/g, '_')}.js`
120        await api('POST', '/extensions/install', {
121          url: p.url,
122          filename: safeFilename,
123          installMethod: 'marketplace',
124          sourceLabel: p.source,
125          installSource: p.catalogSource || p.source,
126        })
127        await loadExtensions()
128        toast.success(`Installed ${p.name}`, { id: toastId })
129      } catch (err: unknown) {
130        toast.error(err instanceof Error ? err.message : 'Install failed', { id: toastId })
131      }
132      setInstalling(null)
133    }
134  
135    const installedFilenames = new Set(Object.keys(extensions))
136  
137    // --- Sidebar mode ---
138    if (inSidebar) {
139      return (
140        <div className="px-3 pb-4 flex-1 overflow-y-auto">
141          <div className="space-y-2">
142            {extensionList.map((ext) => (
143              <SidebarExtensionCard key={ext.filename} ext={ext} onEdit={handleEdit} />
144            ))}
145          </div>
146        </div>
147      )
148    }
149  
150    // --- Full page mode ---
151    const enabledCount = extensionList.filter((p) => p.enabled).length
152    const totalTools = extensionList.reduce((acc, p) => acc + (p.toolCount ?? 0), 0)
153    const totalHooks = extensionList.reduce((acc, p) => acc + (p.hookCount ?? 0), 0)
154  
155    return (
156      <div className="flex-1 overflow-y-auto px-5 pb-6">
157        {/* Stats bar */}
158        <div className="flex items-center gap-3 mb-4">
159          <Stat label="Installed" value={extensionList.length} />
160          <Stat label="Enabled" value={enabledCount} accent />
161          <Stat label="Tools" value={totalTools} />
162          <Stat label="Hooks" value={totalHooks} />
163          <div className="flex-1" />
164          {/* Search */}
165          <div className="relative w-[260px]">
166            <svg className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-3/40" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
167              <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
168            </svg>
169            <input
170              value={search}
171              onChange={(e) => setSearch(e.target.value)}
172              placeholder="Search extensions..."
173              className="w-full pl-8 pr-3 py-2 rounded-[10px] bg-surface border border-white/[0.06] text-[12px] text-text placeholder:text-text-3/40 outline-none focus:border-accent-bright/30 transition-colors"
174              style={{ fontFamily: 'inherit' }}
175            />
176          </div>
177        </div>
178  
179        {/* Tabs */}
180        <div className="flex items-center gap-1 mb-5 border-b border-white/[0.06] pb-px">
181          <TabButton active={tab === 'extensions'} onClick={() => setTab('extensions')} count={extensionList.length}>
182            Extensions
183          </TabButton>
184          <TabButton active={tab === 'marketplace'} onClick={() => setTab('marketplace')}>
185            Marketplace
186          </TabButton>
187        </div>
188  
189        {/* Tab content */}
190        {tab === 'extensions' && (
191          <InstalledGrid
192            extensions={filteredExtensions}
193            allowDelete
194            search={search}
195            agents={agents}
196            onEdit={handleEdit}
197            onToggle={handleToggle}
198            onDelete={handleDeleteClick}
199            onNavigateToAgent={navigateToAgentChat}
200            emptyMessage={search ? 'No extensions match your search' : 'No extensions installed'}
201            emptyAction={!search ? (
202              <button
203                onClick={() => setTab('marketplace')}
204                className="mt-3 px-4 py-2 rounded-[10px] bg-transparent text-accent-bright text-[12px] font-600 cursor-pointer border border-accent-bright/20 hover:bg-accent-soft transition-all"
205                style={{ fontFamily: 'inherit' }}
206              >
207                Browse Marketplace
208              </button>
209            ) : undefined}
210          />
211        )}
212  
213        {tab === 'marketplace' && (
214          <MarketplaceTab
215            marketplace={marketplace}
216            loading={mpLoading}
217            installing={installing}
218            installedFilenames={installedFilenames}
219            search={search}
220            activeTag={activeTag}
221            setActiveTag={setActiveTag}
222            sort={sort}
223            setSort={setSort}
224            onInstall={installFromMarketplace}
225          />
226        )}
227  
228        <ConfirmDialog
229          open={!!confirmDelete}
230          title="Delete Extension"
231          message={confirmDelete ? `Delete "${confirmDelete.name}"? This cannot be undone.` : ''}
232          confirmLabel={deleting ? 'Deleting...' : 'Delete'}
233          danger
234          onConfirm={() => { void handleDeleteConfirm() }}
235          onCancel={() => { if (!deleting) setConfirmDelete(null) }}
236        />
237      </div>
238    )
239  }
240  
241  // --- Sub-components ---
242  
243  function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
244    return (
245      <div className="flex items-center gap-1.5">
246        <span className={`text-[18px] font-700 tabular-nums ${accent ? 'text-accent-bright' : 'text-text'}`}>
247          {value}
248        </span>
249        <span className="text-[11px] text-text-3/60 font-500">{label}</span>
250      </div>
251    )
252  }
253  
254  function TabButton({ active, onClick, count, children }: {
255    active: boolean; onClick: () => void; count?: number; children: React.ReactNode
256  }) {
257    return (
258      <button
259        onClick={onClick}
260        className={`relative px-3 py-2 text-[12px] font-600 cursor-pointer transition-all border-none bg-transparent
261          ${active ? 'text-accent-bright' : 'text-text-3/60 hover:text-text-2'}`}
262        style={{ fontFamily: 'inherit' }}
263      >
264        <span className="flex items-center gap-1.5">
265          {children}
266          {count !== undefined && (
267            <span className={`text-[10px] tabular-nums px-1.5 py-px rounded-full ${
268              active ? 'bg-accent-soft text-accent-bright' : 'bg-white/[0.04] text-text-3/50'
269            }`}>
270              {count}
271            </span>
272          )}
273        </span>
274        {active && <div className="absolute bottom-0 left-2 right-2 h-[2px] rounded-full bg-accent-bright" />}
275      </button>
276    )
277  }
278  
279  function extensionDescription(ext: ExtensionMeta): string {
280    const raw = (ext.description || '').trim()
281    if (raw) return raw
282    const sourceLabel = ext.isBuiltin ? 'built-in tool integration' : 'installed extension'
283    return `No description provided. Click to view metadata and controls for this ${sourceLabel}.`
284  }
285  
286  function extensionCapabilityBadges(ext: ExtensionMeta): string[] {
287    const badges: string[] = []
288    if (ext.toolCount && ext.toolCount > 0) badges.push(`${ext.toolCount} tool${ext.toolCount === 1 ? '' : 's'}`)
289    if (ext.hookCount && ext.hookCount > 0) badges.push(`${ext.hookCount} hook${ext.hookCount === 1 ? '' : 's'}`)
290    if (ext.hasUI) badges.push('UI')
291    if (ext.providerCount && ext.providerCount > 0) badges.push(`${ext.providerCount} provider${ext.providerCount === 1 ? '' : 's'}`)
292    if (ext.connectorCount && ext.connectorCount > 0) badges.push(`${ext.connectorCount} connector${ext.connectorCount === 1 ? '' : 's'}`)
293    if (ext.hasDependencyManifest) badges.push(`${ext.dependencyCount ?? 0} dep${ext.dependencyCount === 1 ? '' : 's'}`)
294    return badges
295  }
296  
297  // --- Installed extensions grid ---
298  
299  function InstalledGrid({ extensions, allowDelete, search, agents, onEdit, onToggle, onDelete, onNavigateToAgent, emptyMessage, emptyAction }: {
300    extensions: ExtensionMeta[]
301    allowDelete: boolean
302    search: string
303    agents: Record<string, Agent>
304    onEdit: (filename: string) => void
305    onToggle: (e: React.MouseEvent, filename: string, enabled: boolean) => void
306    onDelete: (e: React.MouseEvent, filename: string, name: string) => void
307    onNavigateToAgent: (agentId: string) => void
308    emptyMessage: string
309    emptyAction?: React.ReactNode
310  }) {
311    if (extensions.length === 0) {
312      return (
313        <div className="text-center py-16">
314          <div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-white/[0.03] mb-3">
315            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-text-3/30">
316              <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" strokeLinecap="round" strokeLinejoin="round" />
317            </svg>
318          </div>
319          <p className="text-[13px] text-text-3/50">{emptyMessage}</p>
320          {emptyAction}
321        </div>
322      )
323    }
324  
325    // Group enabled first, then disabled
326    const enabled = extensions.filter((p) => p.enabled)
327    const disabled = extensions.filter((p) => !p.enabled)
328    const sorted = [...enabled, ...disabled]
329  
330    return (
331      <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
332        {sorted.map((ext) => (
333          <ExtensionCard
334            key={ext.filename}
335            ext={ext}
336            allowDelete={allowDelete}
337            agents={agents}
338            onEdit={onEdit}
339            onToggle={onToggle}
340            onDelete={onDelete}
341            onNavigateToAgent={onNavigateToAgent}
342            highlight={search}
343          />
344        ))}
345      </div>
346    )
347  }
348  
349  // --- Extension card ---
350  
351  function ExtensionCard({ ext, allowDelete, agents, onEdit, onToggle, onDelete, onNavigateToAgent, highlight }: {
352    ext: ExtensionMeta
353    allowDelete: boolean
354    agents: Record<string, Agent>
355    onEdit: (filename: string) => void
356    onToggle: (e: React.MouseEvent, filename: string, enabled: boolean) => void
357    onDelete: (e: React.MouseEvent, filename: string, name: string) => void
358    onNavigateToAgent: (agentId: string) => void
359    highlight: string
360  }) {
361    const badges = extensionCapabilityBadges(ext)
362    const agent = ext.createdByAgentId ? agents[ext.createdByAgentId] : null
363  
364    return (
365      <div
366        role="button"
367        tabIndex={0}
368        onClick={() => onEdit(ext.filename)}
369        onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onEdit(ext.filename) } }}
370        className={`group relative text-left p-4 rounded-[14px] border transition-all cursor-pointer
371          ${ext.enabled
372            ? 'border-white/[0.06] bg-surface hover:bg-surface-2 hover:border-white/[0.1]'
373            : 'border-white/[0.03] bg-surface/50 hover:bg-surface hover:border-white/[0.06] opacity-70 hover:opacity-100'
374          }`}
375      >
376        {/* Top row: name + toggle */}
377        <div className="flex items-center justify-between gap-2 mb-1.5">
378          <div className="flex items-center gap-2 min-w-0">
379            {agent && (
380              <button
381                type="button"
382                title={`Created by ${agent.name}`}
383                onClick={(e) => { e.stopPropagation(); onNavigateToAgent(ext.createdByAgentId!) }}
384                className="shrink-0 rounded-full hover:ring-2 hover:ring-accent-bright/40 transition-all cursor-pointer bg-transparent border-none p-0"
385              >
386                <AgentAvatar
387                  seed={agent.avatarSeed || null}
388                  avatarUrl={agent.avatarUrl}
389                  name={agent.name || 'Agent'}
390                  size={20}
391                />
392              </button>
393            )}
394            <span className="font-display text-[14px] font-600 text-text truncate">
395              <HighlightText text={ext.name} highlight={highlight} />
396            </span>
397            {ext.version && (
398              <span className="text-[10px] font-mono text-text-3/40 shrink-0">v{ext.version}</span>
399            )}
400          </div>
401          <div className="flex items-center gap-2 shrink-0">
402            <div
403              onClick={(e) => onToggle(e, ext.filename, ext.enabled)}
404              className={`w-9 h-5 rounded-full transition-all relative cursor-pointer shrink-0
405                ${ext.enabled ? 'bg-accent-bright' : 'bg-white/[0.08]'}`}
406            >
407              <div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all
408                ${ext.enabled ? 'left-[18px]' : 'left-0.5'}`} />
409            </div>
410            {allowDelete && (
411              <button
412                onClick={(e) => onDelete(e, ext.filename, ext.name)}
413                className="text-text-3/30 hover:text-red-400 transition-colors p-0.5 opacity-0 group-hover:opacity-100"
414                title="Delete"
415              >
416                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
417                  <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" />
418                </svg>
419              </button>
420            )}
421          </div>
422        </div>
423  
424        {/* Description */}
425        <p className="text-[12px] text-text-3/60 leading-relaxed line-clamp-2 mb-2.5">
426          {extensionDescription(ext)}
427        </p>
428  
429        {/* Badges */}
430        <div className="flex items-center gap-1.5 flex-wrap">
431          {badges.map((badge) => (
432            <span key={badge} className="text-[10px] font-600 px-1.5 py-0.5 rounded-full text-text-3/70 bg-white/[0.04]">
433              {badge}
434            </span>
435          ))}
436          {ext.sourceLabel && (
437            <SourceChip label={getExtensionSourceLabel(ext.sourceLabel)} tone="publisher" />
438          )}
439          {ext.installSource && ext.installSource !== ext.sourceLabel && (
440            <SourceChip label={`via ${getExtensionSourceLabel(ext.installSource)}`} tone="catalog" />
441          )}
442          {ext.hasDependencyManifest && (
443            <span className={`text-[10px] font-700 px-1.5 py-0.5 rounded-full ${
444              ext.dependencyInstallStatus === 'installed'
445                ? 'text-emerald-400 bg-emerald-500/10'
446                : ext.dependencyInstallStatus === 'error'
447                  ? 'text-red-400 bg-red-500/10'
448                  : 'text-amber-400 bg-amber-500/10'
449            }`}>
450              deps {ext.dependencyInstallStatus || 'ready'}
451            </span>
452          )}
453          {ext.author && (
454            <span className="text-[10px] text-text-3/40 ml-auto">
455              {ext.author}
456            </span>
457          )}
458        </div>
459  
460        {/* Failure warning */}
461        {ext.autoDisabled && (
462          <p className="mt-2 text-[11px] text-amber-400/90 line-clamp-2">
463            Auto-disabled after {ext.failureCount ?? 0} failures
464            {ext.lastFailureStage ? ` (${ext.lastFailureStage})` : ''}.
465            {ext.lastFailureError ? ` ${ext.lastFailureError}` : ''}
466          </p>
467        )}
468      </div>
469    )
470  }
471  
472  // --- Sidebar card (compact) ---
473  
474  function SidebarExtensionCard({ ext, onEdit }: { ext: ExtensionMeta; onEdit: (filename: string) => void }) {
475    return (
476      <div
477        role="button"
478        tabIndex={0}
479        onClick={() => onEdit(ext.filename)}
480        onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onEdit(ext.filename) } }}
481        className="w-full text-left p-3 rounded-[12px] border border-white/[0.06] bg-surface hover:bg-surface-2 transition-all cursor-pointer"
482      >
483        <div className="flex items-center justify-between mb-0.5">
484          <span className="font-display text-[13px] font-600 text-text truncate">{ext.name}</span>
485          <span className={`text-[10px] font-600 px-1.5 py-0.5 rounded-full ${
486            ext.enabled ? 'text-emerald-400 bg-emerald-400/10' : 'text-text-3/50 bg-white/[0.04]'
487          }`}>
488            {ext.enabled ? 'On' : 'Off'}
489          </span>
490        </div>
491        <p className="text-[11px] text-text-3/50 line-clamp-1">{extensionDescription(ext)}</p>
492      </div>
493    )
494  }
495  
496  // --- Highlight text helper ---
497  
498  function HighlightText({ text, highlight }: { text: string; highlight: string }) {
499    if (!highlight.trim()) return <>{text}</>
500    const idx = text.toLowerCase().indexOf(highlight.toLowerCase())
501    if (idx === -1) return <>{text}</>
502    return (
503      <>
504        {text.slice(0, idx)}
505        <span className="text-accent-bright">{text.slice(idx, idx + highlight.length)}</span>
506        {text.slice(idx + highlight.length)}
507      </>
508    )
509  }
510  
511  // --- Marketplace tab ---
512  
513  function MarketplaceTab({ marketplace, loading, installing, installedFilenames, search, activeTag, setActiveTag, sort, setSort, onInstall }: {
514    marketplace: MarketplaceExtension[]
515    loading: boolean
516    installing: string | null
517    installedFilenames: Set<string>
518    search: string
519    activeTag: string | null
520    setActiveTag: (v: string | null) => void
521    sort: 'name' | 'downloads'
522    setSort: (v: 'name' | 'downloads') => void
523    onInstall: (p: MarketplaceExtension) => void
524  }) {
525    if (loading) return <p className="text-[12px] text-text-3/70 py-8 text-center">Loading marketplace...</p>
526  
527    if (marketplace.length === 0) {
528      return (
529        <div className="text-center py-16">
530          <div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-white/[0.03] mb-3">
531            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-text-3/30">
532              <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" strokeLinecap="round" strokeLinejoin="round" />
533              <polyline points="9,22 9,12 15,12 15,22" strokeLinecap="round" strokeLinejoin="round" />
534            </svg>
535          </div>
536          <p className="text-[13px] text-text-3/50">No extensions available in the marketplace</p>
537        </div>
538      )
539    }
540  
541    const allTags = dedup(marketplace.flatMap((p) => p.tags ?? [])).sort()
542    const q = search.toLowerCase()
543    const filtered = marketplace
544      .filter((p) => {
545        const sourceTerms = [getExtensionSourceLabel(p.source).toLowerCase(), getExtensionSourceLabel(p.catalogSource).toLowerCase()]
546        if (
547          q
548          && !p.name.toLowerCase().includes(q)
549          && !p.description.toLowerCase().includes(q)
550          && !(p.tags ?? []).some((t) => t.toLowerCase().includes(q))
551          && !sourceTerms.some((term) => term.includes(q))
552        ) return false
553        if (activeTag && !(p.tags ?? []).includes(activeTag)) return false
554        return true
555      })
556      .sort((a, b) => sort === 'downloads' ? (b.downloads ?? 0) - (a.downloads ?? 0) : a.name.localeCompare(b.name))
557  
558    return (
559      <div className="space-y-3">
560        {/* Tags + Sort */}
561        <div className="flex items-center gap-1.5 flex-wrap">
562          <button
563            onClick={() => setActiveTag(null)}
564            className={`px-2 py-1 rounded-[6px] text-[10px] font-600 cursor-pointer transition-all border-none ${
565              !activeTag ? 'bg-accent-soft text-accent-bright' : 'bg-white/[0.03] text-text-3/60 hover:text-text-3'
566            }`}
567          >
568            All
569          </button>
570          {allTags.map((t) => (
571            <button
572              key={t}
573              onClick={() => setActiveTag(activeTag === t ? null : t)}
574              className={`px-2 py-1 rounded-[6px] text-[10px] font-600 cursor-pointer transition-all border-none ${
575                activeTag === t ? 'bg-accent-soft text-accent-bright' : 'bg-white/[0.03] text-text-3/60 hover:text-text-3'
576              }`}
577            >
578              {t}
579            </button>
580          ))}
581          <div className="flex-1" />
582          <select
583            value={sort}
584            onChange={(e) => setSort(e.target.value as 'name' | 'downloads')}
585            className="px-2 py-1 rounded-[6px] bg-surface border border-white/[0.06] text-[10px] text-text-3 outline-none cursor-pointer appearance-none"
586            style={{ fontFamily: 'inherit' }}
587          >
588            <option value="downloads">Popular</option>
589            <option value="name">A-Z</option>
590          </select>
591        </div>
592  
593        {filtered.length === 0 ? (
594          <p className="text-[12px] text-text-3/50 text-center py-4">No extensions match your search</p>
595        ) : (
596          <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
597            {filtered.map((p) => {
598              const isInstalled = installedFilenames.has(`${p.id}.js`)
599              return (
600                <div key={p.id} className="py-3.5 px-4 rounded-[14px] bg-surface border border-white/[0.06]">
601                  <div className="flex items-start gap-3">
602                    <div className="flex-1 min-w-0">
603                      <div className="flex items-center gap-2">
604                        <span className="text-[14px] font-600 text-text">{p.name}</span>
605                        <span className="text-[10px] font-mono text-text-3/70">v{p.version}</span>
606                        {p.openclaw && <span className="text-[9px] font-600 text-emerald-400 bg-emerald-400/10 px-1.5 py-0.5 rounded-full">OpenClaw</span>}
607                      </div>
608                      <div className="flex items-center gap-1.5 mt-2 flex-wrap">
609                        {p.source && <SourceChip label={getExtensionSourceLabel(p.source)} tone="publisher" />}
610                        {p.catalogSource && p.catalogSource !== p.source && (
611                          <SourceChip label={`via ${getExtensionSourceLabel(p.catalogSource)}`} tone="catalog" />
612                        )}
613                      </div>
614                      <div className="text-[11px] text-text-3/60 mt-1 line-clamp-2">{p.description}</div>
615                      <div className="flex items-center gap-2 mt-2">
616                        <span className="text-[10px] text-text-3/70">by {p.author}</span>
617                        <span className="text-[10px] text-text-3/50">&middot;</span>
618                        {(p.tags ?? []).slice(0, 3).map((t) => (
619                          <button
620                            key={t}
621                            onClick={() => setActiveTag(activeTag === t ? null : t)}
622                            className={`text-[9px] font-600 px-1.5 py-0.5 rounded-full cursor-pointer transition-all border-none ${
623                              activeTag === t ? 'text-accent-bright bg-accent-soft' : 'text-text-3/50 bg-white/[0.04] hover:text-text-3'
624                            }`}
625                          >
626                            {t}
627                          </button>
628                        ))}
629                      </div>
630                    </div>
631                    <button
632                      onClick={() => !isInstalled && onInstall(p)}
633                      disabled={isInstalled || installing === p.id}
634                      className={`shrink-0 py-2 px-4 rounded-[10px] text-[12px] font-600 transition-all cursor-pointer
635                        ${isInstalled
636                          ? 'bg-white/[0.04] text-text-3/70 cursor-default'
637                          : installing === p.id
638                            ? 'bg-accent-soft text-accent-bright animate-pulse'
639                            : 'bg-accent-soft text-accent-bright hover:bg-accent-soft/80 border border-accent-bright/20'}`}
640                      style={{ fontFamily: 'inherit' }}
641                    >
642                      {isInstalled ? 'Installed' : installing === p.id ? 'Installing...' : 'Install'}
643                    </button>
644                  </div>
645                </div>
646              )
647            })}
648          </div>
649        )}
650      </div>
651    )
652  }
653  
654  function SourceChip({ label, tone }: { label: string; tone: 'publisher' | 'catalog' }) {
655    return (
656      <span className={tone === 'publisher'
657        ? 'text-[10px] font-700 px-1.5 py-0.5 rounded-full bg-sky-500/10 text-sky-300'
658        : 'text-[10px] font-700 px-1.5 py-0.5 rounded-full bg-white/[0.05] text-text-3/75'}>
659        {label}
660      </span>
661    )
662  }