@agentprojectcontext/apx 1.60.0 → 1.61.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.
@@ -1,10 +1,12 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import useSWR from "swr";
3
3
  import {
4
- CheckCircle2, FlaskConical, Pencil, Plus, ScrollText, Terminal, Trash2, X, XCircle,
4
+ ChevronDown, FlaskConical, Globe, Network, Pencil, Plus, ScrollText,
5
+ Terminal, Trash2, Wrench, X, XCircle,
5
6
  } from "lucide-react";
6
- import { Mcps, Vars, type McpAddBody, type McpScope, type McpTestResult, type McpLogsResult, type VarsList } from "../../lib/api";
7
+ import { Mcps, Vars, type McpAddBody, type McpScope, type McpLogsResult, type VarsList } from "../../lib/api";
7
8
  import type { McpEntry } from "../../types/daemon";
9
+ import { cn } from "../../lib/cn";
8
10
  import { Section } from "../../components/Section";
9
11
  import { Badge, Button, Dialog, Empty, Field, Input, Loading, Switch } from "../../components/ui";
10
12
  import { Tip } from "../../components/ui/tip";
@@ -19,6 +21,31 @@ type DialogMode =
19
21
  | { kind: "new" }
20
22
  | { kind: "edit"; entry: McpEntry };
21
23
 
