@nastechai/agent 0.16.0 → 0.17.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.
Files changed (98) hide show
  1. package/eslint.config.js +23 -0
  2. package/index.html +24 -0
  3. package/package.json +54 -26
  4. package/package.json.bak +89 -0
  5. package/package.json.pub +88 -0
  6. package/src/App.tsx +1173 -0
  7. package/src/components/AuthWidget.tsx +150 -0
  8. package/src/components/AutoField.tsx +206 -0
  9. package/src/components/Backdrop.tsx +93 -0
  10. package/src/components/ChatSidebar.tsx +394 -0
  11. package/src/components/DeleteConfirmDialog.tsx +40 -0
  12. package/src/components/LanguageSwitcher.tsx +186 -0
  13. package/src/components/Markdown.tsx +383 -0
  14. package/src/components/ModelInfoCard.tsx +112 -0
  15. package/src/components/ModelPickerDialog.tsx +470 -0
  16. package/src/components/OAuthLoginModal.tsx +374 -0
  17. package/src/components/OAuthProvidersCard.tsx +287 -0
  18. package/src/components/PlatformsCard.tsx +97 -0
  19. package/src/components/ScheduleBuilder.tsx +273 -0
  20. package/src/components/SidebarFooter.tsx +42 -0
  21. package/src/components/SidebarStatusStrip.tsx +72 -0
  22. package/src/components/SlashPopover.tsx +171 -0
  23. package/src/components/ThemeSwitcher.tsx +243 -0
  24. package/src/components/ToolCall.tsx +228 -0
  25. package/src/components/ToolsetConfigDrawer.tsx +448 -0
  26. package/src/contexts/PageHeaderProvider.tsx +139 -0
  27. package/src/contexts/SystemActions.tsx +120 -0
  28. package/src/contexts/page-header-context.ts +12 -0
  29. package/src/contexts/system-actions-context.ts +18 -0
  30. package/src/contexts/usePageHeader.ts +10 -0
  31. package/src/contexts/useSystemActions.ts +15 -0
  32. package/src/hooks/useModalBehavior.ts +44 -0
  33. package/src/hooks/useSidebarStatus.ts +27 -0
  34. package/src/i18n/af.ts +702 -0
  35. package/src/i18n/context.tsx +123 -0
  36. package/src/i18n/de.ts +701 -0
  37. package/src/i18n/en.ts +708 -0
  38. package/src/i18n/es.ts +701 -0
  39. package/src/i18n/fr.ts +701 -0
  40. package/src/i18n/ga.ts +702 -0
  41. package/src/i18n/hu.ts +702 -0
  42. package/src/i18n/index.ts +2 -0
  43. package/src/i18n/it.ts +701 -0
  44. package/src/i18n/ja.ts +702 -0
  45. package/src/i18n/ko.ts +702 -0
  46. package/src/i18n/pt.ts +702 -0
  47. package/src/i18n/ru.ts +702 -0
  48. package/src/i18n/tr.ts +702 -0
  49. package/src/i18n/types.ts +710 -0
  50. package/src/i18n/uk.ts +702 -0
  51. package/src/i18n/zh-hant.ts +702 -0
  52. package/src/i18n/zh.ts +698 -0
  53. package/src/index.css +274 -0
  54. package/src/lib/api.ts +1585 -0
  55. package/src/lib/dashboard-flags.ts +15 -0
  56. package/src/lib/format.ts +9 -0
  57. package/src/lib/fuzzy.ts +192 -0
  58. package/src/lib/gatewayClient.ts +253 -0
  59. package/src/lib/nested.ts +23 -0
  60. package/src/lib/resolve-page-title.ts +41 -0
  61. package/src/lib/schedule.ts +382 -0
  62. package/src/lib/slashExec.ts +163 -0
  63. package/src/lib/utils.ts +35 -0
  64. package/src/main.tsx +25 -0
  65. package/src/pages/AnalyticsPage.tsx +601 -0
  66. package/src/pages/ChannelsPage.tsx +772 -0
  67. package/src/pages/ChatPage.tsx +889 -0
  68. package/src/pages/ConfigPage.tsx +660 -0
  69. package/src/pages/CronPage.tsx +524 -0
  70. package/src/pages/DocsPage.tsx +69 -0
  71. package/src/pages/EnvPage.tsx +918 -0
  72. package/src/pages/LogsPage.tsx +246 -0
  73. package/src/pages/McpPage.tsx +757 -0
  74. package/src/pages/ModelsPage.tsx +994 -0
  75. package/src/pages/PairingPage.tsx +276 -0
  76. package/src/pages/PluginsPage.tsx +580 -0
  77. package/src/pages/ProfilesPage.tsx +559 -0
  78. package/src/pages/SessionsPage.tsx +936 -0
  79. package/src/pages/SkillsPage.tsx +557 -0
  80. package/src/pages/SystemPage.tsx +1259 -0
  81. package/src/pages/WebhooksPage.tsx +483 -0
  82. package/src/plugins/PluginPage.tsx +64 -0
  83. package/src/plugins/index.ts +6 -0
  84. package/src/plugins/registry.ts +151 -0
  85. package/src/plugins/sdk.d.ts +160 -0
  86. package/src/plugins/slots.ts +199 -0
  87. package/src/plugins/types.ts +37 -0
  88. package/src/plugins/usePlugins.ts +133 -0
  89. package/src/themes/context.tsx +443 -0
  90. package/src/themes/fonts.ts +160 -0
  91. package/src/themes/index.ts +3 -0
  92. package/src/themes/presets.ts +477 -0
  93. package/src/themes/types.ts +187 -0
  94. package/tsconfig.app.json +34 -0
  95. package/tsconfig.json +7 -0
  96. package/tsconfig.node.json +26 -0
  97. package/vite.config.ts +124 -0
  98. package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
