@swarmclawai/swarmclaw 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +85 -139
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/thread/route.ts +1 -2
  4. package/src/app/api/agents/route.ts +1 -1
  5. package/src/app/api/{sessions → chats}/[id]/checkpoints/route.ts +1 -1
  6. package/src/app/api/{sessions → chats}/[id]/main-loop/route.ts +2 -2
  7. package/src/app/api/{sessions → chats}/[id]/restore/route.ts +1 -1
  8. package/src/app/api/{sessions → chats}/[id]/route.ts +4 -52
  9. package/src/app/api/{sessions → chats}/route.ts +5 -7
  10. package/src/app/api/plugins/route.ts +3 -0
  11. package/src/app/api/plugins/settings/route.ts +35 -0
  12. package/src/app/api/usage/route.ts +30 -0
  13. package/src/cli/index.js +35 -33
  14. package/src/cli/index.ts +40 -39
  15. package/src/cli/spec.js +29 -27
  16. package/src/components/agents/agent-card.tsx +1 -1
  17. package/src/components/agents/agent-chat-list.tsx +3 -3
  18. package/src/components/agents/agent-list.tsx +8 -13
  19. package/src/components/agents/agent-sheet.tsx +2 -2
  20. package/src/components/agents/cron-job-form.tsx +3 -3
  21. package/src/components/agents/inspector-panel.tsx +2 -2
  22. package/src/components/auth/setup-wizard.tsx +5 -38
  23. package/src/components/chat/chat-area.tsx +10 -14
  24. package/src/components/{sessions/session-card.tsx → chat/chat-card.tsx} +3 -3
  25. package/src/components/chat/chat-header.tsx +156 -73
  26. package/src/components/{sessions/session-list.tsx → chat/chat-list.tsx} +4 -5
  27. package/src/components/chat/chat-tool-toggles.tsx +26 -17
  28. package/src/components/chat/checkpoint-timeline.tsx +4 -4
  29. package/src/components/chat/message-bubble.tsx +4 -1
  30. package/src/components/chat/message-list.tsx +2 -2
  31. package/src/components/{sessions/new-session-sheet.tsx → chat/new-chat-sheet.tsx} +6 -6
  32. package/src/components/chat/session-debug-panel.tsx +1 -1
  33. package/src/components/chat/tool-request-banner.tsx +3 -3
  34. package/src/components/chatrooms/agent-hover-card.tsx +3 -3
  35. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +2 -2
  36. package/src/components/connectors/connector-sheet.tsx +1 -1
  37. package/src/components/home/home-view.tsx +1 -1
  38. package/src/components/layout/app-layout.tsx +23 -2
  39. package/src/components/plugins/plugin-list.tsx +475 -254
  40. package/src/components/plugins/plugin-sheet.tsx +124 -10
  41. package/src/components/settings/gateway-connection-panel.tsx +1 -1
  42. package/src/components/shared/command-palette.tsx +0 -1
  43. package/src/components/shared/settings/section-heartbeat.tsx +1 -1
  44. package/src/components/shared/settings/section-providers.tsx +1 -1
  45. package/src/components/shared/settings/settings-page.tsx +1 -12
  46. package/src/components/usage/metrics-dashboard.tsx +73 -0
  47. package/src/components/webhooks/webhook-sheet.tsx +1 -1
  48. package/src/lib/chat.ts +1 -1
  49. package/src/lib/{sessions.ts → chats.ts} +28 -18
  50. package/src/lib/providers/claude-cli.ts +1 -1
  51. package/src/lib/server/approvals.ts +4 -4
  52. package/src/lib/server/capability-router.ts +10 -8
  53. package/src/lib/server/chat-execution.ts +36 -105
  54. package/src/lib/server/chatroom-helpers.ts +3 -3
  55. package/src/lib/server/connectors/manager.ts +4 -4
  56. package/src/lib/server/cost.ts +34 -1
  57. package/src/lib/server/daemon-state.ts +2 -2
  58. package/src/lib/server/heartbeat-service.ts +1 -1
  59. package/src/lib/server/main-agent-loop.ts +25 -160
  60. package/src/lib/server/main-session.ts +6 -13
  61. package/src/lib/server/orchestrator-lg.ts +3 -3
  62. package/src/lib/server/orchestrator.ts +5 -5
  63. package/src/lib/server/plugins.ts +112 -4
  64. package/src/lib/server/provider-health.ts +5 -3
  65. package/src/lib/server/queue.ts +12 -10
  66. package/src/lib/server/session-run-manager.test.ts +9 -6
  67. package/src/lib/server/session-run-manager.ts +1 -3
  68. package/src/lib/server/session-tools/calendar.ts +376 -0
  69. package/src/lib/server/session-tools/canvas.ts +1 -1
  70. package/src/lib/server/session-tools/chatroom.ts +4 -2
  71. package/src/lib/server/session-tools/connector.ts +5 -2
  72. package/src/lib/server/session-tools/context.ts +7 -3
  73. package/src/lib/server/session-tools/crud.ts +14 -6
  74. package/src/lib/server/session-tools/delegate.ts +95 -8
  75. package/src/lib/server/session-tools/discovery.ts +2 -2
  76. package/src/lib/server/session-tools/edit_file.ts +4 -2
  77. package/src/lib/server/session-tools/email.ts +322 -0
  78. package/src/lib/server/session-tools/file.ts +5 -2
  79. package/src/lib/server/session-tools/git.ts +1 -1
  80. package/src/lib/server/session-tools/http.ts +1 -1
  81. package/src/lib/server/session-tools/image-gen.ts +382 -0
  82. package/src/lib/server/session-tools/index.ts +74 -49
  83. package/src/lib/server/session-tools/memory.ts +139 -2
  84. package/src/lib/server/session-tools/monitor.ts +1 -1
  85. package/src/lib/server/session-tools/openclaw-nodes.ts +1 -1
  86. package/src/lib/server/session-tools/openclaw-workspace.ts +1 -1
  87. package/src/lib/server/session-tools/platform.ts +6 -3
  88. package/src/lib/server/session-tools/plugin-creator.ts +3 -3
  89. package/src/lib/server/session-tools/replicate.ts +303 -0
  90. package/src/lib/server/session-tools/sample-ui.ts +1 -1
  91. package/src/lib/server/session-tools/sandbox.ts +4 -2
  92. package/src/lib/server/session-tools/schedule.ts +4 -2
  93. package/src/lib/server/session-tools/session-info.ts +7 -4
  94. package/src/lib/server/session-tools/shell.ts +5 -2
  95. package/src/lib/server/session-tools/subagent.ts +2 -2
  96. package/src/lib/server/session-tools/wallet.ts +29 -2
  97. package/src/lib/server/session-tools/web.ts +44 -5
  98. package/src/lib/server/storage.ts +29 -9
  99. package/src/lib/server/stream-agent-chat.ts +72 -249
  100. package/src/lib/server/tool-aliases.ts +26 -15
  101. package/src/lib/server/tool-capability-policy.test.ts +9 -9
  102. package/src/lib/server/tool-capability-policy.ts +32 -27
  103. package/src/lib/tool-definitions.ts +4 -0
  104. package/src/lib/validation/schemas.ts +3 -1
  105. package/src/stores/use-app-store.ts +5 -5
  106. package/src/stores/use-chat-store.ts +7 -7
  107. package/src/types/index.ts +65 -3
  108. /package/src/app/api/{sessions → chats}/[id]/browser/route.ts +0 -0
  109. /package/src/app/api/{sessions → chats}/[id]/chat/route.ts +0 -0
  110. /package/src/app/api/{sessions → chats}/[id]/clear/route.ts +0 -0
  111. /package/src/app/api/{sessions → chats}/[id]/deploy/route.ts +0 -0
  112. /package/src/app/api/{sessions → chats}/[id]/devserver/route.ts +0 -0
  113. /package/src/app/api/{sessions → chats}/[id]/edit-resend/route.ts +0 -0
  114. /package/src/app/api/{sessions → chats}/[id]/fork/route.ts +0 -0
  115. /package/src/app/api/{sessions → chats}/[id]/mailbox/route.ts +0 -0
  116. /package/src/app/api/{sessions → chats}/[id]/messages/route.ts +0 -0
  117. /package/src/app/api/{sessions → chats}/[id]/retry/route.ts +0 -0
  118. /package/src/app/api/{sessions → chats}/[id]/stop/route.ts +0 -0
  119. /package/src/app/api/{sessions → chats}/heartbeat/route.ts +0 -0
