@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.
@@ -18,8 +18,8 @@
18
18
  <link rel="apple-touch-icon" href="/favicon/dark/apple-touch-icon.png" media="(prefers-color-scheme: dark)" />
19
19
  <link rel="manifest" href="/favicon/white/site.webmanifest" media="(prefers-color-scheme: light)" />
20
20
  <link rel="manifest" href="/favicon/dark/site.webmanifest" media="(prefers-color-scheme: dark)" />
21
- <script type="module" crossorigin src="/assets/index-DFNV6BWh.js"></script>
22
- <link rel="stylesheet" crossorigin href="/assets/index-HU-Wt2l9.css">
21
+ <script type="module" crossorigin src="/assets/index-DRFIAiiq.js"></script>
22
+ <link rel="stylesheet" crossorigin href="/assets/index-BuII-tAi.css">
23
23
  </head>
24
24
  <body class="bg-background text-foreground antialiased">
25
25
  <div id="root"></div>
@@ -0,0 +1,287 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import useSWR from "swr";
3
+ import { AlertCircle, CheckCircle2, ChevronDown, ExternalLink, Eye, EyeOff, Github, Loader2, WifiOff, X } from "lucide-react";
4
+ import { cn } from "../../lib/cn";
5
+ import { Integrations, type CatalogEntry, type IntegrationScope, type IntegrationStatus } from "../../lib/api";
6
+ import { PluginCard } from "./PluginCard";
7
+ import { PluginToolsSection } from "./PluginToolsSection";
8
+
9
+ // One generic component that renders any token-based plugin's config form from
10
+ // its `ui` descriptor (configFields + optional post-validate select). Asana and
11
+ // GitHub share it; each keeps its own independent config + credentials.
12
+
13
+ function AsanaLogo({ className }: { className?: string }) {
14
+ return (
15
+ <svg className={className} viewBox="0 0 24 24" fill="currentColor">
16
+ <path d="M18.833 9.637a4.167 4.167 0 1 1 0 8.333 4.167 4.167 0 0 1 0-8.333zm-13.666 0a4.167 4.167 0 1 1 0 8.333 4.167 4.167 0 0 1 0-8.333zM12 2a4.167 4.167 0 1 1 0 8.333A4.167 4.167 0 0 1 12 2z" />
17
+ </svg>
18
+ );
19
+ }
20
+
21
+ type Accent = { text: string; border: string; hover: string; ring: string; wrap: string };
22
+ const ACCENTS: Record<string, Accent> = {
23
+ rose: { text: "text-rose-400", border: "border-rose-700/50", hover: "hover:bg-rose-900/20", ring: "focus:border-rose-500/50", wrap: "border-rose-500/30 from-rose-500/20 to-pink-500/20" },
24
+ slate: { text: "text-slate-200", border: "border-slate-600/60", hover: "hover:bg-slate-700/30", ring: "focus:border-slate-400/60", wrap: "border-slate-500/30 from-slate-500/20 to-slate-700/20" },
25
+ };
26
+
27
+ function iconFor(slug: string, accent: Accent): ReactNode {
28
+ if (slug === "github") return <Github className={cn("h-6 w-6", accent.text)} />;
29
+ if (slug === "asana") return <AsanaLogo className={cn("h-6 w-6", accent.text)} />;
30
+ return <span className={cn("text-lg", accent.text)}>◆</span>;
31
+ }
32
+
33
+ type Step = "idle" | "saving" | "validating" | "done";
34
+ type Opt = { value: string; label: string };
35
+
36
+ export function PluginConnect({ pid, scope, entry }: { pid: string; scope: IntegrationScope; entry: CatalogEntry }) {
37
+ const ui = entry.ui!;
38
+ const accent = ACCENTS[ui.accent || "rose"] || ACCENTS.rose;
39
+
40
+ const [expanded, setExpanded] = useState(false);
41
+ const [values, setValues] = useState<Record<string, string>>({});
42
+ const [reveal, setReveal] = useState<Record<string, boolean>>({});
43
+ const [showHelp, setShowHelp] = useState<string | null>(null);
44
+ const [step, setStep] = useState<Step>("idle");
45
+ const [error, setError] = useState<string | null>(null);
46
+ const [selectOptions, setSelectOptions] = useState<Opt[]>([]);
47
+ const [selectValue, setSelectValue] = useState("");
48
+
49
+ const { data: status, mutate, isLoading } = useSWR<IntegrationStatus>(
50
+ `integration-status-${entry.slug}-${pid}-${scope}`,
51
+ () => Integrations.status(pid, entry.slug, scope),
52
+ { shouldRetryOnError: false },
53
+ );
54
+
55
+ const isActive = status?.status === "active" && status.is_enabled === true;
56
+ const busy = step === "saving" || step === "validating";
57
+ const showForm = !isActive && selectOptions.length === 0;
58
+
59
+ async function handleConnect() {
60
+ if (ui.configFields.some((f) => !values[f.key]?.trim())) return;
61
+ setStep("saving");
62
+ setError(null);
63
+ try {
64
+ await Integrations.configure(pid, entry.slug, scope, { ...values });
65
+ setStep("validating");
66
+ const result = (await Integrations.validate(pid, entry.slug, scope)) as unknown as Record<string, unknown>;
67
+ await mutate();
68
+ if (ui.select && !result[ui.select.key]) {
69
+ const data = (await Integrations.action(pid, entry.slug, ui.select.action, scope)) as Record<string, unknown>;
70
+ const list = (data[ui.select.listKey] as Record<string, unknown>[]) || [];
71
+ if (list.length > 1) {
72
+ setSelectOptions(list.map((o) => ({ value: String(o[ui.select!.valueKey]), label: String(o[ui.select!.labelKey]) })));
73
+ }
74
+ }
75
+ setStep("done");
76
+ setValues({});
77
+ } catch (err) {
78
+ setError(err instanceof Error ? err.message : "Error al conectar");
79
+ setStep("idle");
80
+ }
81
+ }
82
+
83
+ async function handleSelect() {
84
+ if (!selectValue || !ui.select) return;
85
+ setStep("saving");
86
+ setError(null);
87
+ try {
88
+ await Integrations.configure(pid, entry.slug, scope, { [ui.select.key]: selectValue });
89
+ await Integrations.validate(pid, entry.slug, scope);
90
+ await mutate();
91
+ setSelectOptions([]);
92
+ setStep("done");
93
+ } catch (err) {
94
+ setError(err instanceof Error ? err.message : "Error al seleccionar");
95
+ setStep("idle");
96
+ }
97
+ }
98
+
99
+ async function handleDeactivate() {
100
+ setError(null);
101
+ try {
102
+ await Integrations.deactivate(pid, entry.slug, scope);
103
+ await mutate();
104
+ } catch (err) {
105
+ setError(err instanceof Error ? err.message : "Error al desactivar");
106
+ }
107
+ }
108
+
109
+ return (
110
+ <PluginCard
111
+ icon={
112
+ <div className={cn("flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl border bg-gradient-to-br", accent.wrap)}>
113
+ {iconFor(entry.slug, accent)}
114
+ </div>
115
+ }
116
+ title={entry.name}
117
+ description={entry.description}
118
+ hasTools={(entry.tools?.length ?? 0) > 0}
119
+ badges={
120
+ <span
121
+ className={cn(
122
+ "flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px]",
123
+ isActive ? "border-emerald-700 bg-emerald-900/20 text-emerald-400" : "border-border bg-muted text-muted-foreground",
124
+ )}
125
+ >
126
+ <span className={cn("h-1.5 w-1.5 rounded-full", isActive ? "bg-emerald-400" : "bg-muted-foreground")} />
127
+ {isLoading ? "..." : isActive ? "Activo" : status?.status === "error" ? "Error" : "No configurado"}
128
+ </span>
129
+ }
130
+ expanded={expanded}
131
+ onToggle={() => setExpanded((v) => !v)}
132
+ >
133
+ <div className="space-y-4 p-4">
134
+ {error && (
135
+ <div className="flex items-center gap-2 rounded-lg border border-red-700/30 bg-red-900/20 px-3 py-2.5 text-xs text-red-300">
136
+ <AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />
137
+ <span className="flex-1">{error}</span>
138
+ <button onClick={() => setError(null)}><X className="h-3.5 w-3.5" /></button>
139
+ </div>
140
+ )}
141
+
142
+ {/* Connected summary */}
143
+ {isActive && selectOptions.length === 0 && (
144
+ <div className="space-y-1 rounded-xl border border-emerald-700/30 bg-emerald-900/10 p-3">
145
+ <div className="flex items-center gap-2">
146
+ <CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
147
+ <span className="text-xs font-medium text-emerald-300">Conectado</span>
148
+ </div>
149
+ {(ui.connectedFields || []).map((f) => {
150
+ const v = status?.[f.key];
151
+ if (!v) return null;
152
+ return (
153
+ <p key={f.key} className="pl-5 text-[10px] text-muted-foreground">
154
+ {f.label}: {String(v)}
155
+ </p>
156
+ );
157
+ })}
158
+ </div>
159
+ )}
160
+
161
+ {/* Post-validate selection (e.g. Asana workspace) */}
162
+ {selectOptions.length > 1 && ui.select && (
163
+ <div className="space-y-2">
164
+ <p className="text-[11px] text-muted-foreground">{ui.select.label}:</p>
165
+ <div className="flex gap-2">
166
+ <select
167
+ value={selectValue}
168
+ onChange={(e) => setSelectValue(e.target.value)}
169
+ className={cn("flex-1 rounded-lg border border-border bg-background px-2 py-1.5 text-xs outline-none", accent.ring)}
170
+ >
171
+ <option value="">Seleccionar...</option>
172
+ {selectOptions.map((o) => (
173
+ <option key={o.value} value={o.value}>{o.label}</option>
174
+ ))}
175
+ </select>
176
+ <button
177
+ onClick={handleSelect}
178
+ disabled={!selectValue || busy}
179
+ className={cn("rounded-lg border px-3 py-1.5 text-xs transition-all disabled:cursor-not-allowed disabled:opacity-50", accent.border, accent.text, accent.hover)}
180
+ >
181
+ {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Confirmar"}
182
+ </button>
183
+ </div>
184
+ </div>
185
+ )}
186
+
187
+ {/* Config form */}
188
+ {showForm && (
189
+ <div className="space-y-3">
190
+ <p className="text-xs font-semibold text-foreground">Credenciales {entry.name}</p>
191
+ {ui.configFields.map((field) => (
192
+ <div key={field.key} className="space-y-2">
193
+ {field.help && (
194
+ <div className="overflow-hidden rounded-lg border border-border">
195
+ <button
196
+ type="button"
197
+ onClick={() => setShowHelp((v) => (v === field.key ? null : field.key))}
198
+ className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/40"
199
+ >
200
+ <span className="text-[11px] text-muted-foreground">
201
+ {field.help.label} ·{" "}
202
+ <a
203
+ href={field.help.url}
204
+ target="_blank"
205
+ rel="noreferrer"
206
+ onClick={(e) => e.stopPropagation()}
207
+ className={cn("inline-flex items-center gap-0.5 hover:underline", accent.text)}
208
+ >
209
+ {field.help.urlLabel} <ExternalLink className="h-2.5 w-2.5" />
210
+ </a>
211
+ </span>
212
+ <ChevronDown className={cn("h-3.5 w-3.5 flex-shrink-0 text-muted-foreground transition-transform", showHelp === field.key && "rotate-180")} />
213
+ </button>
214
+ {showHelp === field.key && (
215
+ <div className="space-y-1.5 border-t border-border px-3 pb-3 pt-2.5">
216
+ {field.help.steps.map((s, i) => (
217
+ <div key={i} className="flex items-start gap-2">
218
+ <span className={cn("mt-0.5 flex-shrink-0 font-mono text-[10px]", accent.text)}>{i + 1}.</span>
219
+ <p className="text-[11px] text-muted-foreground">{s}</p>
220
+ </div>
221
+ ))}
222
+ </div>
223
+ )}
224
+ </div>
225
+ )}
226
+ <div>
227
+ <label className="mb-1 block text-[10px] text-muted-foreground">{field.label}</label>
228
+ <div className="relative">
229
+ <input
230
+ type={field.type === "password" && !reveal[field.key] ? "password" : "text"}
231
+ placeholder={field.placeholder}
232
+ value={values[field.key] || ""}
233
+ onChange={(e) => setValues((v) => ({ ...v, [field.key]: e.target.value }))}
234
+ onKeyDown={(e) => e.key === "Enter" && handleConnect()}
235
+ className={cn("w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none placeholder:text-muted-foreground/60", field.type === "password" && "pr-14", accent.ring)}
236
+ />
237
+ {field.type === "password" && (
238
+ <button
239
+ type="button"
240
+ onClick={() => setReveal((r) => ({ ...r, [field.key]: !r[field.key] }))}
241
+ className="absolute right-2.5 top-1/2 flex -translate-y-1/2 items-center gap-0.5 text-[10px] text-muted-foreground transition-colors hover:text-foreground"
242
+ >
243
+ {reveal[field.key] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
244
+ {reveal[field.key] ? "Ocultar" : "Ver"}
245
+ </button>
246
+ )}
247
+ </div>
248
+ </div>
249
+ </div>
250
+ ))}
251
+
252
+ {step === "validating" && (
253
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
254
+ <Loader2 className="h-3.5 w-3.5 animate-spin" /> Verificando...
255
+ </div>
256
+ )}
257
+
258
+ <button
259
+ onClick={handleConnect}
260
+ disabled={ui.configFields.some((f) => !values[f.key]?.trim()) || busy}
261
+ className={cn("flex w-full items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-xs transition-all disabled:cursor-not-allowed disabled:opacity-50", accent.border, accent.text, accent.hover)}
262
+ >
263
+ {busy ? (
264
+ <><Loader2 className="h-3.5 w-3.5 animate-spin" />{step === "saving" ? "Guardando..." : "Validando..."}</>
265
+ ) : "Conectar"}
266
+ </button>
267
+ </div>
268
+ )}
269
+
270
+ {entry.tools && entry.tools.length > 0 && (
271
+ <PluginToolsSection pid={pid} tools={entry.tools} isActive={isActive} />
272
+ )}
273
+
274
+ {isActive && (
275
+ <div className="flex justify-end border-t border-border pt-2">
276
+ <button
277
+ onClick={handleDeactivate}
278
+ className="flex items-center gap-1.5 rounded-lg border border-red-700/50 px-3 py-1.5 text-xs text-red-400 transition-all hover:bg-red-900/20"
279
+ >
280
+ <WifiOff className="h-3.5 w-3.5" /> Desactivar
281
+ </button>
282
+ </div>
283
+ )}
284
+ </div>
285
+ </PluginCard>
286
+ );
287
+ }
@@ -97,7 +97,8 @@ export function SkillsInspectorPanel() {
97
97
  };
98
98
 
99
99
  return (
100
- <div className="grid gap-6 xl:grid-cols-2 xl:items-start">
100
+ <div className="space-y-6">
101
+ <div className="grid gap-6 lg:grid-cols-2 lg:items-start">
101
102
  <Section
102
103
  title={t("settings_ui.inspector_title")}
103
104
  description={t("settings_ui.inspector_desc")}
@@ -142,31 +143,6 @@ export function SkillsInspectorPanel() {
142
143
  </div>
143
144
  </Section>
144
145
 
145
- <Section
146
- title={t("settings_ui.thresholds_title")}
147
- description={t("settings_ui.thresholds_desc")}
148
- >
149
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
150
- {knobs().map((k) => (
151
- <Field key={k.key} label={k.label} hint={k.hint}>
152
- <Input
153
- type="number"
154
- step={k.step}
155
- min={k.min}
156
- max={k.max}
157
- defaultValue={String(cfg[k.key])}
158
- disabled={busy}
159
- onBlur={(ev) => {
160
- const n = Number(ev.target.value);
161
- if (Number.isFinite(n) && n !== cfg[k.key]) apply({ [k.key]: n });
162
- }}
163
- className="max-w-[12rem]"
164
- />
165
- </Field>
166
- ))}
167
- </div>
168
- </Section>
169
-
170
146
  <Section
171
147
  title={t("settings_ui.test_title")}
172
148
  description={t("settings_ui.test_desc")}
@@ -226,6 +202,32 @@ export function SkillsInspectorPanel() {
226
202
  )}
227
203
  </div>
228
204
  </Section>
205
+ </div>
206
+
207
+ <Section
208
+ title={t("settings_ui.thresholds_title")}
209
+ description={t("settings_ui.thresholds_desc")}
210
+ >
211
+ <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-4">
212
+ {knobs().map((k) => (
213
+ <Field key={k.key} label={k.label} hint={k.hint}>
214
+ <Input
215
+ type="number"
216
+ step={k.step}
217
+ min={k.min}
218
+ max={k.max}
219
+ defaultValue={String(cfg[k.key])}
220
+ disabled={busy}
221
+ onBlur={(ev) => {
222
+ const n = Number(ev.target.value);
223
+ if (Number.isFinite(n) && n !== cfg[k.key]) apply({ [k.key]: n });
224
+ }}
225
+ className="max-w-[12rem]"
226
+ />
227
+ </Field>
228
+ ))}
229
+ </div>
230
+ </Section>
229
231
  </div>
230
232
  );
231
233
  }
@@ -6,15 +6,13 @@ import { http } from "../http";
6
6
  export type IntegrationScope = "project" | "global";
7
7
 
8
8
  // Status returned by a plugin's status endpoint. Common fields plus
9
- // plugin-specific extras (Asana adds user/workspace metadata).
9
+ // plugin-specific extras (Asana adds user/workspace, GitHub adds user_login…),
10
+ // so it carries an index signature for the generic component to read.
10
11
  export interface IntegrationStatus {
11
12
  slug: string;
12
13
  status: string;
13
14
  is_enabled: boolean;
14
- user_name?: string | null;
15
- user_email?: string | null;
16
- workspace_gid?: string | null;
17
- workspace_name?: string | null;
15
+ [key: string]: unknown;
18
16
  }
19
17
 
20
18
  export interface PluginTool {
@@ -22,6 +20,34 @@ export interface PluginTool {
22
20
  desc: string;
23
21
  }
24
22
 
23
+ // Declarative UI descriptor (mirrors the plugin's `ui` in core). Lets the
24
+ // generic PluginConnect component render each plugin's config form.
25
+ export interface PluginConfigField {
26
+ key: string;
27
+ label: string;
28
+ type: "password" | "text";
29
+ placeholder?: string;
30
+ help?: { label: string; url: string; urlLabel: string; steps: string[] };
31
+ }
32
+ export interface PluginSelect {
33
+ key: string;
34
+ label: string;
35
+ action: string;
36
+ listKey: string;
37
+ valueKey: string;
38
+ labelKey: string;
39
+ }
40
+ export interface PluginConnectedField {
41
+ key: string;
42
+ label: string;
43
+ }
44
+ export interface PluginUi {
45
+ accent?: string;
46
+ configFields: PluginConfigField[];
47
+ select?: PluginSelect;
48
+ connectedFields?: PluginConnectedField[];
49
+ }
50
+
25
51
  // One entry of the plugin catalog with its resolved status for this project.
26
52
  export interface CatalogEntry {
27
53
  slug: string;
@@ -30,6 +56,7 @@ export interface CatalogEntry {
30
56
  description: string;
31
57
  auth: string;
32
58
  tools?: PluginTool[];
59
+ ui?: PluginUi | null;
33
60
  coming_soon: boolean;
34
61
  status: IntegrationStatus;
35
62
  resolved_scope: IntegrationScope | null;
@@ -1,28 +1,26 @@
1
1
  import { useState } from "react";
2
2
  import useSWR from "swr";
3
- import { Network, Puzzle, Wrench } from "lucide-react";
3
+ import { Puzzle, Wrench } from "lucide-react";
4
4
  import { Integrations, type CatalogEntry, type IntegrationScope } from "../../lib/api";
5
5
  import { cn } from "../../lib/cn";
6
6
  import { Section } from "../../components/Section";
7
7
  import { Empty, Loading } from "../../components/ui";
8
- import { AsanaPlugin } from "../../components/integrations/AsanaPlugin";
8
+ import { PluginConnect } from "../../components/integrations/PluginConnect";
9
9
  import { ComingSoonPlugin } from "../../components/integrations/ComingSoonPlugin";
10
- import { McpsTab } from "./McpsTab";
11
10
 
12
- type SubTab = "plugins" | "mcp" | "tools";
11
+ type SubTab = "plugins" | "tools";
13
12
 
14
13
  const SUBTABS: { value: SubTab; label: string; icon: typeof Puzzle }[] = [
15
14
  { value: "plugins", label: "Plugins", icon: Puzzle },
16
- { value: "mcp", label: "MCP Servers", icon: Network },
17
15
  { value: "tools", label: "Tools", icon: Wrench },
18
16
  ];
19
17
 
20
- // Renders the live plugin (Asana) or a coming-soon placeholder per catalog entry.
18
+ // Renders a connectable plugin (via the generic PluginConnect, driven by its
19
+ // `ui` descriptor) or a coming-soon placeholder. MCP servers are NOT shown here
20
+ // — they have their own top-level "MCPs" nav item.
21
21
  function PluginRow({ pid, scope, entry }: { pid: string; scope: IntegrationScope; entry: CatalogEntry }) {
22
- if (entry.coming_soon) return <ComingSoonPlugin entry={entry} />;
23
- if (entry.slug === "asana") return <AsanaPlugin pid={pid} scope={scope} />;
24
- // Implemented plugin without a bespoke UI yet — fall back to the placeholder.
25
- return <ComingSoonPlugin entry={entry} />;
22
+ if (entry.coming_soon || !entry.ui) return <ComingSoonPlugin entry={entry} />;
23
+ return <PluginConnect pid={pid} scope={scope} entry={entry} />;
26
24
  }
27
25
 
28
26
  function PluginsSection({ pid, scope }: { pid: string; scope: IntegrationScope }) {
@@ -95,7 +93,7 @@ export function IntegrationsTab({ pid }: { pid: string }) {
95
93
  return (
96
94
  <Section
97
95
  title="Integrations"
98
- description="Plugins, MCP servers y tools disponibles para este proyecto"
96
+ description="Plugins y tools disponibles para este proyecto"
99
97
  >
100
98
  {/* Scope selector — a real project can use its own integrations or the
101
99
  global (default-project) ones. */}
@@ -139,7 +137,6 @@ export function IntegrationsTab({ pid }: { pid: string }) {
139
137
  </div>
140
138
 
141
139
  {tab === "plugins" && <PluginsSection pid={pid} scope={scope} />}
142
- {tab === "mcp" && <McpsTab pid={pid} />}
143
140
  {tab === "tools" && <ToolsSection pid={pid} />}
144
141
  </Section>
145
142
  );