24
+ // Per-row live test state: mirrors PandaProject's MCP cards — a colored dot +
25
+ // tool count come from the last on-demand test (tools/list) of each server.
26
+ type McpResult = { busy?: boolean; ok?: boolean; error?: string; tools?: { name: string; description: string }[] };
27
+
28
+ // Short one-liner describing how a server connects (http url or stdio command),
29
+ // shown under the name like Panda's config summary. Prefers an explicit
30
+ // description in the raw config when present.
31
+ function mcpSummary(m: McpEntry): string {
32
+ const raw = (m as unknown as { raw?: Record<string, unknown> }).raw || {};
33
+ if (typeof raw.description === "string" && raw.description.trim()) return raw.description.trim();
34
+ if (m.transport === "http" && m.url) return m.url.length > 64 ? m.url.slice(0, 61) + "…" : m.url;
35
+ const cmd = [m.command, ...(m.args || [])].filter(Boolean).join(" ");
36
+ if (cmd) return "$ " + (cmd.length > 64 ? cmd.slice(0, 61) + "…" : cmd);
37
+ return "";
38
+ }
39
+
40
+ // Colored status dot: red on a failed test, emerald when tested-ok or enabled,
41
+ // muted when disabled, amber while testing.
42
+ function dotClass(enabled: boolean, res?: McpResult): string {
43
+ if (res?.busy) return "bg-amber-400 animate-pulse";
44
+ if (res?.ok === false) return "bg-red-400";
45
+ if (res?.ok) return "bg-emerald-400";
46
+ return enabled ? "bg-emerald-500/70" : "bg-slate-500";
47
+ }
48
+
22
49
  const SOURCE_TONE: Record<string, "info" | "muted" | "success"> = {
23
50
  apc: "info",
24
51
  runtime: "success",
@@ -51,7 +78,8 @@ export function McpsTab({ pid }: { pid: string }) {
51
78
  const vars = useSWR<VarsList>(`/projects/${pid}/vars`, () => Vars.list(pid));
52
79
  const [dialog, setDialog] = useState<DialogMode>(null);
53
80
  const [activeMcp, setActiveMcp] = useState<string | null>(null);
54
- const [testFor, setTestFor] = useState<{ name: string; result?: McpTestResult; busy?: boolean } | null>(null);
81
+ const [results, setResults] = useState<Record<string, McpResult>>({});
82
+ const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({});
55
83
 
56
84
  const varNames = useMemo(
57
85
  () => (vars.data ? Object.keys(vars.data.effective).sort() : []),
@@ -75,15 +103,20 @@ export function McpsTab({ pid }: { pid: string }) {
75
103
 
76
104
  const runTest = async (name: string) => {
77
105
  setActiveMcp(name);
78
- setTestFor({ name, busy: true });
106
+ setResults((prev) => ({ ...prev, [name]: { busy: true } }));
107
+ setExpandedTools((prev) => ({ ...prev, [name]: true }));
79
108
  try {
80
109
  const result = await Mcps.test(pid, name);
81
- setTestFor({ name, result });
110
+ setResults((prev) => ({ ...prev, [name]: { ok: result.ok, error: result.error, tools: result.tools } }));
82
111
  } catch (e: any) {
83
- setTestFor({ name, result: { ok: false, error: e?.message || "error" } });
112
+ setResults((prev) => ({ ...prev, [name]: { ok: false, error: e?.message || "error" } }));
84
113
  }
85
114
  };
86
115
 
116
+ const testedValues = Object.values(results);
117
+ const okCount = testedValues.filter((r) => r.ok).length;
118
+ const errCount = testedValues.filter((r) => r.ok === false).length;
119
+
87
120
  return (
88
121
  <div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
89
122
  <div className="lg:col-span-3">
@@ -117,11 +150,32 @@ export function McpsTab({ pid }: { pid: string }) {
117
150
  {list.isLoading && <Loading />}
118
151
  {!list.isLoading && (list.data?.length ?? 0) === 0 && <Empty>{t("project.mcps.empty")}</Empty>}
119
152
 
153
+ {(okCount > 0 || errCount > 0) && (
154
+ <div className="mb-2 flex items-center gap-4 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs">
155
+ {okCount > 0 && (
156
+ <span className="flex items-center gap-1.5 text-emerald-400">
157
+ <span className="h-2 w-2 rounded-full bg-emerald-400" /> {okCount} conectados
158
+ </span>
159
+ )}
160
+ {errCount > 0 && (
161
+ <span className="flex items-center gap-1.5 text-red-400">
162
+ <span className="h-2 w-2 rounded-full bg-red-400" /> {errCount} con error
163
+ </span>
164
+ )}
165
+ </div>
166
+ )}
167
+
120
168
  <ul className="space-y-2 text-sm">
121
169
  {(list.data || []).map((m) => {
122
170
  const writable = m.source === "apc" || m.source === "runtime" || m.source === "global";
123
171
  const scopeForRemove: McpScope = sourceToScope(m.source);
124
172
  const isActive = activeMcp === m.name;
173
+ const enabled = m.enabled !== false;
174
+ const res = results[m.name];
175
+ const tools = res?.tools || [];
176
+ const open = !!expandedTools[m.name];
177
+ const summary = mcpSummary(m);
178
+ const Icon = m.transport === "http" ? Globe : Network;
125
179
  return (
126
180
  <li
127
181
  key={`${m.source}-${m.name}`}
@@ -132,45 +186,79 @@ export function McpsTab({ pid }: { pid: string }) {
132
186
  onClick={() => setActiveMcp(m.name)}
133
187
  role="button"
134
188
  >
135
- <div className="flex flex-wrap items-center gap-3">
136
- <span className="font-medium">{m.name}</span>
137
- <Badge tone={SOURCE_TONE[m.source] ?? "muted"} >{sourceLabel(m.source)}</Badge>
138
- <span className="ml-auto text-xs text-muted-fg">
139
- {(m.transport || "stdio").toUpperCase()}
140
- </span>
141
- <div onClick={(e) => e.stopPropagation()}>
142
- <Switch
143
- checked={m.enabled !== false}
144
- onChange={() => toggleEnabled(m)}
145
- label=""
146
- />
189
+ <div className="flex items-start gap-3">
190
+ {/* Icon + colored status dot */}
191
+ <div className="relative mt-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg border border-border bg-background">
192
+ <Icon size={16} className="text-muted-fg" />
193
+ <span className={cn("absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-card", dotClass(enabled, res))} />
194
+ </div>
195
+
196
+ <div className="min-w-0 flex-1">
197
+ <div className="flex flex-wrap items-center gap-2">
198
+ <span className="font-medium">{m.name}</span>
199
+ <Badge tone={SOURCE_TONE[m.source] ?? "muted"}>{sourceLabel(m.source)}</Badge>
200
+ <span className="ml-auto text-xs text-muted-fg">{(m.transport || "stdio").toUpperCase()}</span>
201
+ <div onClick={(e) => e.stopPropagation()}>
202
+ <Switch checked={enabled} onChange={() => toggleEnabled(m)} label="" />
203
+ </div>
204
+ <Tip content={t("project.mcps.test_btn")}>
205
+ <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); runTest(m.name); }} aria-label={t("project.mcps.test_btn")}>
206
+ {res?.busy ? <FlaskConical size={13} className="animate-pulse" /> : <FlaskConical size={13} />}
207
+ </Button>
208
+ </Tip>
209
+ <Tip content={t("project.mcps.logs_btn")}>
210
+ <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); setActiveMcp(m.name); }} aria-label={t("project.mcps.logs_btn")}>
211
+ <ScrollText size={13} />
212
+ </Button>
213
+ </Tip>
214
+ {writable && (
215
+ <Tip content={t("project.mcps.edit_btn")}>
216
+ <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); setDialog({ kind: "edit", entry: m }); }} aria-label={t("project.mcps.edit_btn")}>
217
+ <Pencil size={13} />
218
+ </Button>
219
+ </Tip>
220
+ )}
221
+ {writable && (
222
+ <Button size="sm" variant="destructive" onClick={(e) => { e.stopPropagation(); remove(m.name, scopeForRemove); }}>
223
+ <Trash2 size={13} />
224
+ </Button>
225
+ )}
226
+ </div>
227
+
228
+ {summary && <p className="mt-0.5 truncate font-mono text-xs text-muted-fg">{summary}</p>}
229
+
230
+ {res?.ok === false && res.error && (
231
+ <p className="mt-1 flex items-start gap-1 text-xs text-red-400">
232
+ <XCircle size={12} className="mt-0.5 flex-shrink-0" /> <span className="break-words">{res.error}</span>
233
+ </p>
234
+ )}
235
+
236
+ {tools.length > 0 && (
237
+ <div className="mt-1.5" onClick={(e) => e.stopPropagation()}>
238
+ <button
239
+ type="button"
240
+ onClick={() => setExpandedTools((prev) => ({ ...prev, [m.name]: !prev[m.name] }))}
241
+ className="flex items-center gap-1 text-xs text-muted-fg transition-colors hover:text-fg"
242
+ >
243
+ <Wrench size={12} /> {tools.length} tools
244
+ <ChevronDown size={12} className={cn("transition-transform", open && "rotate-180")} />
245
+ </button>
246
+ {open && (
247
+ <div className="mt-1.5 flex flex-wrap gap-1.5">
248
+ {tools.slice(0, 40).map((tool) => (
249
+ <Tip key={tool.name} content={tool.description || "—"}>
250
+ <span className="inline-flex items-center gap-1 rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[10px] text-muted-fg">
251
+ <Terminal size={10} /> {tool.name}
252
+ </span>
253
+ </Tip>
254
+ ))}
255
+ {tools.length > 40 && <span className="text-[10px] text-muted-fg">… +{tools.length - 40}</span>}
256
+ </div>
257
+ )}
258
+ </div>
259
+ )}
147
260
  </div>
148
- <Tip content={t("project.mcps.test_btn")}>
149
- <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); runTest(m.name); }} aria-label={t("project.mcps.test_btn")}>
150
- <FlaskConical size={13} />
151
- </Button>
152
- </Tip>
153
- <Tip content={t("project.mcps.logs_btn")}>
154
- <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); setActiveMcp(m.name); }} aria-label={t("project.mcps.logs_btn")}>
155
- <ScrollText size={13} />
156
- </Button>
157
- </Tip>
158
- {writable && (
159
- <Tip content={t("project.mcps.edit_btn")}>
160
- <Button size="sm" variant="ghost" onClick={(e) => { e.stopPropagation(); setDialog({ kind: "edit", entry: m }); }} aria-label={t("project.mcps.edit_btn")}>
161
- <Pencil size={13} />
162
- </Button>
163
- </Tip>
164
- )}
165
- {writable && (
166
- <Button size="sm" variant="destructive" onClick={(e) => { e.stopPropagation(); remove(m.name, scopeForRemove); }}>
167
- <Trash2 size={13} />
168
- </Button>
169
- )}
170
261
  </div>