@@ -1,13 +1,16 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useState, useCallback } from 'react'
3
+ import { useEffect, useState, useCallback, useMemo } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { api } from '@/lib/api-client'
6
6
  import { toast } from 'sonner'
7
- import type { MarketplacePlugin, PluginMeta } from '@/types'
7
+ import type { Agent, MarketplacePlugin, PluginMeta } from '@/types'
8
8
  import { AgentAvatar } from '@/components/agents/agent-avatar'
9
9
  import { ConfirmDialog } from '@/components/shared/confirm-dialog'
10
10
 
11
+ type InstalledTab = 'core' | 'extensions'
12
+ type TopTab = InstalledTab | 'swarmforge'
13
+
11
14
  export function PluginList({ inSidebar }: { inSidebar?: boolean }) {
12
15
  const plugins = useAppStore((s) => s.plugins)
13
16
  const loadPlugins = useAppStore((s) => s.loadPlugins)
@@ -19,7 +22,6 @@ export function PluginList({ inSidebar }: { inSidebar?: boolean }) {
19
22
  const setActiveView = useAppStore((s) => s.setActiveView)
20
23
 
21
24
  const navigateToAgentChat = useCallback((agentId: string) => {
22
- // Find the most recent chat for this agent
23
25
  const agentSession = Object.values(sessions)
24
26
  .filter((s) => s.agentId === agentId)
25
27
  .sort((a, b) => (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0))[0]
@@ -30,7 +32,7 @@ export function PluginList({ inSidebar }: { inSidebar?: boolean }) {
30
32
  // eslint-disable-next-line react-hooks/exhaustive-deps
31
33
  }, [sessions])
32
34
 
33
- const [tab, setTab] = useState<'installed' | 'marketplace'>('installed')
35
+ const [tab, setTab] = useState<TopTab>('core')
34
36
  const [marketplace, setMarketplace] = useState<MarketplacePlugin[]>([])
35
37
  const [mpLoading, setMpLoading] = useState(false)
36
38
  const [installing, setInstalling] = useState<string | null>(null)
@@ -54,16 +56,28 @@ export function PluginList({ inSidebar }: { inSidebar?: boolean }) {
54
56
  }, [])
55
57
 
56
58
  useEffect(() => {
57
- if (inSidebar || tab !== 'marketplace') return
58
- const timer = setTimeout(() => {
59
- void loadMarketplace()
60
- }, 0)
59
+ if (inSidebar || tab !== 'swarmforge') return
60
+ const timer = setTimeout(() => { void loadMarketplace() }, 0)
61
61
  return () => clearTimeout(timer)
62
62
  }, [tab, inSidebar, loadMarketplace])
63
63
 
64
64
  const pluginList = Object.values(plugins)
65
- const corePlugins = pluginList.filter((plugin) => plugin.source === 'local')
66
- const extensionPlugins = pluginList.filter((plugin) => plugin.source !== 'local')
65
+ const corePlugins = useMemo(() => pluginList.filter((p) => p.source === 'local'), [pluginList])
66
+ const extensionPlugins = useMemo(() => pluginList.filter((p) => p.source !== 'local'), [pluginList])
67
+
68
+ // Search filtering for installed plugins
69
+ const filterInstalled = useCallback((list: PluginMeta[]) => {
70
+ if (!search.trim()) return list
71
+ const q = search.toLowerCase()
72
+ return list.filter((p) =>
73
+ p.name.toLowerCase().includes(q) ||
74
+ (p.description || '').toLowerCase().includes(q) ||
75
+ p.filename.toLowerCase().includes(q)
76
+ )
77
+ }, [search])
78
+
79
+ const filteredCore = useMemo(() => filterInstalled(corePlugins), [filterInstalled, corePlugins])
80
+ const filteredExtensions = useMemo(() => filterInstalled(extensionPlugins), [filterInstalled, extensionPlugins])
67
81
 
68
82
  const handleEdit = (filename: string) => {
69
83
  setEditingPluginFilename(filename)
@@ -117,111 +131,331 @@ export function PluginList({ inSidebar }: { inSidebar?: boolean }) {
117
131
 
118
132
  const installedFilenames = new Set(Object.keys(plugins))
119
133
 
120
- const tabClass = (t: string) =>
121
- `py-1.5 px-3.5 rounded-[8px] text-[12px] font-600 cursor-pointer transition-all border
122
- ${tab === t
123
- ? 'bg-accent-soft border-accent-bright/25 text-accent-bright'
124
- : 'bg-transparent border-transparent text-text-3 hover:text-text-2'}`
125
-
126
- const pluginDescription = (plugin: PluginMeta): string => {
127
- const raw = (plugin.description || '').trim()
128
- if (raw) return raw
129
- const sourceLabel = plugin.source === 'local' ? 'core plugin' : 'installed plugin'
130
- return `No description provided. Click to view metadata and controls for this ${sourceLabel}.`
134
+ // --- Sidebar mode ---
135
+ if (inSidebar) {
136
+ return (
137
+ <div className="px-3 pb-4 flex-1 overflow-y-auto">
138
+ <div className="space-y-2">
139
+ {pluginList.map((plugin) => (
140
+ <SidebarPluginCard key={plugin.filename} plugin={plugin} onEdit={handleEdit} />
141
+ ))}
142
+ </div>
143
+ </div>
144
+ )
131
145
  }
132
146
 
133
- const pluginCapabilityBadges = (plugin: PluginMeta): string[] => {
134
- const badges: string[] = []
135
- if (plugin.toolCount && plugin.toolCount > 0) badges.push(`${plugin.toolCount} tool${plugin.toolCount === 1 ? '' : 's'}`)
136
- if (plugin.hookCount && plugin.hookCount > 0) badges.push(`${plugin.hookCount} hook${plugin.hookCount === 1 ? '' : 's'}`)
137
- if (plugin.hasUI) badges.push('UI')
138
- if (plugin.providerCount && plugin.providerCount > 0) badges.push(`${plugin.providerCount} provider${plugin.providerCount === 1 ? '' : 's'}`)
139
- if (plugin.connectorCount && plugin.connectorCount > 0) badges.push(`${plugin.connectorCount} connector${plugin.connectorCount === 1 ? '' : 's'}`)
140
- return badges
147
+ // --- Full page mode ---
148
+ const enabledCount = pluginList.filter((p) => p.enabled).length
149
+ const totalTools = pluginList.reduce((acc, p) => acc + (p.toolCount ?? 0), 0)
150
+ const totalHooks = pluginList.reduce((acc, p) => acc + (p.hookCount ?? 0), 0)
151
+
152
+ return (
153
+ <div className="flex-1 overflow-y-auto px-5 pb-6">
154
+ {/* Stats bar */}
155
+ <div className="flex items-center gap-3 mb-4">
156
+ <Stat label="Installed" value={pluginList.length} />
157
+ <Stat label="Enabled" value={enabledCount} accent />
158
+ <Stat label="Tools" value={totalTools} />
159
+ <Stat label="Hooks" value={totalHooks} />
160
+ <div className="flex-1" />
161
+ {/* Search */}
162
+ <div className="relative w-[260px]">
163
+ <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">
164
+ <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
165
+ </svg>
166
+ <input
167
+ value={search}
168
+ onChange={(e) => setSearch(e.target.value)}
169
+ placeholder="Search plugins..."
170
+ 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"
171
+ style={{ fontFamily: 'inherit' }}
172
+ />
173
+ </div>
174
+ </div>
175
+
176
+ {/* Tabs */}
177
+ <div className="flex items-center gap-1 mb-5 border-b border-white/[0.06] pb-px">
178
+ <TabButton active={tab === 'core'} onClick={() => setTab('core')} count={corePlugins.length}>
179
+ Core
180
+ </TabButton>
181
+ <TabButton active={tab === 'extensions'} onClick={() => setTab('extensions')} count={extensionPlugins.length}>
182
+ Extensions
183
+ </TabButton>
184
+ <TabButton active={tab === 'swarmforge'} onClick={() => setTab('swarmforge')}>
185
+ SwarmForge
186
+ </TabButton>
187
+ </div>
188
+
189
+ {/* Tab content */}
190
+ {tab === 'core' && (
191
+ <InstalledGrid
192
+ plugins={filteredCore}
193
+ allowDelete={false}
194
+ search={search}
195
+ agents={agents}
196
+ onEdit={handleEdit}
197
+ onToggle={handleToggle}
198
+ onDelete={handleDeleteClick}
199
+ onNavigateToAgent={navigateToAgentChat}
200
+ emptyMessage="No core plugins found"
201
+ />
202
+ )}
203
+
204
+ {tab === 'extensions' && (
205
+ <InstalledGrid
206
+ plugins={filteredExtensions}
207
+ allowDelete
208
+ search={search}
209
+ agents={agents}
210
+ onEdit={handleEdit}
211
+ onToggle={handleToggle}
212
+ onDelete={handleDeleteClick}
213
+ onNavigateToAgent={navigateToAgentChat}
214
+ emptyMessage={search ? 'No extensions match your search' : 'No extensions installed'}
215
+ emptyAction={!search ? (
216
+ <button
217
+ onClick={() => setTab('swarmforge')}
218
+ 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"
219
+ style={{ fontFamily: 'inherit' }}
220
+ >
221
+ Browse SwarmForge
222
+ </button>
223
+ ) : undefined}
224
+ />
225
+ )}
226
+
227
+ {tab === 'swarmforge' && (
228
+ <MarketplaceTab
229
+ marketplace={marketplace}
230
+ loading={mpLoading}
231
+ installing={installing}
232
+ installedFilenames={installedFilenames}
233
+ search={search}
234
+ activeTag={activeTag}
235
+ setActiveTag={setActiveTag}
236
+ sort={sort}
237
+ setSort={setSort}
238
+ onInstall={installFromMarketplace}
239
+ />
240
+ )}
241
+
242
+ <ConfirmDialog
243
+ open={!!confirmDelete}
244
+ title="Delete Plugin"
245
+ message={confirmDelete ? `Delete "${confirmDelete.name}"? This cannot be undone.` : ''}
246
+ confirmLabel={deleting ? 'Deleting...' : 'Delete'}
247
+ danger
248
+ onConfirm={() => { void handleDeleteConfirm() }}
249
+ onCancel={() => { if (!deleting) setConfirmDelete(null) }}
250
+ />
251
+ </div>
252
+ )
253
+ }
254
+
255
+ // --- Sub-components ---
256
+
257
+ function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
258
+ return (
259
+ <div className="flex items-center gap-1.5">
260
+ <span className={`text-[18px] font-700 tabular-nums ${accent ? 'text-accent-bright' : 'text-text'}`}>
261
+ {value}
262
+ </span>
263
+ <span className="text-[11px] text-text-3/60 font-500">{label}</span>
264
+ </div>
265
+ )
266
+ }
267
+
268
+ function TabButton({ active, onClick, count, children }: {
269
+ active: boolean; onClick: () => void; count?: number; children: React.ReactNode
270
+ }) {
271
+ return (
272
+ <button
273
+ onClick={onClick}
274
+ className={`relative px-3 py-2 text-[12px] font-600 cursor-pointer transition-all border-none bg-transparent
275
+ ${active ? 'text-accent-bright' : 'text-text-3/60 hover:text-text-2'}`}
276
+ style={{ fontFamily: 'inherit' }}
277
+ >
278
+ <span className="flex items-center gap-1.5">
279
+ {children}
280
+ {count !== undefined && (
281
+ <span className={`text-[10px] tabular-nums px-1.5 py-px rounded-full ${
282
+ active ? 'bg-accent-soft text-accent-bright' : 'bg-white/[0.04] text-text-3/50'
283
+ }`}>
284
+ {count}
285
+ </span>
286
+ )}
287
+ </span>
288
+ {active && <div className="absolute bottom-0 left-2 right-2 h-[2px] rounded-full bg-accent-bright" />}
289
+ </button>
290
+ )
291
+ }
292
+
293
+ function pluginDescription(plugin: PluginMeta): string {
294
+ const raw = (plugin.description || '').trim()
295
+ if (raw) return raw
296
+ const sourceLabel = plugin.source === 'local' ? 'core plugin' : 'installed plugin'
297
+ return `No description provided. Click to view metadata and controls for this ${sourceLabel}.`
298
+ }
299
+
300
+ function pluginCapabilityBadges(plugin: PluginMeta): string[] {
301
+ const badges: string[] = []
302
+ if (plugin.toolCount && plugin.toolCount > 0) badges.push(`${plugin.toolCount} tool${plugin.toolCount === 1 ? '' : 's'}`)
303
+ if (plugin.hookCount && plugin.hookCount > 0) badges.push(`${plugin.hookCount} hook${plugin.hookCount === 1 ? '' : 's'}`)
304
+ if (plugin.hasUI) badges.push('UI')
305
+ if (plugin.providerCount && plugin.providerCount > 0) badges.push(`${plugin.providerCount} provider${plugin.providerCount === 1 ? '' : 's'}`)
306
+ if (plugin.connectorCount && plugin.connectorCount > 0) badges.push(`${plugin.connectorCount} connector${plugin.connectorCount === 1 ? '' : 's'}`)
307
+ return badges
308
+ }
309
+
310
+ // --- Installed plugins grid ---
311
+
312
+ function InstalledGrid({ plugins, allowDelete, search, agents, onEdit, onToggle, onDelete, onNavigateToAgent, emptyMessage, emptyAction }: {
313
+ plugins: PluginMeta[]
314
+ allowDelete: boolean
315
+ search: string
316
+ agents: Record<string, Agent>
317
+ onEdit: (filename: string) => void
318
+ onToggle: (e: React.MouseEvent, filename: string, enabled: boolean) => void
319
+ onDelete: (e: React.MouseEvent, filename: string, name: string) => void
320
+ onNavigateToAgent: (agentId: string) => void
321
+ emptyMessage: string
322
+ emptyAction?: React.ReactNode
323
+ }) {
324
+ if (plugins.length === 0) {
325
+ return (
326
+ <div className="text-center py-16">
327
+ <div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-white/[0.03] mb-3">
328
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-text-3/30">
329
+ <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" strokeLinecap="round" strokeLinejoin="round" />
330
+ </svg>
331
+ </div>
332
+ <p className="text-[13px] text-text-3/50">{emptyMessage}</p>
333
+ {emptyAction}
334
+ </div>
335
+ )
141
336
  }
142
337
 
143
- const renderInstalledPlugin = (plugin: (typeof pluginList)[number], allowDelete: boolean) => (
338
+ // Group enabled first, then disabled
339
+ const enabled = plugins.filter((p) => p.enabled)
340
+ const disabled = plugins.filter((p) => !p.enabled)
341
+ const sorted = [...enabled, ...disabled]
342
+
343
+ return (
344
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
345
+ {sorted.map((plugin) => (
346
+ <PluginCard
347
+ key={plugin.filename}
348
+ plugin={plugin}
349
+ allowDelete={allowDelete}
350
+ agents={agents}
351
+ onEdit={onEdit}
352
+ onToggle={onToggle}
353
+ onDelete={onDelete}
354
+ onNavigateToAgent={onNavigateToAgent}
355
+ highlight={search}
356
+ />
357
+ ))}
358
+ </div>
359
+ )
360
+ }
361
+
362
+ // --- Plugin card ---
363
+
364
+ function PluginCard({ plugin, allowDelete, agents, onEdit, onToggle, onDelete, onNavigateToAgent, highlight }: {
365
+ plugin: PluginMeta
366
+ allowDelete: boolean
367
+ agents: Record<string, Agent>
368
+ onEdit: (filename: string) => void
369
+ onToggle: (e: React.MouseEvent, filename: string, enabled: boolean) => void
370
+ onDelete: (e: React.MouseEvent, filename: string, name: string) => void
371
+ onNavigateToAgent: (agentId: string) => void
372
+ highlight: string
373
+ }) {
374
+ const badges = pluginCapabilityBadges(plugin)
375
+ const agent = plugin.createdByAgentId ? agents[plugin.createdByAgentId] : null
376
+
377
+ return (
144
378
  <div
145
- key={plugin.filename}
146
379
  role="button"
147
380
  tabIndex={0}
148
- onClick={() => handleEdit(plugin.filename)}
149
- onKeyDown={(e) => {
150
- if (e.key === 'Enter' || e.key === ' ') {
151
- e.preventDefault()
152
- handleEdit(plugin.filename)
153
- }
154
- }}
155
- className="w-full text-left p-4 rounded-[14px] border border-white/[0.06] bg-surface hover:bg-surface-2 transition-all cursor-pointer"
381
+ onClick={() => onEdit(plugin.filename)}
382
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onEdit(plugin.filename) } }}
383
+ className={`group relative text-left p-4 rounded-[14px] border transition-all cursor-pointer
384
+ ${plugin.enabled
385
+ ? 'border-white/[0.06] bg-surface hover:bg-surface-2 hover:border-white/[0.1]'
386
+ : 'border-white/[0.03] bg-surface/50 hover:bg-surface hover:border-white/[0.06] opacity-70 hover:opacity-100'
387
+ }`}
156
388
  >
157
- <div className="flex items-center justify-between mb-1">
389
+ {/* Top row: name + toggle */}
390
+ <div className="flex items-center justify-between gap-2 mb-1.5">
158
391
  <div className="flex items-center gap-2 min-w-0">
159
- {plugin.createdByAgentId && agents[plugin.createdByAgentId] && (
392
+ {agent && (
160
393
  <button
161
394
  type="button"
162
- title={`Created by ${agents[plugin.createdByAgentId].name} — click to open chat`}
163
- onClick={(e) => { e.stopPropagation(); navigateToAgentChat(plugin.createdByAgentId!) }}
395
+ title={`Created by ${agent.name}`}
396
+ onClick={(e) => { e.stopPropagation(); onNavigateToAgent(plugin.createdByAgentId!) }}
164
397
  className="shrink-0 rounded-full hover:ring-2 hover:ring-accent-bright/40 transition-all cursor-pointer bg-transparent border-none p-0"
165
398
  >
166
399
  <AgentAvatar
167
- seed={agents[plugin.createdByAgentId].avatarSeed || null}
168
- avatarUrl={agents[plugin.createdByAgentId].avatarUrl}
169
- name={agents[plugin.createdByAgentId].name || 'Agent'}
400
+ seed={agent.avatarSeed || null}
401
+ avatarUrl={agent.avatarUrl}
402
+ name={agent.name || 'Agent'}
170
403
  size={20}
171
404
  />
172
405
  </button>
173
406
  )}
174
- <span className="font-display text-[14px] font-600 text-text truncate">{plugin.name}</span>
407
+ <span className="font-display text-[14px] font-600 text-text truncate">
408
+ <HighlightText text={plugin.name} highlight={highlight} />
409
+ </span>
410
+ {plugin.version && (
411
+ <span className="text-[10px] font-mono text-text-3/40 shrink-0">v{plugin.version}</span>
412
+ )}
175
413
  </div>
176
- <div className="flex items-center gap-2 shrink-0 ml-2">
177
- {!inSidebar ? (
178
- <>
179
- <div
180
- onClick={(e) => handleToggle(e, plugin.filename, plugin.enabled)}
181
- className={`w-9 h-5 rounded-full transition-all relative cursor-pointer shrink-0
182
- ${plugin.enabled ? 'bg-accent-bright' : 'bg-white/[0.08]'}`}
183
- >
184
- <div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all
185
- ${plugin.enabled ? 'left-[18px]' : 'left-0.5'}`} />
186
- </div>
187
- {allowDelete && (
188
- <button
189
- onClick={(e) => handleDeleteClick(e, plugin.filename, plugin.name)}
190
- className="text-text-3/40 hover:text-red-400 transition-colors p-0.5"
191
- title="Delete"
192
- >
193
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
194
- <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" />
195
- </svg>
196
- </button>
197
- )}
198
- </>
199
- ) : (
200
- <span className={`text-[10px] font-600 px-1.5 py-0.5 rounded-full ${plugin.enabled ? 'text-emerald-400 bg-emerald-400/10' : 'text-text-3/50 bg-white/[0.04]'}`}>
201
- {plugin.enabled ? 'Enabled' : 'Disabled'}
202
- </span>
414
+ <div className="flex items-center gap-2 shrink-0">
415
+ <div
416
+ onClick={(e) => onToggle(e, plugin.filename, plugin.enabled)}
417
+ className={`w-9 h-5 rounded-full transition-all relative cursor-pointer shrink-0
418
+ ${plugin.enabled ? 'bg-accent-bright' : 'bg-white/[0.08]'}`}
419
+ >
420
+ <div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all
421
+ ${plugin.enabled ? 'left-[18px]' : 'left-0.5'}`} />
422
+ </div>
423
+ {allowDelete && (
424
+ <button
425
+ onClick={(e) => onDelete(e, plugin.filename, plugin.name)}
426
+ className="text-text-3/30 hover:text-red-400 transition-colors p-0.5 opacity-0 group-hover:opacity-100"
427
+ title="Delete"
428
+ >
429
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
430
+ <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" />
431
+ </svg>
432
+ </button>
203
433
  )}
204
434
  </div>
205
435
  </div>
206
- <div className="text-[11px] font-mono text-text-3/50 mb-1">{plugin.filename}</div>
207
- <p className="text-[12px] text-text-3/70 leading-relaxed">{pluginDescription(plugin)}</p>
208
- <div className="flex items-center gap-1.5 mt-2 flex-wrap">
209
- <span className={`text-[10px] font-600 px-1.5 py-0.5 rounded-full ${
210
- plugin.source === 'local' ? 'text-indigo-300 bg-indigo-500/10' : 'text-emerald-300 bg-emerald-500/10'
211
- }`}>
212
- {plugin.source === 'local' ? 'Core' : 'Extension'}
213
- </span>
214
- {pluginCapabilityBadges(plugin).map((badge) => (
215
- <span key={badge} className="text-[10px] font-600 px-1.5 py-0.5 rounded-full text-text-3/80 bg-white/[0.05]">
436
+
437
+ {/* Description */}
438
+ <p className="text-[12px] text-text-3/60 leading-relaxed line-clamp-2 mb-2.5">
439
+ {pluginDescription(plugin)}
440
+ </p>
441
+
442
+ {/* Badges */}
443
+ <div className="flex items-center gap-1.5 flex-wrap">
444
+ {badges.map((badge) => (
445
+ <span key={badge} className="text-[10px] font-600 px-1.5 py-0.5 rounded-full text-text-3/70 bg-white/[0.04]">
216
446
  {badge}
217
447
  </span>
218
448
  ))}
449
+ {plugin.author && (
450
+ <span className="text-[10px] text-text-3/40 ml-auto">
451
+ {plugin.author}
452
+ </span>
453
+ )}
219
454
  </div>
220
- {!inSidebar && (
221
- <p className="text-[10px] text-text-3/50 mt-2">Click for full details and controls</p>
222
- )}
455
+
456
+ {/* Failure warning */}
223
457
  {plugin.autoDisabled && (
224
- <p className="mt-1 text-[11px] text-amber-400/90 line-clamp-2">
458
+ <p className="mt-2 text-[11px] text-amber-400/90 line-clamp-2">
225
459
  Auto-disabled after {plugin.failureCount ?? 0} failures
226
460
  {plugin.lastFailureStage ? ` (${plugin.lastFailureStage})` : ''}.
227
461
  {plugin.lastFailureError ? ` ${plugin.lastFailureError}` : ''}
@@ -229,186 +463,173 @@ export function PluginList({ inSidebar }: { inSidebar?: boolean }) {
229
463
  )}
230
464
  </div>
231
465
  )
466
+ }
232
467
 
233
- // Marketplace tab content (full-width only)
234
- const renderMarketplace = () => {
235
- if (mpLoading) return <p className="text-[12px] text-text-3/70 py-8 text-center">Loading marketplace...</p>
236
- if (marketplace.length === 0) return <p className="text-[12px] text-text-3/70 py-8 text-center">No plugins available</p>
468
+ // --- Sidebar card (compact) ---
237
469
 
238
- const allTags = Array.from(new Set(marketplace.flatMap((p) => p.tags ?? []))).sort()
239
- const q = search.toLowerCase()
240
- const filtered = marketplace
241
- .filter((p) => {
242
- if (q && !p.name.toLowerCase().includes(q) && !p.description.toLowerCase().includes(q) && !(p.tags ?? []).some((t) => t.toLowerCase().includes(q))) return false
243
- if (activeTag && !(p.tags ?? []).includes(activeTag)) return false
244
- return true
245
- })
246
- .sort((a, b) => sort === 'downloads' ? (b.downloads ?? 0) - (a.downloads ?? 0) : a.name.localeCompare(b.name))
470
+ function SidebarPluginCard({ plugin, onEdit }: { plugin: PluginMeta; onEdit: (filename: string) => void }) {
471
+ return (
472
+ <div
473
+ role="button"
474
+ tabIndex={0}
475
+ onClick={() => onEdit(plugin.filename)}
476
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onEdit(plugin.filename) } }}
477
+ className="w-full text-left p-3 rounded-[12px] border border-white/[0.06] bg-surface hover:bg-surface-2 transition-all cursor-pointer"
478
+ >
479
+ <div className="flex items-center justify-between mb-0.5">
480
+ <span className="font-display text-[13px] font-600 text-text truncate">{plugin.name}</span>
481
+ <span className={`text-[10px] font-600 px-1.5 py-0.5 rounded-full ${
482
+ plugin.enabled ? 'text-emerald-400 bg-emerald-400/10' : 'text-text-3/50 bg-white/[0.04]'
483
+ }`}>
484
+ {plugin.enabled ? 'On' : 'Off'}
485
+ </span>
486
+ </div>
487
+ <p className="text-[11px] text-text-3/50 line-clamp-1">{pluginDescription(plugin)}</p>
488
+ </div>
489
+ )
490
+ }
491
+
492
+ // --- Highlight text helper ---
493
+
494
+ function HighlightText({ text, highlight }: { text: string; highlight: string }) {
495
+ if (!highlight.trim()) return <>{text}</>
496
+ const idx = text.toLowerCase().indexOf(highlight.toLowerCase())
497
+ if (idx === -1) return <>{text}</>
498
+ return (
499
+ <>
500
+ {text.slice(0, idx)}
501
+ <span className="text-accent-bright">{text.slice(idx, idx + highlight.length)}</span>
502
+ {text.slice(idx + highlight.length)}
503
+ </>
504
+ )
505
+ }
506
+
507
+ // --- Marketplace tab ---
508
+
509
+ function MarketplaceTab({ marketplace, loading, installing, installedFilenames, search, activeTag, setActiveTag, sort, setSort, onInstall }: {
510
+ marketplace: MarketplacePlugin[]
511
+ loading: boolean
512
+ installing: string | null
513
+ installedFilenames: Set<string>
514
+ search: string
515
+ activeTag: string | null
516
+ setActiveTag: (v: string | null) => void
517
+ sort: 'name' | 'downloads'
518
+ setSort: (v: 'name' | 'downloads') => void
519
+ onInstall: (p: MarketplacePlugin) => void
520
+ }) {
521
+ if (loading) return <p className="text-[12px] text-text-3/70 py-8 text-center">Loading marketplace...</p>
247
522
 
523
+ if (marketplace.length === 0) {
248
524
  return (
249
- <div className="space-y-3">
250
- <input
251
- value={search}
252
- onChange={(e) => setSearch(e.target.value)}
253
- placeholder="Search plugins..."
254
- className="w-full px-3 py-2.5 rounded-[10px] bg-surface border border-white/[0.06] text-[12px] text-text placeholder:text-text-3/50 outline-none focus:border-accent-bright/30"
255
- style={{ fontFamily: 'inherit' }}
256
- />
257
- <div className="flex items-center gap-1.5 flex-wrap">
258
- <button
259
- onClick={() => setActiveTag(null)}
260
- className={`px-2 py-1 rounded-[6px] text-[10px] font-600 cursor-pointer transition-all border-none ${
261
- !activeTag ? 'bg-accent-soft text-accent-bright' : 'bg-white/[0.03] text-text-3/60 hover:text-text-3'
262
- }`}
263
- >
264
- All
265
- </button>
266
- {allTags.map((t) => (
267
- <button
268
- key={t}
269
- onClick={() => setActiveTag(activeTag === t ? null : t)}
270
- className={`px-2 py-1 rounded-[6px] text-[10px] font-600 cursor-pointer transition-all border-none ${
271
- activeTag === t ? 'bg-accent-soft text-accent-bright' : 'bg-white/[0.03] text-text-3/60 hover:text-text-3'
272
- }`}
273
- >
274
- {t}
275
- </button>
276
- ))}
277
- <div className="flex-1" />
278
- <select
279
- value={sort}
280
- onChange={(e) => setSort(e.target.value as 'name' | 'downloads')}
281
- 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"
282
- style={{ fontFamily: 'inherit' }}
283
- >
284
- <option value="downloads">Popular</option>
285
- <option value="name">A-Z</option>
286
- </select>
525
+ <div className="text-center py-16">
526
+ <div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-white/[0.03] mb-3">
527
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-text-3/30">
528
+ <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" strokeLinecap="round" strokeLinejoin="round" />
529
+ <polyline points="9,22 9,12 15,12 15,22" strokeLinecap="round" strokeLinejoin="round" />
530
+ </svg>
287
531
  </div>
288
- {filtered.length === 0 ? (
289
- <p className="text-[12px] text-text-3/50 text-center py-4">No plugins match your search</p>
290
- ) : (
291
- <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
292
- {filtered.map((p) => {
293
- const isInstalled = installedFilenames.has(`${p.id}.js`)
294
- return (
295
- <div key={p.id} className="py-3.5 px-4 rounded-[14px] bg-surface border border-white/[0.06]">
296
- <div className="flex items-start gap-3">
297
- <div className="flex-1 min-w-0">
298
- <div className="flex items-center gap-2">
299
- <span className="text-[14px] font-600 text-text">{p.name}</span>
300
- <span className="text-[10px] font-mono text-text-3/70">v{p.version}</span>
301
- {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>}
302
- </div>
303
- <div className="text-[11px] text-text-3/60 mt-1 line-clamp-2">{p.description}</div>
304
- <div className="flex items-center gap-2 mt-2">
305
- <span className="text-[10px] text-text-3/70">by {p.author}</span>
306
- <span className="text-[10px] text-text-3/50">&middot;</span>
307
- {(p.tags ?? []).slice(0, 3).map((t) => (
308
- <button
309
- key={t}
310
- onClick={() => setActiveTag(activeTag === t ? null : t)}
311
- className={`text-[9px] font-600 px-1.5 py-0.5 rounded-full cursor-pointer transition-all border-none ${
312
- activeTag === t ? 'text-accent-bright bg-accent-soft' : 'text-text-3/50 bg-white/[0.04] hover:text-text-3'
313
- }`}
314
- >
315
- {t}
316
- </button>
317
- ))}
318
- </div>
319
- </div>
320
- <button
321
- onClick={() => !isInstalled && installFromMarketplace(p)}
322
- disabled={isInstalled || installing === p.id}
323
- className={`shrink-0 py-2 px-4 rounded-[10px] text-[12px] font-600 transition-all cursor-pointer
324
- ${isInstalled
325
- ? 'bg-white/[0.04] text-text-3/70 cursor-default'
326
- : installing === p.id
327
- ? 'bg-accent-soft text-accent-bright animate-pulse'
328
- : 'bg-accent-soft text-accent-bright hover:bg-accent-soft/80 border border-accent-bright/20'}`}
329
- style={{ fontFamily: 'inherit' }}
330
- >
331
- {isInstalled ? 'Installed' : installing === p.id ? 'Installing...' : 'Install'}
332
- </button>
333
- </div>
334
- </div>
335
- )
336
- })}
337
- </div>
338
- )}
532
+ <p className="text-[13px] text-text-3/50">No plugins available in the marketplace</p>
339
533
  </div>
340
534
  )
341
535
  }
342
536
 
537
+ const allTags = Array.from(new Set(marketplace.flatMap((p) => p.tags ?? []))).sort()
538
+ const q = search.toLowerCase()
539
+ const filtered = marketplace
540
+ .filter((p) => {
541
+ if (q && !p.name.toLowerCase().includes(q) && !p.description.toLowerCase().includes(q) && !(p.tags ?? []).some((t) => t.toLowerCase().includes(q))) return false
542
+ if (activeTag && !(p.tags ?? []).includes(activeTag)) return false
543
+ return true
544
+ })
545
+ .sort((a, b) => sort === 'downloads' ? (b.downloads ?? 0) - (a.downloads ?? 0) : a.name.localeCompare(b.name))
546
+
343
547
  return (
344
- <div className={`flex-1 overflow-y-auto ${inSidebar ? 'px-3 pb-4' : 'px-5 pb-6'}`}>
345
- {/* Tabs full-width only */}
346
- {!inSidebar && (
347
- <div className="flex gap-1 mb-4">
348
- <button onClick={() => setTab('installed')} className={tabClass('installed')} style={{ fontFamily: 'inherit' }}>
349
- Installed
350
- </button>
351
- <button onClick={() => setTab('marketplace')} className={tabClass('marketplace')} style={{ fontFamily: 'inherit' }}>
352
- SwarmForge
548
+ <div className="space-y-3">
549
+ {/* Tags + Sort */}
550
+ <div className="flex items-center gap-1.5 flex-wrap">
551
+ <button
552
+ onClick={() => setActiveTag(null)}
553
+ className={`px-2 py-1 rounded-[6px] text-[10px] font-600 cursor-pointer transition-all border-none ${
554
+ !activeTag ? 'bg-accent-soft text-accent-bright' : 'bg-white/[0.03] text-text-3/60 hover:text-text-3'
555
+ }`}
556
+ >
557
+ All
558
+ </button>
559
+ {allTags.map((t) => (
560
+ <button
561
+ key={t}
562
+ onClick={() => setActiveTag(activeTag === t ? null : t)}
563
+ className={`px-2 py-1 rounded-[6px] text-[10px] font-600 cursor-pointer transition-all border-none ${
564
+ activeTag === t ? 'bg-accent-soft text-accent-bright' : 'bg-white/[0.03] text-text-3/60 hover:text-text-3'
565
+ }`}
566
+ >
567
+ {t}
353
568
  </button>
354
- </div>
355
- )}
569
+ ))}
570
+ <div className="flex-1" />
571
+ <select
572
+ value={sort}
573
+ onChange={(e) => setSort(e.target.value as 'name' | 'downloads')}
574
+ 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"
575
+ style={{ fontFamily: 'inherit' }}
576
+ >
577
+ <option value="downloads">Popular</option>
578
+ <option value="name">A-Z</option>
579
+ </select>
580
+ </div>
356
581
 
357
- {(!inSidebar && tab === 'marketplace') ? renderMarketplace() : (
358
- pluginList.length === 0 ? (
359
- <div className="text-center py-12">
360
- <p className="text-[13px] text-text-3/60">No plugins installed</p>
361
- <button
362
- onClick={() => { setEditingPluginFilename(null); setPluginSheetOpen(true) }}
363
- className="mt-3 px-4 py-2 rounded-[10px] bg-transparent text-accent-bright text-[13px] font-600 cursor-pointer border border-accent-bright/20 hover:bg-accent-soft transition-all"
364
- style={{ fontFamily: 'inherit' }}
365
- >
366
- + Add Plugin
367
- </button>
368
- </div>
369
- ) : (
370
- inSidebar ? (
371
- <div className="space-y-2">
372
- {pluginList.map((plugin) => renderInstalledPlugin(plugin, plugin.source !== 'local'))}
373
- </div>
374
- ) : (
375
- <div className="space-y-6">
376
- {corePlugins.length > 0 && (
377
- <section>
378
- <div className="mb-3 px-1">
379
- <h3 className="text-[13px] font-700 text-text-2">Core Platform</h3>
380
- <p className="text-[12px] text-text-3/60 mt-0.5">Official SwarmClaw plugins shipped with the platform.</p>
381
- </div>
382
- <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
383
- {corePlugins.map((plugin) => renderInstalledPlugin(plugin, false))}
384
- </div>
385
- </section>
386
- )}
387
-
388
- {extensionPlugins.length > 0 && (
389
- <section>
390
- <div className="mb-3 px-1">
391
- <h3 className="text-[13px] font-700 text-text-2">Extensions</h3>
392
- <p className="text-[12px] text-text-3/60 mt-0.5">Marketplace and custom plugins installed by your team.</p>
393
- </div>
394
- <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
395
- {extensionPlugins.map((plugin) => renderInstalledPlugin(plugin, true))}
582
+ {filtered.length === 0 ? (
583
+ <p className="text-[12px] text-text-3/50 text-center py-4">No plugins match your search</p>
584
+ ) : (
585
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
586
+ {filtered.map((p) => {
587
+ const isInstalled = installedFilenames.has(`${p.id}.js`)
588
+ return (
589
+ <div key={p.id} className="py-3.5 px-4 rounded-[14px] bg-surface border border-white/[0.06]">
590
+ <div className="flex items-start gap-3">
591
+ <div className="flex-1 min-w-0">
592
+ <div className="flex items-center gap-2">
593
+ <span className="text-[14px] font-600 text-text">{p.name}</span>
594
+ <span className="text-[10px] font-mono text-text-3/70">v{p.version}</span>
595
+ {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>}
596
+ </div>
597
+ <div className="text-[11px] text-text-3/60 mt-1 line-clamp-2">{p.description}</div>
598
+ <div className="flex items-center gap-2 mt-2">
599
+ <span className="text-[10px] text-text-3/70">by {p.author}</span>
600
+ <span className="text-[10px] text-text-3/50">&middot;</span>
601
+ {(p.tags ?? []).slice(0, 3).map((t) => (
602
+ <button
603
+ key={t}
604
+ onClick={() => setActiveTag(activeTag === t ? null : t)}
605
+ className={`text-[9px] font-600 px-1.5 py-0.5 rounded-full cursor-pointer transition-all border-none ${
606
+ activeTag === t ? 'text-accent-bright bg-accent-soft' : 'text-text-3/50 bg-white/[0.04] hover:text-text-3'
607
+ }`}
608
+ >
609
+ {t}
610
+ </button>
611
+ ))}
612
+ </div>
396
613
  </div>
397
- </section>
398
- )}
399
- </div>
400
- )
401
- )
614
+ <button
615
+ onClick={() => !isInstalled && onInstall(p)}
616
+ disabled={isInstalled || installing === p.id}
617
+ className={`shrink-0 py-2 px-4 rounded-[10px] text-[12px] font-600 transition-all cursor-pointer
618
+ ${isInstalled
619
+ ? 'bg-white/[0.04] text-text-3/70 cursor-default'
620
+ : installing === p.id
621
+ ? 'bg-accent-soft text-accent-bright animate-pulse'
622
+ : 'bg-accent-soft text-accent-bright hover:bg-accent-soft/80 border border-accent-bright/20'}`}
623
+ style={{ fontFamily: 'inherit' }}
624
+ >
625
+ {isInstalled ? 'Installed' : installing === p.id ? 'Installing...' : 'Install'}
626
+ </button>
627
+ </div>
628
+ </div>
629
+ )
630
+ })}
631
+ </div>
402
632
  )}
403
- <ConfirmDialog
404
- open={!!confirmDelete}
405
- title="Delete Plugin"
406
- message={confirmDelete ? `Delete "${confirmDelete.name}"? This cannot be undone.` : ''}
407
- confirmLabel={deleting ? 'Deleting...' : 'Delete'}
408
- danger
409
- onConfirm={() => { void handleDeleteConfirm() }}
410
- onCancel={() => { if (!deleting) setConfirmDelete(null) }}
411
- />
412
633
  </div>
413
634
  )
414
635
  }