@@ -0,0 +1,757 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useState } from "react";
2
+ import { Package, Power, Server, Trash2, X, Zap } from "lucide-react";
3
+ import { Badge } from "@nastechai/ui/ui/components/badge";
4
+ import { Button } from "@nastechai/ui/ui/components/button";
5
+ import { Select, SelectOption } from "@nastechai/ui/ui/components/select";
6
+ import { Spinner } from "@nastechai/ui/ui/components/spinner";
7
+ import { H2 } from "@nastechai/ui/ui/components/typography/h2";
8
+ import { api } from "@/lib/api";
9
+ import type {
10
+ McpCatalogDiagnostic,
11
+ McpCatalogEntry,
12
+ McpServer,
13
+ McpServerCreate,
14
+ McpTestResult,
15
+ } from "@/lib/api";
16
+ import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
17
+ import { useToast } from "@nastechai/ui/hooks/use-toast";
18
+ import { useConfirmDelete } from "@nastechai/ui/hooks/use-confirm-delete";
19
+ import { useModalBehavior } from "@/hooks/useModalBehavior";
20
+ import { Toast } from "@nastechai/ui/ui/components/toast";
21
+ import { Card, CardContent } from "@nastechai/ui/ui/components/card";
22
+ import { Input } from "@nastechai/ui/ui/components/input";
23
+ import { Label } from "@nastechai/ui/ui/components/label";
24
+ import { usePageHeader } from "@/contexts/usePageHeader";
25
+ import { cn, themedBody } from "@/lib/utils";
26
+
27
+ type Transport = "http" | "stdio";
28
+
29
+ function truncateText(value: string, maxLength: number): string {
30
+ return value.length > maxLength ? value.slice(0, maxLength) + "..." : value;
31
+ }
32
+
33
+ function parseArgs(raw: string): string[] {
34
+ return raw
35
+ .split(/[\s,]+/)
36
+ .map((s) => s.trim())
37
+ .filter(Boolean);
38
+ }
39
+
40
+ function parseEnv(raw: string): Record<string, string> {
41
+ const env: Record<string, string> = {};
42
+ raw
43
+ .split("\n")
44
+ .map((line) => line.trim())
45
+ .filter(Boolean)
46
+ .forEach((line) => {
47
+ const idx = line.indexOf("=");
48
+ if (idx === -1) return;
49
+ const key = line.slice(0, idx).trim();
50
+ const value = line.slice(idx + 1).trim();
51
+ if (key) env[key] = value;
52
+ });
53
+ return env;
54
+ }
55
+
56
+ const TRANSPORT_TONE: Record<string, "success" | "warning" | "secondary"> = {
57
+ http: "success",
58
+ stdio: "warning",
59
+ unknown: "secondary",
60
+ };
61
+
62
+ export default function McpPage() {
63
+ const [servers, setServers] = useState<McpServer[]>([]);
64
+ const [catalog, setCatalog] = useState<McpCatalogEntry[]>([]);
65
+ const [diagnostics, setDiagnostics] = useState<McpCatalogDiagnostic[]>([]);
66
+ const [loading, setLoading] = useState(true);
67
+ const { toast, showToast } = useToast();
68
+ const { setEnd } = usePageHeader();
69
+
70
+ // Add server modal state
71
+ const [createModalOpen, setCreateModalOpen] = useState(false);
72
+ const [name, setName] = useState("");
73
+ const [transport, setTransport] = useState<Transport>("http");
74
+ const [url, setUrl] = useState("");
75
+ const [command, setCommand] = useState("");
76
+ const [args, setArgs] = useState("");
77
+ const [env, setEnv] = useState("");
78
+ const [creating, setCreating] = useState(false);
79
+ const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
80
+ const createModalRef = useModalBehavior({
81
+ open: createModalOpen,
82
+ onClose: closeCreateModal,
83
+ });
84
+
85
+ // Test results keyed by server name
86
+ const [testing, setTesting] = useState<string | null>(null);
87
+ const [testResults, setTestResults] = useState<
88
+ Record<string, McpTestResult>
89
+ >({});
90
+
91
+ // Enable/disable state
92
+ const [togglingName, setTogglingName] = useState<string | null>(null);
93
+ const [restartNote, setRestartNote] = useState<string | null>(null);
94
+
95
+ // Catalog install modal state
96
+ const [installEntry, setInstallEntry] = useState<McpCatalogEntry | null>(
97
+ null,
98
+ );
99
+ const [installEnv, setInstallEnv] = useState<Record<string, string>>({});
100
+ const [installingName, setInstallingName] = useState<string | null>(null);
101
+ const closeInstallModal = useCallback(() => setInstallEntry(null), []);
102
+ const installModalRef = useModalBehavior({
103
+ open: installEntry !== null,
104
+ onClose: closeInstallModal,
105
+ });
106
+
107
+ const loadServers = useCallback(() => {
108
+ return api
109
+ .getMcpServers()
110
+ .then((res) => setServers(res.servers))
111
+ .catch((e) => showToast(`Error: ${e}`, "error"));
112
+ }, [showToast]);
113
+
114
+ const loadCatalog = useCallback(() => {
115
+ return api
116
+ .getMcpCatalog()
117
+ .then((res) => {
118
+ setCatalog(res.entries);
119
+ setDiagnostics(res.diagnostics);
120
+ })
121
+ .catch((e) => showToast(`Error: ${e}`, "error"));
122
+ }, [showToast]);
123
+
124
+ useEffect(() => {
125
+ Promise.all([loadServers(), loadCatalog()]).finally(() =>
126
+ setLoading(false),
127
+ );
128
+ }, [loadServers, loadCatalog]);
129
+
130
+ const handleCreate = async () => {
131
+ if (!name.trim()) {
132
+ showToast("Name required", "error");
133
+ return;
134
+ }
135
+ if (transport === "http" && !url.trim()) {
136
+ showToast("URL required", "error");
137
+ return;
138
+ }
139
+ if (transport === "stdio" && !command.trim()) {
140
+ showToast("Command required", "error");
141
+ return;
142
+ }
143
+ setCreating(true);
144
+ try {
145
+ const body: McpServerCreate = { name: name.trim() };
146
+ if (transport === "http") {
147
+ body.url = url.trim();
148
+ } else {
149
+ body.command = command.trim();
150
+ const argList = parseArgs(args);
151
+ if (argList.length) body.args = argList;
152
+ }
153
+ const envMap = parseEnv(env);
154
+ if (Object.keys(envMap).length) body.env = envMap;
155
+
156
+ await api.addMcpServer(body);
157
+ showToast("Add ✓", "success");
158
+ setName("");
159
+ setUrl("");
160
+ setCommand("");
161
+ setArgs("");
162
+ setEnv("");
163
+ setTransport("http");
164
+ setCreateModalOpen(false);
165
+ loadServers();
166
+ } catch (e) {
167
+ showToast(`Failed to add: ${e}`, "error");
168
+ } finally {
169
+ setCreating(false);
170
+ }
171
+ };
172
+
173
+ const handleTest = async (server: McpServer) => {
174
+ setTesting(server.name);
175
+ try {
176
+ const result = await api.testMcpServer(server.name);
177
+ setTestResults((prev) => ({ ...prev, [server.name]: result }));
178
+ if (result.ok) {
179
+ showToast(`${server.name}: ${result.tools.length} tool(s)`, "success");
180
+ } else {
181
+ showToast(`${server.name}: ${result.error ?? "Failed"}`, "error");
182
+ }
183
+ } catch (e) {
184
+ showToast(`Error: ${e}`, "error");
185
+ } finally {
186
+ setTesting(null);
187
+ }
188
+ };
189
+
190
+ const handleToggleEnabled = async (server: McpServer) => {
191
+ const next = !server.enabled;
192
+ setTogglingName(server.name);
193
+ try {
194
+ await api.setMcpServerEnabled(server.name, next);
195
+ setServers((prev) =>
196
+ prev.map((s) =>
197
+ s.name === server.name ? { ...s, enabled: next } : s,
198
+ ),
199
+ );
200
+ setRestartNote(
201
+ "Enable/disable takes effect on the next gateway restart.",
202
+ );
203
+ } catch (e) {
204
+ showToast(`Error: ${e}`, "error");
205
+ } finally {
206
+ setTogglingName(null);
207
+ }
208
+ };
209
+
210
+ const serverDelete = useConfirmDelete({
211
+ onDelete: useCallback(
212
+ async (serverName: string) => {
213
+ try {
214
+ await api.removeMcpServer(serverName);
215
+ showToast(`Delete: "${truncateText(serverName, 30)}"`, "success");
216
+ setTestResults((prev) => {
217
+ const next = { ...prev };
218
+ delete next[serverName];
219
+ return next;
220
+ });
221
+ loadServers();
222
+ } catch (e) {
223
+ showToast(`Error: ${e}`, "error");
224
+ throw e;
225
+ }
226
+ },
227
+ [loadServers, showToast],
228
+ ),
229
+ });
230
+
231
+ // ── Catalog install ──────────────────────────────────────────────────
232
+ const runInstall = useCallback(
233
+ async (entry: McpCatalogEntry, envMap: Record<string, string>) => {
234
+ setInstallingName(entry.name);
235
+ try {
236
+ const res = await api.installMcpCatalogEntry(entry.name, envMap, true);
237
+ if (res.background) {
238
+ showToast("Installing in background…", "success");
239
+ } else {
240
+ showToast(`Installed: "${truncateText(entry.name, 30)}"`, "success");
241
+ }
242
+ setInstallEntry(null);
243
+ setInstallEnv({});
244
+ await Promise.all([loadServers(), loadCatalog()]);
245
+ } catch (e) {
246
+ showToast(`Failed to install: ${e}`, "error");
247
+ } finally {
248
+ setInstallingName(null);
249
+ }
250
+ },
251
+ [loadServers, loadCatalog, showToast],
252
+ );
253
+
254
+ const handleInstallClick = (entry: McpCatalogEntry) => {
255
+ if (entry.required_env.length > 0) {
256
+ const initial: Record<string, string> = {};
257
+ entry.required_env.forEach((item) => {
258
+ initial[item.name] = "";
259
+ });
260
+ setInstallEnv(initial);
261
+ setInstallEntry(entry);
262
+ } else {
263
+ void runInstall(entry, {});
264
+ }
265
+ };
266
+
267
+ const handleInstallSubmit = () => {
268
+ if (!installEntry) return;
269
+ const missing = installEntry.required_env.filter(
270
+ (item) => item.required && !(installEnv[item.name] ?? "").trim(),
271
+ );
272
+ if (missing.length > 0) {
273
+ showToast(`${missing[0].prompt} required`, "error");
274
+ return;
275
+ }
276
+ const envMap: Record<string, string> = {};
277
+ Object.entries(installEnv).forEach(([k, v]) => {
278
+ if (v.trim()) envMap[k] = v.trim();
279
+ });
280
+ void runInstall(installEntry, envMap);
281
+ };
282
+
283
+ // Put "Add Server" button in page header
284
+ useLayoutEffect(() => {
285
+ setEnd(
286
+ <Button
287
+ className="uppercase"
288
+ size="sm"
289
+ onClick={() => setCreateModalOpen(true)}
290
+ >
291
+ Add Server
292
+ </Button>,
293
+ );
294
+ return () => {
295
+ setEnd(null);
296
+ };
297
+ }, [setEnd, loading]);
298
+
299
+ if (loading) {
300
+ return (
301
+ <div className="flex items-center justify-center py-24">
302
+ <Spinner className="text-2xl text-primary" />
303
+ </div>
304
+ );
305
+ }
306
+
307
+ const diagnosticsByName: Record<string, McpCatalogDiagnostic[]> = {};
308
+ diagnostics.forEach((d) => {
309
+ (diagnosticsByName[d.name] ??= []).push(d);
310
+ });
311
+
312
+ return (
313
+ <div className="flex flex-col gap-6">
314
+ <Toast toast={toast} />
315
+
316
+ <DeleteConfirmDialog
317
+ open={serverDelete.isOpen}
318
+ onCancel={serverDelete.cancel}
319
+ onConfirm={serverDelete.confirm}
320
+ title="Remove MCP server"
321
+ description={
322
+ serverDelete.pendingId
323
+ ? `"${truncateText(serverDelete.pendingId, 40)}" — this will remove the server.`
324
+ : "This will remove the server."
325
+ }
326
+ loading={serverDelete.isDeleting}
327
+ />
328
+
329
+ {/* Add server modal */}
330
+ {createModalOpen && (
331
+ <div
332
+ ref={createModalRef}
333
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
334
+ onClick={(e) =>
335
+ e.target === e.currentTarget && setCreateModalOpen(false)
336
+ }
337
+ role="dialog"
338
+ aria-modal="true"
339
+ aria-labelledby="create-mcp-title"
340
+ >
341
+ <div
342
+ className={cn(
343
+ themedBody,
344
+ "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col",
345
+ )}
346
+ >
347
+ <Button
348
+ ghost
349
+ size="icon"
350
+ onClick={() => setCreateModalOpen(false)}
351
+ className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
352
+ aria-label="Close"
353
+ >
354
+ <X />
355
+ </Button>
356
+
357
+ <header className="p-5 pb-3 border-b border-border">
358
+ <h2
359
+ id="create-mcp-title"
360
+ className="font-mondwest text-display text-base tracking-wider"
361
+ >
362
+ Add MCP server
363
+ </h2>
364
+ </header>
365
+
366
+ <div className="p-5 grid gap-4">
367
+ <div className="grid gap-2">
368
+ <Label htmlFor="mcp-name">Name</Label>
369
+ <Input
370
+ id="mcp-name"
371
+ autoFocus
372
+ placeholder="my-server"
373
+ value={name}
374
+ onChange={(e) => setName(e.target.value)}
375
+ />
376
+ </div>
377
+
378
+ <div className="grid gap-2">
379
+ <Label htmlFor="mcp-transport">Transport</Label>
380
+ <Select
381
+ id="mcp-transport"
382
+ value={transport}
383
+ onValueChange={(v) => setTransport(v as Transport)}
384
+ >
385
+ <SelectOption value="http">HTTP/SSE</SelectOption>
386
+ <SelectOption value="stdio">stdio</SelectOption>
387
+ </Select>
388
+ </div>
389
+
390
+ {transport === "http" ? (
391
+ <div className="grid gap-2">
392
+ <Label htmlFor="mcp-url">URL</Label>
393
+ <Input
394
+ id="mcp-url"
395
+ placeholder="https://example.com/mcp"
396
+ value={url}
397
+ onChange={(e) => setUrl(e.target.value)}
398
+ />
399
+ </div>
400
+ ) : (
401
+ <>
402
+ <div className="grid gap-2">
403
+ <Label htmlFor="mcp-command">Command</Label>
404
+ <Input
405
+ id="mcp-command"
406
+ placeholder="npx"
407
+ value={command}
408
+ onChange={(e) => setCommand(e.target.value)}
409
+ />
410
+ </div>
411
+ <div className="grid gap-2">
412
+ <Label htmlFor="mcp-args">Args</Label>
413
+ <Input
414
+ id="mcp-args"
415
+ placeholder="-y @modelcontextprotocol/server-foo"
416
+ value={args}
417
+ onChange={(e) => setArgs(e.target.value)}
418
+ />
419
+ </div>
420
+ </>
421
+ )}
422
+
423
+ <div className="grid gap-2">
424
+ <Label htmlFor="mcp-env">Environment (KEY=VALUE per line)</Label>
425
+ <textarea
426
+ id="mcp-env"
427
+ className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
428
+ placeholder={"API_KEY=secret\nDEBUG=1"}
429
+ value={env}
430
+ onChange={(e) => setEnv(e.target.value)}
431
+ />
432
+ </div>
433
+
434
+ <div className="flex justify-end">
435
+ <Button
436
+ className="uppercase"
437
+ size="sm"
438
+ onClick={handleCreate}
439
+ disabled={creating}
440
+ prefix={creating ? <Spinner /> : undefined}
441
+ >
442
+ {creating ? "Adding..." : "Add"}
443
+ </Button>
444
+ </div>
445
+ </div>
446
+ </div>
447
+ </div>
448
+ )}
449
+
450
+ {/* Catalog install modal (required env vars) */}
451
+ {installEntry && (
452
+ <div
453
+ ref={installModalRef}
454
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
455
+ onClick={(e) =>
456
+ e.target === e.currentTarget && setInstallEntry(null)
457
+ }
458
+ role="dialog"
459
+ aria-modal="true"
460
+ aria-labelledby="install-mcp-title"
461
+ >
462
+ <div
463
+ className={cn(
464
+ themedBody,
465
+ "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col",
466
+ )}
467
+ >
468
+ <Button
469
+ ghost
470
+ size="icon"
471
+ onClick={() => setInstallEntry(null)}
472
+ className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
473
+ aria-label="Close"
474
+ >
475
+ <X />
476
+ </Button>
477
+
478
+ <header className="p-5 pb-3 border-b border-border">
479
+ <h2
480
+ id="install-mcp-title"
481
+ className="font-mondwest text-display text-base tracking-wider"
482
+ >
483
+ Install {installEntry.name}
484
+ </h2>
485
+ </header>
486
+
487
+ <div className="p-5 grid gap-4">
488
+ <p className="text-xs text-muted-foreground">
489
+ This MCP requires the following values to be configured.
490
+ </p>
491
+ {installEntry.required_env.map((item) => (
492
+ <div className="grid gap-2" key={item.name}>
493
+ <Label htmlFor={`install-env-${item.name}`}>
494
+ {item.prompt}
495
+ {item.required ? " *" : ""}
496
+ </Label>
497
+ <Input
498
+ id={`install-env-${item.name}`}
499
+ type="password"
500
+ placeholder={item.name}
501
+ value={installEnv[item.name] ?? ""}
502
+ onChange={(e) =>
503
+ setInstallEnv((prev) => ({
504
+ ...prev,
505
+ [item.name]: e.target.value,
506
+ }))
507
+ }
508
+ />
509
+ </div>
510
+ ))}
511
+
512
+ <div className="flex justify-end">
513
+ <Button
514
+ className="uppercase"
515
+ size="sm"
516
+ onClick={handleInstallSubmit}
517
+ disabled={installingName === installEntry.name}
518
+ prefix={
519
+ installingName === installEntry.name ? (
520
+ <Spinner />
521
+ ) : undefined
522
+ }
523
+ >
524
+ {installingName === installEntry.name
525
+ ? "Installing..."
526
+ : "Install"}
527
+ </Button>
528
+ </div>
529
+ </div>
530
+ </div>
531
+ </div>
532
+ )}
533
+
534
+ {/* ── Your MCP servers ── */}
535
+ <div className="flex flex-col gap-3">
536
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
537
+ <H2
538
+ variant="sm"
539
+ className="flex items-center gap-2 text-muted-foreground"
540
+ >
541
+ <Server className="h-4 w-4" />
542
+ Your MCP servers ({servers.length})
543
+ </H2>
544
+ </div>
545
+
546
+ {restartNote && (
547
+ <p className="text-xs text-warning">{restartNote}</p>
548
+ )}
549
+
550
+ {servers.length === 0 && (
551
+ <Card>
552
+ <CardContent className="py-8 text-center text-sm text-muted-foreground">
553
+ No MCP servers configured.
554
+ </CardContent>
555
+ </Card>
556
+ )}
557
+
558
+ {servers.map((server) => {
559
+ const envCount = Object.keys(server.env ?? {}).length;
560
+ const result = testResults[server.name];
561
+
562
+ return (
563
+ <Card key={server.name}>
564
+ <CardContent
565
+ className={cn(
566
+ "flex items-start gap-4 py-4",
567
+ !server.enabled && "opacity-60",
568
+ )}
569
+ >
570
+ <div className="flex-1 min-w-0">
571
+ <div className="flex items-center gap-2 mb-1">
572
+ <span className="font-medium text-sm truncate">
573
+ {server.name}
574
+ </span>
575
+ <Badge
576
+ tone={TRANSPORT_TONE[server.transport] ?? "secondary"}
577
+ >
578
+ {server.transport}
579
+ </Badge>
580
+ {!server.enabled && (
581
+ <Badge tone="outline">disabled</Badge>
582
+ )}
583
+ </div>
584
+ <div className="flex items-center gap-4 text-xs text-muted-foreground">
585
+ {server.transport === "http" ? (
586
+ <span className="font-mono truncate">
587
+ {server.url ?? "—"}
588
+ </span>
589
+ ) : (
590
+ <span className="font-mono truncate">
591
+ {[server.command, ...(server.args ?? [])]
592
+ .filter(Boolean)
593
+ .join(" ") || "—"}
594
+ </span>
595
+ )}
596
+ {envCount > 0 && (
597
+ <span>
598
+ {envCount} env var{envCount === 1 ? "" : "s"}
599
+ </span>
600
+ )}
601
+ </div>
602
+ {result && (
603
+ <div className="mt-2 text-xs">
604
+ {result.ok ? (
605
+ <p className="text-success">
606
+ {result.tools.length === 0
607
+ ? "Connected — no tools"
608
+ : `Tools: ${result.tools
609
+ .map((tool) => tool.name)
610
+ .join(", ")}`}
611
+ </p>
612
+ ) : (
613
+ <p className="text-destructive">
614
+ {result.error ?? "Connection failed"}
615
+ </p>
616
+ )}
617
+ </div>
618
+ )}
619
+ </div>
620
+
621
+ <div className="flex items-center gap-1 shrink-0">
622
+ <Button
623
+ ghost
624
+ size="sm"
625
+ title={server.enabled ? "Disable" : "Enable"}
626
+ aria-label={server.enabled ? "Disable" : "Enable"}
627
+ onClick={() => handleToggleEnabled(server)}
628
+ disabled={togglingName === server.name}
629
+ prefix={
630
+ togglingName === server.name ? (
631
+ <Spinner />
632
+ ) : (
633
+ <Power />
634
+ )
635
+ }
636
+ className={server.enabled ? "text-success" : undefined}
637
+ >
638
+ {server.enabled ? "Disable" : "Enable"}
639
+ </Button>
640
+
641
+ <Button
642
+ ghost
643
+ size="icon"
644
+ title="Test connection"
645
+ aria-label="Test connection"
646
+ onClick={() => handleTest(server)}
647
+ disabled={testing === server.name}
648
+ >
649
+ {testing === server.name ? <Spinner /> : <Zap />}
650
+ </Button>
651
+
652
+ <Button
653
+ ghost
654
+ destructive
655
+ size="icon"
656
+ title="Delete"
657
+ aria-label="Delete"
658
+ onClick={() => serverDelete.requestDelete(server.name)}
659
+ >
660
+ <Trash2 />
661
+ </Button>
662
+ </div>
663
+ </CardContent>
664
+ </Card>
665
+ );
666
+ })}
667
+ </div>
668
+
669
+ {/* ── Catalog ── */}
670
+ <div className="flex flex-col gap-3">
671
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
672
+ <H2
673
+ variant="sm"
674
+ className="flex items-center gap-2 text-muted-foreground"
675
+ >
676
+ <Package className="h-4 w-4" />
677
+ Catalog ({catalog.length})
678
+ </H2>
679
+ </div>
680
+
681
+ <p className="text-xs text-muted-foreground">
682
+ Browse Nous-approved MCP servers and install them with one click.
683
+ </p>
684
+
685
+ {catalog.length === 0 && (
686
+ <Card>
687
+ <CardContent className="py-8 text-center text-sm text-muted-foreground">
688
+ No catalog entries available.
689
+ </CardContent>
690
+ </Card>
691
+ )}
692
+
693
+ {catalog.map((entry) => {
694
+ const entryDiags = diagnosticsByName[entry.name] ?? [];
695
+ const isInstalling = installingName === entry.name;
696
+
697
+ return (
698
+ <Card key={entry.name}>
699
+ <CardContent className="flex items-start gap-4 py-4">
700
+ <div className="flex-1 min-w-0">
701
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
702
+ <span className="font-medium text-sm truncate">
703
+ {entry.name}
704
+ </span>
705
+ <Badge
706
+ tone={TRANSPORT_TONE[entry.transport] ?? "secondary"}
707
+ >
708
+ {entry.transport}
709
+ </Badge>
710
+ <Badge tone="outline">
711
+ {entry.source === "official" ? "official" : entry.source}
712
+ </Badge>
713
+ {entry.installed && (
714
+ <Badge tone="success">Installed</Badge>
715
+ )}
716
+ {entry.installed && !entry.enabled && (
717
+ <Badge tone="outline">disabled</Badge>
718
+ )}
719
+ </div>
720
+ {entry.description && (
721
+ <p className="text-xs text-muted-foreground">
722
+ {entry.description}
723
+ </p>
724
+ )}
725
+ {entryDiags.map((d, i) => (
726
+ <p
727
+ key={`${entry.name}-diag-${i}`}
728
+ className="text-xs text-warning mt-1"
729
+ >
730
+ {d.message}
731
+ </p>
732
+ ))}
733
+ </div>
734
+
735
+ <div className="flex items-center gap-1 shrink-0">
736
+ {entry.installed ? (
737
+ <Badge tone="success">Installed</Badge>
738
+ ) : (
739
+ <Button
740
+ className="uppercase"
741
+ size="sm"
742
+ onClick={() => handleInstallClick(entry)}
743
+ disabled={isInstalling}
744
+ prefix={isInstalling ? <Spinner /> : undefined}
745
+ >
746
+ {isInstalling ? "Installing..." : "Install"}
747
+ </Button>
748
+ )}
749
+ </div>
750
+ </CardContent>
751
+ </Card>
752
+ );
753
+ })}
754
+ </div>
755
+ </div>
756
+ );
757
+ }