171
- {testFor?.name === m.name && (
172
- <TestResultRow result={testFor.result} busy={!!testFor.busy} onClose={() => setTestFor(null)} />
173
- )}
174
262
  </li>
175
263
  );
176
264
  })}
@@ -190,49 +278,12 @@ export function McpsTab({ pid }: { pid: string }) {
190
278
  </div>
191
279
 
192
280
  <div className="lg:col-span-1">
193
- <LogsPanel pid={pid} mcpName={activeMcp} runningTest={!!testFor?.busy} />
281
+ <LogsPanel pid={pid} mcpName={activeMcp} runningTest={!!(activeMcp && results[activeMcp]?.busy)} />
194
282
  </div>
195
283
  </div>
196
284
  );
197
285
  }
198
286
 
199
- function TestResultRow({
200
- result,
201
- busy,
202
- onClose,
203
- }: { result?: McpTestResult; busy: boolean; onClose: () => void }) {
204
- return (
205
- <div className="mt-2 rounded border border-border/60 bg-background/40 p-2 text-xs">
206
- <div className="mb-1 flex items-center gap-2">
207
- {busy && <span className="text-muted-fg">{t("project.mcps.testing")}</span>}
208
- {!busy && result?.ok && (
209
- <span className="flex items-center gap-1 text-emerald-400">
210
- <CheckCircle2 size={12} /> {t("project.mcps.test_ok", { n: String(result.tool_count ?? 0) })}
211
- </span>
212
- )}
213
- {!busy && result && !result.ok && (
214
- <span className="flex items-center gap-1 text-red-400">
215
- <XCircle size={12} /> {result.error}
216
- </span>
217
- )}
218
- <button className="ml-auto text-muted-fg hover:text-fg" onClick={onClose}>×</button>
219
- </div>
220
- {!busy && result?.ok && result.tools && result.tools.length > 0 && (
221
- <ul className="space-y-0.5 font-mono">
222
- {result.tools.slice(0, 10).map((tool) => (
223
- <li key={tool.name} className="truncate">
224
- <span className="text-primary">{tool.name}</span>
225
- {tool.description && <span className="ml-2 text-muted-fg">— {tool.description}</span>}
226
- </li>
227
- ))}
228
- {result.tools.length > 10 && (
229
- <li className="text-muted-fg">… +{result.tools.length - 10}</li>
230
- )}
231
- </ul>
232
- )}
233
- </div>
234
- );
235
- }
236
287
 
237
288
  // Right-side terminal-style live logs panel. Polls the daemon every 1.5s
238
289
  // while a test is running (or every 4s when idle and an MCP is pinned) so