@lamalibre/lamaste-agent-ui 4.0.0

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.
@@ -0,0 +1,866 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'react';
2
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
3
+ import {
4
+ Package,
5
+ Power,
6
+ PowerOff,
7
+ Trash2,
8
+ Download,
9
+ AlertCircle,
10
+ Play,
11
+ Square,
12
+ ExternalLink,
13
+ ChevronLeft,
14
+ ArrowUpCircle,
15
+ ChevronUp,
16
+ ChevronsUp,
17
+ X,
18
+ } from 'lucide-react';
19
+ import { Cpu, Terminal, Folder, Shield } from 'lucide-react';
20
+ import { useToast } from '../components/Toast.jsx';
21
+ import { useAgentClient } from '../context/AgentClientContext.jsx';
22
+ import { errorMessage } from '../lib/errorMessage.js';
23
+
24
+ // Curated plugin list — same as local plugins, filtered to agent-capable
25
+ const KNOWN_PLUGINS = [
26
+ {
27
+ name: 'herd',
28
+ packageName: '@lamalibre/herd-server',
29
+ description: 'Zero-config LLM inference pooling',
30
+ icon: 'cpu',
31
+ },
32
+ {
33
+ name: 'shell',
34
+ packageName: '@lamalibre/shell-server',
35
+ description: 'Secure remote terminal via tmux',
36
+ icon: 'terminal',
37
+ },
38
+ {
39
+ name: 'sync',
40
+ packageName: '@lamalibre/sync-server',
41
+ description: 'Bidirectional file sync',
42
+ icon: 'folder',
43
+ },
44
+ {
45
+ name: 'gate',
46
+ packageName: '@lamalibre/gate-server',
47
+ description: 'VPN tunnel management',
48
+ icon: 'shield',
49
+ },
50
+ ];
51
+
52
+ const ICON_MAP = {
53
+ cpu: Cpu,
54
+ terminal: Terminal,
55
+ folder: Folder,
56
+ shield: Shield,
57
+ package: Package,
58
+ };
59
+
60
+ function StatusBadge({ status }) {
61
+ const styles =
62
+ status === 'enabled' ? 'bg-green-500/20 text-green-400' : 'bg-zinc-500/20 text-zinc-400';
63
+
64
+ return (
65
+ <span className={`text-xs px-2 py-0.5 rounded-full ${styles}`}>
66
+ {status === 'enabled' ? 'Enabled' : 'Disabled'}
67
+ </span>
68
+ );
69
+ }
70
+
71
+ function AvailablePluginCard({ plugin, installed, onInstall, isInstalling }) {
72
+ const Icon = ICON_MAP[plugin.icon] || Package;
73
+
74
+ return (
75
+ <div className="rounded-lg bg-zinc-900 border border-zinc-800 p-5">
76
+ <div className="flex items-center gap-2 mb-2">
77
+ <Icon size={16} className="text-cyan-400" />
78
+ <span className="text-white font-semibold">{plugin.name}</span>
79
+ </div>
80
+ <p className="text-zinc-400 text-sm mb-3">{plugin.description}</p>
81
+ <p className="text-zinc-500 text-xs mb-4 font-mono">{plugin.packageName}</p>
82
+ {installed ? (
83
+ <span className="text-xs text-zinc-500 bg-zinc-800 px-3 py-1.5 rounded inline-block">
84
+ Installed
85
+ </span>
86
+ ) : (
87
+ <button
88
+ type="button"
89
+ disabled={isInstalling}
90
+ onClick={() => onInstall(plugin.packageName)}
91
+ className="flex items-center gap-1.5 rounded bg-cyan-600 px-3 py-1.5 text-xs text-white hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed"
92
+ >
93
+ <Download size={12} />
94
+ {isInstalling ? 'Installing...' : 'Install'}
95
+ </button>
96
+ )}
97
+ </div>
98
+ );
99
+ }
100
+
101
+ function getUpdateType(current, latest) {
102
+ const parse = (v) => (v || '0.0.0').split('.').map(Number);
103
+ const [cMaj, cMin] = parse(current);
104
+ const [lMaj, lMin] = parse(latest);
105
+ if (lMaj > cMaj) return 'major';
106
+ if (lMin > cMin) return 'minor';
107
+ return 'patch';
108
+ }
109
+
110
+ function UpdateIcon({ type, size = 14 }) {
111
+ if (type === 'major') return <ChevronsUp size={size} className="text-red-400" />;
112
+ if (type === 'minor') return <ChevronUp size={size} className="text-amber-400" />;
113
+ return <ArrowUpCircle size={size} className="text-cyan-400" />;
114
+ }
115
+
116
+ function UpdateDialog({ plugin, updateInfo, onUpdate, onClose, isUpdating }) {
117
+ const updateType = getUpdateType(updateInfo.currentVersion, updateInfo.latestVersion);
118
+ const typeLabel = { major: 'Major', minor: 'Minor', patch: 'Patch' }[updateType];
119
+ const typeColor = { major: 'text-red-400', minor: 'text-amber-400', patch: 'text-cyan-400' }[
120
+ updateType
121
+ ];
122
+
123
+ return (
124
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
125
+ <div className="w-full max-w-sm rounded-lg bg-zinc-900 border border-zinc-700 shadow-xl">
126
+ <div className="flex items-center justify-between p-4 border-b border-zinc-800">
127
+ <div className="flex items-center gap-2">
128
+ <UpdateIcon type={updateType} size={16} />
129
+ <span className="text-white font-semibold text-sm">{typeLabel} Update Available</span>
130
+ </div>
131
+ <button type="button" onClick={onClose} className="text-zinc-500 hover:text-zinc-300">
132
+ <X size={16} />
133
+ </button>
134
+ </div>
135
+
136
+ <div className="p-4 space-y-3">
137
+ <div className="flex items-center justify-between text-sm">
138
+ <span className="text-zinc-400">Plugin</span>
139
+ <span className="text-white">{plugin.displayName || plugin.name}</span>
140
+ </div>
141
+ <div className="flex items-center justify-between text-sm">
142
+ <span className="text-zinc-400">Current</span>
143
+ <span className="text-zinc-300 font-mono text-xs">v{updateInfo.currentVersion}</span>
144
+ </div>
145
+ <div className="flex items-center justify-between text-sm">
146
+ <span className="text-zinc-400">Latest</span>
147
+ <span className={`font-mono text-xs ${typeColor}`}>v{updateInfo.latestVersion}</span>
148
+ </div>
149
+ {plugin.status === 'enabled' && (
150
+ <p className="text-xs text-zinc-500">
151
+ The panel server will restart automatically after update.
152
+ </p>
153
+ )}
154
+ </div>
155
+
156
+ <div className="flex items-center justify-end gap-2 p-4 border-t border-zinc-800">
157
+ <button
158
+ type="button"
159
+ onClick={onClose}
160
+ className="rounded px-3 py-1.5 text-xs text-zinc-300 bg-zinc-700 hover:bg-zinc-600"
161
+ >
162
+ Cancel
163
+ </button>
164
+ <button
165
+ type="button"
166
+ disabled={isUpdating}
167
+ onClick={() => {
168
+ onUpdate(plugin.name);
169
+ onClose();
170
+ }}
171
+ className="rounded px-3 py-1.5 text-xs text-white bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed"
172
+ >
173
+ {isUpdating ? 'Updating...' : 'Update'}
174
+ </button>
175
+ </div>
176
+ </div>
177
+ </div>
178
+ );
179
+ }
180
+
181
+ // Default agent panel port — matches AGENT_PANEL_PORT in agents.rs
182
+ const AGENT_PANEL_DEFAULT_PORT = 9393;
183
+
184
+ // Theme override — maps Lamaste Desktop's zinc/cyan palette to sync-panel's
185
+ // custom CSS tokens (--color-surface, --color-card, etc.) so microfrontend
186
+ // plugins look native inside the host.
187
+ const HOST_THEME = {
188
+ surface: 'oklch(0.145 0.000 0)', // zinc-950
189
+ card: 'oklch(0.210 0.006 285.885)', // zinc-900
190
+ cardHover: 'oklch(0.274 0.006 286.033)', // zinc-800
191
+ border: 'oklch(0.274 0.006 286.033)', // zinc-800
192
+ accent: 'oklch(0.789 0.154 211.53)', // cyan-400
193
+ accentDim: 'oklch(0.609 0.126 211.53)', // cyan-500
194
+ textPrimary: 'oklch(1.000 0.000 0)', // white
195
+ textSecondary: 'oklch(0.552 0.016 285.938)', // zinc-400
196
+ success: 'oklch(0.723 0.191 149.579)', // green-400
197
+ warning: 'oklch(0.795 0.184 86.047)', // amber-400
198
+ error: 'oklch(0.637 0.237 25.331)', // red-400
199
+ };
200
+
201
+ function AgentPluginPanel({ pluginName, client, onBack, subPath, onPagesDiscovered }) {
202
+ const mountRef = useRef(null);
203
+ const cleanupRef = useRef(null);
204
+ const [error, setError] = useState(null);
205
+ const [loading, setLoading] = useState(true);
206
+ const [retryCount, setRetryCount] = useState(0);
207
+ const subPathRef = useRef(subPath || '');
208
+ const lastMountedSubPathRef = useRef(null);
209
+
210
+ // Keep subPathRef in sync for remounting
211
+ useEffect(() => {
212
+ subPathRef.current = subPath || '';
213
+ }, [subPath]);
214
+
215
+ useEffect(() => {
216
+ let cancelled = false;
217
+
218
+ function cleanupPlugin() {
219
+ if (cleanupRef.current) {
220
+ try {
221
+ cleanupRef.current();
222
+ } catch {
223
+ // best-effort cleanup
224
+ }
225
+ cleanupRef.current = null;
226
+ }
227
+ if (window.__lamalibrePlugins?.[pluginName]) {
228
+ delete window.__lamalibrePlugins[pluginName];
229
+ }
230
+ }
231
+
232
+ function mountPlugin() {
233
+ const pluginEntry = window.__lamalibrePlugins?.[pluginName];
234
+ if (!pluginEntry || typeof pluginEntry.mount !== 'function') {
235
+ setError('Plugin did not register a mount function');
236
+ setLoading(false);
237
+ return;
238
+ }
239
+
240
+ // Report pages metadata to the parent for sidebar injection
241
+ if (onPagesDiscovered && Array.isArray(pluginEntry.pages)) {
242
+ onPagesDiscovered(pluginEntry.pages);
243
+ }
244
+
245
+ try {
246
+ const isDesktop = !!window.__TAURI_INTERNALS__;
247
+ const panelUrl = isDesktop
248
+ ? `http://127.0.0.1:${AGENT_PANEL_DEFAULT_PORT}`
249
+ : window.location.origin;
250
+
251
+ const ctx = {
252
+ mountPoint: mountRef.current,
253
+ panelUrl,
254
+ // Plugins mount at /<name>/ on the agent daemon (matches both the
255
+ // desktop-loaded panel and the Authelia-protected plugin tunnel
256
+ // flow, where nginx rewrites herd.example.com/x -> :9393/herd/x).
257
+ basePath: `/${pluginName}`,
258
+ subPath: subPathRef.current,
259
+ theme: HOST_THEME,
260
+ };
261
+
262
+ const result = pluginEntry.mount(ctx);
263
+ if (result && typeof result === 'object' && typeof result.unmount === 'function') {
264
+ cleanupRef.current = result.unmount;
265
+ } else if (typeof result === 'function') {
266
+ cleanupRef.current = result;
267
+ }
268
+ lastMountedSubPathRef.current = subPathRef.current;
269
+
270
+ setLoading(false);
271
+ } catch (err) {
272
+ setError(`[mount] ${errorMessage(err)}`);
273
+ setLoading(false);
274
+ }
275
+ }
276
+
277
+ async function loadBundle() {
278
+ setError(null);
279
+ setLoading(true);
280
+ cleanupPlugin();
281
+
282
+ let bundle;
283
+ try {
284
+ bundle = await client.fetchAgentPluginBundle(pluginName);
285
+ } catch (err) {
286
+ if (!cancelled) {
287
+ const msg = typeof err === 'string' ? err : err?.message || String(err);
288
+ setError(`[fetch] ${msg}`);
289
+ setLoading(false);
290
+ }
291
+ return;
292
+ }
293
+
294
+ if (cancelled) return;
295
+
296
+ if (!bundle?.source) {
297
+ setError('[fetch] Bundle response was empty or invalid');
298
+ setLoading(false);
299
+ return;
300
+ }
301
+
302
+ try {
303
+ const evalFn = new Function(bundle.source);
304
+ evalFn();
305
+ } catch (err) {
306
+ if (!cancelled) {
307
+ setError(`[eval] ${errorMessage(err)}`);
308
+ setLoading(false);
309
+ }
310
+ return;
311
+ }
312
+ if (!cancelled) mountPlugin();
313
+ }
314
+
315
+ loadBundle();
316
+
317
+ return () => {
318
+ cancelled = true;
319
+ cleanupPlugin();
320
+ };
321
+ }, [pluginName, client, retryCount]);
322
+
323
+ // Re-mount when subPath changes (unmount old, mount new with updated subPath).
324
+ useEffect(() => {
325
+ if (loading || error) return;
326
+ // Skip if this subPath was already mounted by the initial loadBundle effect
327
+ if (lastMountedSubPathRef.current === (subPath || '')) return;
328
+
329
+ const pluginEntry = window.__lamalibrePlugins?.[pluginName];
330
+ if (!pluginEntry || typeof pluginEntry.mount !== 'function') return;
331
+
332
+ // Cleanup previous mount
333
+ if (cleanupRef.current) {
334
+ try {
335
+ cleanupRef.current();
336
+ } catch {
337
+ /* best-effort */
338
+ }
339
+ cleanupRef.current = null;
340
+ }
341
+
342
+ // Clear mount point
343
+ if (mountRef.current) {
344
+ mountRef.current.innerHTML = '';
345
+ }
346
+
347
+ try {
348
+ const isDesktop = !!window.__TAURI_INTERNALS__;
349
+ const panelUrl = isDesktop
350
+ ? `http://127.0.0.1:${AGENT_PANEL_DEFAULT_PORT}`
351
+ : window.location.origin;
352
+
353
+ const ctx = {
354
+ mountPoint: mountRef.current,
355
+ panelUrl,
356
+ // Plugins mount at /<name>/ on the agent daemon (see initial mount).
357
+ basePath: `/${pluginName}`,
358
+ subPath: subPath || '',
359
+ theme: HOST_THEME,
360
+ };
361
+
362
+ const result = pluginEntry.mount(ctx);
363
+ if (result && typeof result === 'object' && typeof result.unmount === 'function') {
364
+ cleanupRef.current = result.unmount;
365
+ } else if (typeof result === 'function') {
366
+ cleanupRef.current = result;
367
+ }
368
+ lastMountedSubPathRef.current = subPath || '';
369
+ } catch (err) {
370
+ // eslint-disable-next-line react-hooks/set-state-in-effect
371
+ setError(`[mount] ${errorMessage(err)}`);
372
+ }
373
+ }, [subPath]); // eslint-disable-line react-hooks/exhaustive-deps
374
+
375
+ return (
376
+ <div>
377
+ {!onPagesDiscovered && (
378
+ <button
379
+ type="button"
380
+ onClick={onBack}
381
+ className="flex items-center gap-1.5 text-sm text-zinc-400 hover:text-zinc-200 mb-4"
382
+ >
383
+ <ChevronLeft size={14} />
384
+ Back to Plugins
385
+ </button>
386
+ )}
387
+
388
+ {loading && <div className="text-zinc-500 text-sm">Loading plugin panel...</div>}
389
+
390
+ {error && (
391
+ <div className="rounded-lg bg-zinc-900 border border-red-800/50 p-4">
392
+ <div className="flex items-center gap-2 text-red-400 text-sm mb-3">
393
+ <AlertCircle size={16} />
394
+ {error}
395
+ </div>
396
+ <button
397
+ type="button"
398
+ onClick={() => setRetryCount((c) => c + 1)}
399
+ className="rounded bg-zinc-700 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-600"
400
+ >
401
+ Retry
402
+ </button>
403
+ </div>
404
+ )}
405
+
406
+ <div ref={mountRef} />
407
+ </div>
408
+ );
409
+ }
410
+
411
+ function PluginCard({
412
+ plugin,
413
+ onEnable,
414
+ onDisable,
415
+ onUninstall,
416
+ onOpen,
417
+ onUpdate,
418
+ updateInfo,
419
+ isActing,
420
+ isUpdating,
421
+ }) {
422
+ const [confirmUninstall, setConfirmUninstall] = useState(false);
423
+ const [showUpdateDialog, setShowUpdateDialog] = useState(false);
424
+
425
+ const updateType = updateInfo?.hasUpdate
426
+ ? getUpdateType(updateInfo.currentVersion, updateInfo.latestVersion)
427
+ : null;
428
+
429
+ return (
430
+ <div className="rounded-lg bg-zinc-900 border border-zinc-800 p-5">
431
+ <div className="flex items-center justify-between mb-3">
432
+ <div className="flex items-center gap-2">
433
+ <Package size={16} className="text-cyan-400" />
434
+ <span className="text-white font-semibold">{plugin.displayName || plugin.name}</span>
435
+ <span className="text-zinc-500 text-xs">v{plugin.version}</span>
436
+ {updateType && (
437
+ <button
438
+ type="button"
439
+ onClick={() => setShowUpdateDialog(true)}
440
+ title={`${updateType} update: v${updateInfo.latestVersion}`}
441
+ className="hover:opacity-80"
442
+ >
443
+ <UpdateIcon type={updateType} size={14} />
444
+ </button>
445
+ )}
446
+ </div>
447
+ <StatusBadge status={plugin.status} />
448
+ </div>
449
+
450
+ {plugin.displayName && <p className="text-zinc-500 text-xs font-mono mb-1">{plugin.name}</p>}
451
+
452
+ {plugin.description && <p className="text-zinc-400 text-sm mb-2">{plugin.description}</p>}
453
+
454
+ <p className="text-zinc-500 text-xs mb-4">
455
+ {plugin.packageName}
456
+ {plugin.installedAt && (
457
+ <span> &middot; Installed {new Date(plugin.installedAt).toLocaleDateString()}</span>
458
+ )}
459
+ {plugin.panel?.pages?.length > 0 && (
460
+ <span>
461
+ {' '}
462
+ &middot; {plugin.panel.pages.length} page{plugin.panel.pages.length > 1 ? 's' : ''}
463
+ </span>
464
+ )}
465
+ </p>
466
+
467
+ {plugin.capabilities && plugin.capabilities.length > 0 && (
468
+ <div className="flex flex-wrap gap-1 mb-4">
469
+ {plugin.capabilities.map((cap) => (
470
+ <span key={cap} className="text-xs bg-zinc-800 text-zinc-400 px-2 py-0.5 rounded">
471
+ {cap}
472
+ </span>
473
+ ))}
474
+ </div>
475
+ )}
476
+
477
+ <div className="flex flex-wrap items-center gap-2">
478
+ {plugin.status === 'disabled' ? (
479
+ <>
480
+ <button
481
+ type="button"
482
+ disabled={isActing}
483
+ onClick={() => onEnable(plugin.name)}
484
+ className="flex items-center gap-1.5 rounded bg-zinc-700 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-600 disabled:opacity-50 disabled:cursor-not-allowed"
485
+ >
486
+ <Power size={12} />
487
+ Enable
488
+ </button>
489
+ {confirmUninstall ? (
490
+ <span className="flex items-center gap-2 text-xs">
491
+ <span className="text-red-400">Uninstall?</span>
492
+ <button
493
+ type="button"
494
+ onClick={() => {
495
+ setConfirmUninstall(false);
496
+ onUninstall(plugin.name);
497
+ }}
498
+ className="rounded bg-red-600 px-2 py-1 text-white hover:bg-red-500"
499
+ >
500
+ Yes
501
+ </button>
502
+ <button
503
+ type="button"
504
+ onClick={() => setConfirmUninstall(false)}
505
+ className="rounded bg-zinc-700 px-2 py-1 text-zinc-300 hover:bg-zinc-600"
506
+ >
507
+ No
508
+ </button>
509
+ </span>
510
+ ) : (
511
+ <button
512
+ type="button"
513
+ disabled={isActing}
514
+ onClick={() => setConfirmUninstall(true)}
515
+ className="flex items-center gap-1.5 rounded bg-zinc-700 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-600 disabled:opacity-50 disabled:cursor-not-allowed"
516
+ >
517
+ <Trash2 size={12} />
518
+ Uninstall
519
+ </button>
520
+ )}
521
+ </>
522
+ ) : (
523
+ <>
524
+ <button
525
+ type="button"
526
+ disabled={isActing}
527
+ onClick={() => onDisable(plugin.name)}
528
+ className="flex items-center gap-1.5 rounded bg-zinc-700 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-600 disabled:opacity-50 disabled:cursor-not-allowed"
529
+ >
530
+ <PowerOff size={12} />
531
+ Disable
532
+ </button>
533
+ {plugin.packages?.server && (
534
+ <button
535
+ type="button"
536
+ onClick={() => onOpen(plugin.name)}
537
+ className="flex items-center gap-1.5 rounded bg-zinc-700 px-3 py-1.5 text-xs text-cyan-400 hover:bg-zinc-600"
538
+ >
539
+ <ExternalLink size={12} />
540
+ Open
541
+ </button>
542
+ )}
543
+ </>
544
+ )}
545
+ </div>
546
+
547
+ {showUpdateDialog && updateInfo?.hasUpdate && (
548
+ <UpdateDialog
549
+ plugin={plugin}
550
+ updateInfo={updateInfo}
551
+ onUpdate={onUpdate}
552
+ onClose={() => setShowUpdateDialog(false)}
553
+ isUpdating={isUpdating}
554
+ />
555
+ )}
556
+ </div>
557
+ );
558
+ }
559
+
560
+ function InstallForm({ onInstall, isInstalling }) {
561
+ const [packageName, setPackageName] = useState('');
562
+
563
+ const handleSubmit = (e) => {
564
+ e.preventDefault();
565
+ if (!packageName.trim()) return;
566
+ onInstall(packageName.trim());
567
+ setPackageName('');
568
+ };
569
+
570
+ return (
571
+ <form onSubmit={handleSubmit} className="flex items-end gap-3">
572
+ <div className="flex-1">
573
+ <label className="block text-xs text-zinc-400 mb-1" htmlFor="agent-plugin-package-name">
574
+ Package name
575
+ </label>
576
+ <input
577
+ id="agent-plugin-package-name"
578
+ type="text"
579
+ value={packageName}
580
+ onChange={(e) => setPackageName(e.target.value)}
581
+ placeholder="@lamalibre/..."
582
+ className="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-1.5 text-sm text-zinc-300 placeholder-zinc-600 focus:outline-none focus:border-cyan-400"
583
+ />
584
+ </div>
585
+ <button
586
+ type="submit"
587
+ disabled={isInstalling || !packageName.trim()}
588
+ className="flex items-center gap-1.5 rounded bg-cyan-600 px-4 py-1.5 text-sm text-white hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed"
589
+ >
590
+ <Download size={14} />
591
+ Install
592
+ </button>
593
+ </form>
594
+ );
595
+ }
596
+
597
+ function PanelNotRunning({ onStart, isStarting }) {
598
+ return (
599
+ <div className="rounded-lg bg-zinc-900 border border-zinc-800 p-8 text-center">
600
+ <AlertCircle size={32} className="text-zinc-500 mx-auto mb-4" />
601
+ <h2 className="text-white font-semibold mb-2">Agent Plugin Server Not Running</h2>
602
+ <p className="text-zinc-400 text-sm mb-6 max-w-md mx-auto">
603
+ The agent plugin server needs to be running to manage plugins. Start it to install, enable,
604
+ and configure plugins on this agent.
605
+ </p>
606
+ <button
607
+ type="button"
608
+ disabled={isStarting}
609
+ onClick={onStart}
610
+ className="inline-flex items-center gap-2 rounded bg-cyan-600 px-5 py-2 text-sm text-white hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed"
611
+ >
612
+ <Play size={16} />
613
+ {isStarting ? 'Starting...' : 'Start Plugin Server'}
614
+ </button>
615
+ </div>
616
+ );
617
+ }
618
+
619
+ export { AgentPluginPanel };
620
+
621
+ export default function AgentPluginsPage({ onOpenPlugin }) {
622
+ const client = useAgentClient();
623
+ const queryClient = useQueryClient();
624
+ const addToast = useToast();
625
+ const [openPlugin, setOpenPlugin] = useState(null);
626
+
627
+ // Fetch plugins directly — if the panel isn't running, the query will error
628
+ const pluginsQuery = useQuery({
629
+ queryKey: ['agent-plugins'],
630
+ queryFn: () => client.getAgentPlugins(),
631
+ refetchInterval: 10000,
632
+ retry: 1,
633
+ });
634
+
635
+ // Derive panel status from the plugins query
636
+ const panelRunning = pluginsQuery.isSuccess;
637
+
638
+ const startPanelMutation = useMutation({
639
+ mutationFn: () => client.startAgentPanel(),
640
+ onSuccess: () => {
641
+ queryClient.invalidateQueries({ queryKey: ['agent-plugins'] });
642
+ addToast('Plugin server started');
643
+ },
644
+ onError: (err) => addToast(errorMessage(err) || 'Failed to start plugin server', 'error'),
645
+ });
646
+
647
+ const stopPanelMutation = useMutation({
648
+ mutationFn: () => client.stopAgentPanel(),
649
+ onSuccess: () => {
650
+ queryClient.invalidateQueries({ queryKey: ['agent-plugins'] });
651
+ addToast('Plugin server stopped');
652
+ },
653
+ onError: (err) => addToast(errorMessage(err) || 'Failed to stop plugin server', 'error'),
654
+ });
655
+
656
+ // Check for updates on all installed plugins
657
+ const installedList = pluginsQuery.data?.plugins || [];
658
+ const updatesQuery = useQuery({
659
+ queryKey: [
660
+ 'agent-plugin-updates',
661
+ installedList.map((p) => `${p.name}@${p.version}`).join(','),
662
+ ],
663
+ queryFn: async () => {
664
+ const results = {};
665
+ await Promise.all(
666
+ installedList.map(async (p) => {
667
+ try {
668
+ results[p.name] = await client.checkAgentPluginUpdate(p.name);
669
+ } catch {
670
+ // Silently skip — network errors shouldn't block the UI
671
+ }
672
+ }),
673
+ );
674
+ return results;
675
+ },
676
+ enabled: installedList.length > 0,
677
+ refetchInterval: 60000,
678
+ staleTime: 30000,
679
+ });
680
+
681
+ const updateInfoMap = updatesQuery.data || {};
682
+
683
+ const installMutation = useMutation({
684
+ mutationFn: (packageName) => client.installAgentPlugin(packageName),
685
+ onSuccess: (data) => {
686
+ queryClient.invalidateQueries({ queryKey: ['agent-plugins'] });
687
+ addToast(`Plugin "${data.plugin?.name || 'unknown'}" installed`);
688
+ },
689
+ onError: (err) => addToast(errorMessage(err), 'error'),
690
+ });
691
+
692
+ const enableMutation = useMutation({
693
+ mutationFn: (name) => client.enableAgentPlugin(name),
694
+ onSuccess: (data) => {
695
+ queryClient.invalidateQueries({ queryKey: ['agent-plugins'] });
696
+ addToast(`Plugin "${data.name}" enabled — panel server is restarting...`);
697
+ },
698
+ onError: (err) => addToast(errorMessage(err), 'error'),
699
+ });
700
+
701
+ const disableMutation = useMutation({
702
+ mutationFn: (name) => client.disableAgentPlugin(name),
703
+ onSuccess: (data) => {
704
+ queryClient.invalidateQueries({ queryKey: ['agent-plugins'] });
705
+ addToast(`Plugin "${data.name}" disabled — panel server is restarting...`);
706
+ },
707
+ onError: (err) => addToast(errorMessage(err), 'error'),
708
+ });
709
+
710
+ const uninstallMutation = useMutation({
711
+ mutationFn: (name) => client.uninstallAgentPlugin(name),
712
+ onSuccess: (data) => {
713
+ queryClient.invalidateQueries({ queryKey: ['agent-plugins'] });
714
+ addToast(`Plugin "${data.name}" uninstalled`);
715
+ },
716
+ onError: (err) => addToast(errorMessage(err), 'error'),
717
+ });
718
+
719
+ const updateMutation = useMutation({
720
+ mutationFn: (name) => client.updateAgentPlugin(name),
721
+ onSuccess: (data) => {
722
+ queryClient.invalidateQueries({ queryKey: ['agent-plugins'] });
723
+ queryClient.invalidateQueries({ queryKey: ['agent-plugin-updates'] });
724
+ const p = data.plugin || data;
725
+ addToast(`Plugin "${p.name || 'unknown'}" updated to v${p.version || '?'}`);
726
+ },
727
+ onError: (err) => addToast(errorMessage(err), 'error'),
728
+ });
729
+
730
+ const isActing =
731
+ installMutation.isPending ||
732
+ enableMutation.isPending ||
733
+ disableMutation.isPending ||
734
+ uninstallMutation.isPending ||
735
+ updateMutation.isPending;
736
+
737
+ const handleInstall = useCallback(
738
+ (packageName) => installMutation.mutate(packageName),
739
+ [installMutation],
740
+ );
741
+
742
+ const handleEnable = useCallback((name) => enableMutation.mutate(name), [enableMutation]);
743
+
744
+ const handleDisable = useCallback((name) => disableMutation.mutate(name), [disableMutation]);
745
+
746
+ const handleUninstall = useCallback(
747
+ (name) => uninstallMutation.mutate(name),
748
+ [uninstallMutation],
749
+ );
750
+
751
+ const handleUpdate = useCallback((name) => updateMutation.mutate(name), [updateMutation]);
752
+
753
+ const plugins = pluginsQuery.data?.plugins;
754
+ const installedNames = new Set((plugins || []).map((p) => p.packageName));
755
+
756
+ // If a plugin panel is open, show it
757
+ if (openPlugin) {
758
+ return (
759
+ <div className="p-6 max-w-4xl mx-auto">
760
+ <AgentPluginPanel
761
+ pluginName={openPlugin}
762
+ client={client}
763
+ onBack={() => setOpenPlugin(null)}
764
+ subPath=""
765
+ />
766
+ </div>
767
+ );
768
+ }
769
+
770
+ return (
771
+ <div className="p-6 max-w-4xl mx-auto">
772
+ <div className="flex items-center justify-between mb-6">
773
+ <div>
774
+ <h1 className="text-xl font-bold text-white">Plugins</h1>
775
+ <p className="text-zinc-500 text-sm mt-1">Install, enable, and manage agent plugins</p>
776
+ </div>
777
+ {panelRunning && (
778
+ <div className="flex items-center gap-3">
779
+ <span className="flex items-center gap-1.5 text-xs text-green-400">
780
+ <span className="w-1.5 h-1.5 rounded-full bg-green-400" />
781
+ Server running
782
+ </span>
783
+ <button
784
+ type="button"
785
+ disabled={stopPanelMutation.isPending}
786
+ onClick={() => stopPanelMutation.mutate()}
787
+ className="flex items-center gap-1.5 rounded bg-zinc-700 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-600 disabled:opacity-50 disabled:cursor-not-allowed"
788
+ >
789
+ <Square size={10} />
790
+ {stopPanelMutation.isPending ? 'Stopping...' : 'Stop'}
791
+ </button>
792
+ </div>
793
+ )}
794
+ </div>
795
+
796
+ {/* Panel server not running */}
797
+ {pluginsQuery.isError ? (
798
+ <PanelNotRunning
799
+ onStart={() => startPanelMutation.mutate()}
800
+ isStarting={startPanelMutation.isPending}
801
+ />
802
+ ) : (
803
+ <>
804
+ {/* Custom install form */}
805
+ <div className="rounded-lg bg-zinc-900 border border-zinc-800 p-5 mb-8">
806
+ <h2 className="text-sm font-semibold text-zinc-300 mb-4">Install Custom Plugin</h2>
807
+ <InstallForm onInstall={handleInstall} isInstalling={installMutation.isPending} />
808
+ {installMutation.isPending && (
809
+ <p className="text-xs text-zinc-500 mt-2">Installing...</p>
810
+ )}
811
+ </div>
812
+
813
+ {/* Unified plugin grid: installed first, then available */}
814
+ <div className="mb-6">
815
+ <h2 className="text-sm font-semibold text-zinc-300 mb-3">Plugins</h2>
816
+ {pluginsQuery.isLoading ? (
817
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
818
+ {Array.from({ length: 4 }).map((_, i) => (
819
+ <div
820
+ key={i}
821
+ className="h-32 animate-pulse rounded-lg bg-zinc-900 border border-zinc-800"
822
+ />
823
+ ))}
824
+ </div>
825
+ ) : pluginsQuery.isError ? (
826
+ <div className="rounded-lg bg-zinc-900 border border-zinc-800 p-6">
827
+ <div className="flex items-center gap-2 text-red-400 text-sm">
828
+ <AlertCircle size={16} />
829
+ Failed to load plugins
830
+ </div>
831
+ </div>
832
+ ) : (
833
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
834
+ {/* Installed plugins first */}
835
+ {plugins?.map((plugin) => (
836
+ <PluginCard
837
+ key={plugin.name}
838
+ plugin={plugin}
839
+ onEnable={handleEnable}
840
+ onDisable={handleDisable}
841
+ onUninstall={handleUninstall}
842
+ onOpen={(name) => (onOpenPlugin ? onOpenPlugin(name) : setOpenPlugin(name))}
843
+ onUpdate={handleUpdate}
844
+ updateInfo={updateInfoMap[plugin.name]}
845
+ isActing={isActing}
846
+ isUpdating={updateMutation.isPending}
847
+ />
848
+ ))}
849
+ {/* Available but not yet installed */}
850
+ {KNOWN_PLUGINS.filter((kp) => !installedNames.has(kp.packageName)).map((plugin) => (
851
+ <AvailablePluginCard
852
+ key={plugin.name}
853
+ plugin={plugin}
854
+ installed={false}
855
+ onInstall={handleInstall}
856
+ isInstalling={installMutation.isPending}
857
+ />
858
+ ))}
859
+ </div>
860
+ )}
861
+ </div>
862
+ </>
863
+ )}
864
+ </div>
865
+ );
866
+ }