@unoverse-platform/studio 0.1.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 (57) hide show
  1. package/bin/studio.mjs +124 -0
  2. package/index.html +12 -0
  3. package/local/catalog.ts +105 -0
  4. package/local/keys.ts +118 -0
  5. package/local/nodes.ts +77 -0
  6. package/local/plugin.ts +401 -0
  7. package/local/scaffold.d.mts +13 -0
  8. package/local/scaffold.mjs +85 -0
  9. package/package.json +43 -0
  10. package/public/logo.png +0 -0
  11. package/screens/AtomsView.tsx +206 -0
  12. package/screens/ComponentsView.tsx +551 -0
  13. package/screens/ConfigForm.tsx +217 -0
  14. package/screens/DesignSystemView.tsx +324 -0
  15. package/screens/IdentityHeader.tsx +88 -0
  16. package/screens/PromptBlocksView.tsx +137 -0
  17. package/screens/SkillsView.tsx +167 -0
  18. package/screens/TemplatesView.tsx +1005 -0
  19. package/screens/index.ts +26 -0
  20. package/screens/states.ts +44 -0
  21. package/src/App.tsx +252 -0
  22. package/src/ConnectUniverse.tsx +232 -0
  23. package/src/NewProject.tsx +89 -0
  24. package/src/NodeKeys.tsx +98 -0
  25. package/src/NodesView.tsx +443 -0
  26. package/src/PublishDialog.tsx +309 -0
  27. package/src/UniverseSession.tsx +121 -0
  28. package/src/host.ts +165 -0
  29. package/src/index.css +7 -0
  30. package/src/main.tsx +11 -0
  31. package/src/universes.ts +210 -0
  32. package/vendor/sdk/appEngine.tsx +219 -0
  33. package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
  34. package/vendor/sdk/lib/connection.tsx +302 -0
  35. package/vendor/sdk/lib/core/actions.ts +84 -0
  36. package/vendor/sdk/lib/core/client.ts +134 -0
  37. package/vendor/sdk/lib/core/conditions.ts +43 -0
  38. package/vendor/sdk/lib/core/connection.ts +142 -0
  39. package/vendor/sdk/lib/core/index.ts +32 -0
  40. package/vendor/sdk/lib/core/store.ts +455 -0
  41. package/vendor/sdk/lib/core/types.ts +194 -0
  42. package/vendor/sdk/lib/index.ts +59 -0
  43. package/vendor/sdk/lib/isolate.tsx +70 -0
  44. package/vendor/sdk/lib/primitives.tsx +453 -0
  45. package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
  46. package/vendor/sdk/lib/realtime/types.ts +45 -0
  47. package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
  48. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
  49. package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
  50. package/vendor/sdk/lib/render.tsx +178 -0
  51. package/vendor/sdk/lib/streamed.tsx +65 -0
  52. package/vendor/sdk/lib/style.ts +227 -0
  53. package/vendor/sdk/lib/template.tsx +521 -0
  54. package/vendor/sdk/lib/theme.ts +55 -0
  55. package/vendor/sdk/lib/voice.tsx +311 -0
  56. package/vendor/sdk/main.tsx +96 -0
  57. package/vite.config.ts +70 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The selected node's keys, read from the project's `.env`.
3
+ *
4
+ * Two decisions show up here. Keys belong to the NODE being tested, not to a global
5
+ * list: a developer testing one node should see exactly the credentials it declares.
6
+ * And Studio does not store secrets at all. Developers already have a `.env`, already
7
+ * gitignored; a second secret store would be one more thing to learn and to leak.
8
+ *
9
+ * So this panel is read-only. It names the exact variable each field wants and says
10
+ * whether it is set. Entering the value happens in the editor they are already in.
11
+ *
12
+ * See docs/architecture/LOCAL_STUDIO.md.
13
+ */
14
+ import { useEffect, useState } from "react";
15
+
16
+ export interface CredentialType {
17
+ name: string;
18
+ displayName?: string;
19
+ description?: string;
20
+ properties?: { name: string; displayName?: string; description?: string; required?: boolean }[];
21
+ }
22
+
23
+ interface FieldPresence {
24
+ field: string;
25
+ env: string;
26
+ set: boolean;
27
+ }
28
+
29
+ export function NodeKeys({ needs, types }: { needs: string[]; types: CredentialType[] }) {
30
+ const [present, setPresent] = useState<Record<string, FieldPresence[]>>({});
31
+ const [envFiles, setEnvFiles] = useState<string[]>([]);
32
+
33
+ useEffect(() => {
34
+ fetch("/keys")
35
+ .then((r) => r.json())
36
+ .then((d) => setPresent(d.present ?? {}))
37
+ .catch(() => setPresent({}));
38
+ fetch("/local/info")
39
+ .then((r) => r.json())
40
+ .then((d) => setEnvFiles(d.envFiles ?? []))
41
+ .catch(() => {});
42
+ }, []);
43
+
44
+ const mine = needs.map((n) => types.find((t) => t.name === n) ?? { name: n }) as CredentialType[];
45
+ if (mine.length === 0) return null;
46
+
47
+ const missing = mine.some((t) => (present[t.name] ?? []).some((f) => !f.set));
48
+
49
+ return (
50
+ <div className="mt-4">
51
+ <div className="flex items-baseline gap-2">
52
+ <span className="text-[11px] font-semibold uppercase tracking-wider text-gray-500">Keys</span>
53
+ <span className="text-[11px] text-gray-500">
54
+ from your <code className="rounded bg-gray-200 px-1 text-gray-700">.env</code>
55
+ {envFiles[0] && <span className="ml-1 text-gray-600" title={envFiles.join(" or ")}>({envFiles[0]})</span>}
56
+ </span>
57
+ </div>
58
+
59
+ {missing && (
60
+ <p className="mt-1 text-[11px] text-amber-700">
61
+ Add the variables below to your .env and restart Studio to pick them up.
62
+ </p>
63
+ )}
64
+
65
+ <div className="mt-2 space-y-3">
66
+ {mine.map((t) => {
67
+ const fields = present[t.name] ?? [];
68
+ return (
69
+ <section key={t.name} className="rounded-lg border border-gray-200 bg-gray-50 p-3">
70
+ <div className="flex items-center gap-2">
71
+ <h3 className="text-xs font-semibold text-gray-800">{t.displayName ?? t.name}</h3>
72
+ {fields.length > 0 && fields.every((f) => f.set) && (
73
+ <span className="rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-700">
74
+ ready
75
+ </span>
76
+ )}
77
+ </div>
78
+ {t.description && <p className="mt-0.5 text-[11px] text-gray-500">{t.description}</p>}
79
+
80
+ <div className="mt-2 space-y-1">
81
+ {fields.map((f) => (
82
+ <div key={f.field} className="flex items-center gap-2 text-[11px]">
83
+ <span className={f.set ? "text-emerald-600" : "text-gray-400"}>{f.set ? "●" : "○"}</span>
84
+ <code className={`rounded px-1 py-0.5 font-mono ${f.set ? "bg-gray-200 text-gray-700" : "bg-amber-100 text-amber-800"}`}>
85
+ {f.env}
86
+ </code>
87
+ <span className="text-gray-600">{f.set ? "set" : "not set"}</span>
88
+ </div>
89
+ ))}
90
+ {fields.length === 0 && <p className="text-[11px] text-gray-500">This credential declares no fields.</p>}
91
+ </div>
92
+ </section>
93
+ );
94
+ })}
95
+ </div>
96
+ </div>
97
+ );
98
+ }
@@ -0,0 +1,443 @@
1
+ /**
2
+ * Nodes view — Canvas-style. Left: a searchable palette of node cards grouped by
3
+ * category (name + category pill + description + colored accent). Right: a
4
+ * schema-driven config form (like the Canvas node panel) + Run, with the raw
5
+ * output (single) or streamed frames (generator).
6
+ *
7
+ * The runtime routes (/nodes, /execute, /execute-stream) are auth-gated server-side;
8
+ * authedFetch attaches the workbench bearer.
9
+ */
10
+ import { useCallback, useEffect, useMemo, useState } from "react";
11
+ import { authedFetch, usePersistentState, useRegistryToggles, ToggleSwitch } from "./host";
12
+ import { ConfigForm, defaultsFromSchema } from "@unoverse/studio";
13
+ import { NodeKeys, type CredentialType } from "./NodeKeys";
14
+
15
+ interface NodeInput {
16
+ name: string;
17
+ type?: string;
18
+ }
19
+ interface NodeDef {
20
+ type: string;
21
+ name: string;
22
+ description?: string;
23
+ whenToUse?: string;
24
+ executionMode: "single" | "generator";
25
+ category?: string;
26
+ inputs?: NodeInput[];
27
+ configSchema?: any;
28
+ credentials?: Array<{ name?: string } | string>;
29
+ testData?: any; // author-declared sample inputs/config, if any
30
+ relevance?: number; // 0–1 blended score, present only on semantic-search results
31
+ }
32
+
33
+ const pretty = (v: unknown) => JSON.stringify(v, null, 2);
34
+ // Nodes wrap their result in `__outputs` (the downstream-routing envelope); show the
35
+ // payload itself, not the wrapper.
36
+ const unwrap = (v: any) => (v && typeof v === "object" && "__outputs" in v ? v.__outputs : v);
37
+
38
+ // Category → accent color (left border + pill), mirroring the Canvas palette.
39
+ // Descriptive taxonomy (see docs 14 — category drives both sidebar grouping and
40
+ // UNO catalog ranking).
41
+ const CAT_COLOR: Record<string, string> = {
42
+ AI: "#6366f1",
43
+ Voice: "#8b5cf6",
44
+ "Go To Market": "#f43f5e",
45
+ Search: "#06b6d4",
46
+ "Web Scraping": "#0ea5e9",
47
+ "Media & Design": "#ec4899",
48
+ Documents: "#f59e0b",
49
+ "Knowledge & Vectors": "#a855f7",
50
+ "Storage & Data": "#3b82f6",
51
+ Communication: "#14b8a6",
52
+ Flow: "#22c55e",
53
+ Output: "#eab308",
54
+ // legacy / display
55
+ "Design System": "#ec4899",
56
+ Triggers: "#22c55e",
57
+ Storage: "#3b82f6",
58
+ Memory: "#a855f7",
59
+ Ingest: "#06b6d4",
60
+ };
61
+ const colorFor = (cat?: string) => CAT_COLOR[cat ?? ""] ?? "#6b7280";
62
+
63
+ function jsonEditor(label: string, value: string, onChange: (v: string) => void) {
64
+ return (
65
+ <label className="flex flex-col gap-1">
66
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-gray-400">{label}</span>
67
+ <textarea
68
+ value={value}
69
+ onChange={(e) => onChange(e.target.value)}
70
+ spellCheck={false}
71
+ className="h-28 w-full resize-y rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-gray-800 focus:border-emerald-500 focus:outline-none"
72
+ />
73
+ </label>
74
+ );
75
+ }
76
+
77
+ export function NodesView() {
78
+ const [nodes, setNodes] = useState<NodeDef[]>([]);
79
+ // Credential DEFINITIONS ride the same /nodes payload; the key panel renders from them.
80
+ const [credentialTypes, setCredentialTypes] = useState<CredentialType[]>([]);
81
+ // The folder these nodes came from. Shown so it is obvious this is YOUR nodes/ folder
82
+ // and not a marketplace listing.
83
+ const [nodesDir, setNodesDir] = useState<string>("");
84
+ const [selected, setSelected] = usePersistentState<string | null>("wb:nodes:selected", null);
85
+ const active = useRegistryToggles("node");
86
+ const [query, setQuery] = useState("");
87
+ const [config, setConfig] = useState<Record<string, any>>({});
88
+ const [inputs, setInputs] = useState("{}");
89
+ const [context, setContext] = useState(
90
+ pretty({ nodeId: "wb-1", executionId: "wb-exec-1", publishingContext: { chatId: "wb", conversationId: "wb", userId: "wb-user" } }),
91
+ );
92
+ const [advanced, setAdvanced] = useState(false);
93
+ // Bumped to remount ConfigForm when config is replaced wholesale (node switch /
94
+ // Load sample) — its multi-line fields keep a local text mirror that otherwise
95
+ // wouldn't pick up an external value change.
96
+ const [formKey, setFormKey] = useState(0);
97
+ const [running, setRunning] = useState(false);
98
+ const [output, setOutput] = useState("");
99
+ const [error, setError] = useState<string | null>(null);
100
+ // Semantic-search results in relevance order, or null when browsing / when
101
+ // ranking is unavailable (then the palette falls back to the local substring
102
+ // filter below — never a wrong order).
103
+ const [ranked, setRanked] = useState<NodeDef[] | null>(null);
104
+ const [searching, setSearching] = useState(false);
105
+
106
+ // Fetch the loaded-node catalog. Reused after a marketplace install/uninstall so the
107
+ // palette reflects the new/removed node types without a page reload.
108
+ const loadNodes = useCallback(async (): Promise<NodeDef[]> => {
109
+ try {
110
+ const r = await authedFetch("/nodes");
111
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
112
+ const d = await r.json();
113
+ // Design-system display components (category "Design System") are rendered via
114
+ // the component stream, not run standalone — hide them from the Nodes tab.
115
+ const list: NodeDef[] = (d.nodes ?? []).filter((n: NodeDef) => n.category !== "Design System");
116
+ setNodes(list);
117
+ setCredentialTypes(d.credentialTypes ?? []);
118
+ fetch("/local/info").then((r) => r.json()).then((i) => setNodesDir(i.nodes ?? "")).catch(() => {});
119
+ setError(null);
120
+ return list;
121
+ } catch (e: any) {
122
+ setError(String(e.message ?? e));
123
+ return [];
124
+ }
125
+ }, []);
126
+
127
+ useEffect(() => {
128
+ loadNodes().then((list) => {
129
+ // Restore the persisted selection across reloads; fall back to the first.
130
+ const restore = list.find((n) => n.type === selected) ?? list[0];
131
+ if (restore) choose(restore);
132
+ });
133
+ // eslint-disable-next-line react-hooks/exhaustive-deps
134
+ }, []);
135
+
136
+ // Semantic search — debounced POST /nodes/search (the same embedding+lexical
137
+ // ranker the agent's getNodeCatalog uses, over this server's own catalog). An
138
+ // empty box shows the browse view; while a query is in flight the local
139
+ // substring filter renders, then swaps to ranked order when results arrive.
140
+ useEffect(() => {
141
+ const q = query.trim();
142
+ if (!q) {
143
+ setRanked(null);
144
+ setSearching(false);
145
+ return;
146
+ }
147
+ let cancelled = false;
148
+ setSearching(true);
149
+ const t = setTimeout(async () => {
150
+ try {
151
+ const r = await authedFetch("/nodes/search", {
152
+ method: "POST",
153
+ headers: { "content-type": "application/json" },
154
+ body: JSON.stringify({ task: q, limit: 40 }),
155
+ });
156
+ const d = await r.json();
157
+ // ranked:false (no OpenAI key / API error) → null → local substring fallback.
158
+ // Drop design-system display components here too (same as the browse list).
159
+ const hits: NodeDef[] = (d.nodes ?? []).filter((n: NodeDef) => n.category !== "Design System");
160
+ if (!cancelled) setRanked(r.ok && d.ranked ? hits : null);
161
+ } catch {
162
+ if (!cancelled) setRanked(null);
163
+ } finally {
164
+ if (!cancelled) setSearching(false);
165
+ }
166
+ }, 250);
167
+ return () => {
168
+ cancelled = true;
169
+ clearTimeout(t);
170
+ };
171
+ }, [query]);
172
+
173
+ function choose(n: NodeDef) {
174
+ setSelected(n.type);
175
+ setConfig(defaultsFromSchema(n.configSchema));
176
+ setInputs(pretty(Object.fromEntries((n.inputs ?? []).map((i) => [i.name, ""]))));
177
+ setOutput("");
178
+ setError(null);
179
+ }
180
+ const setField = (k: string, v: any) => setConfig((c) => ({ ...c, [k]: v }));
181
+
182
+ /** Fill the form from the node's author-declared `testData`, if present. */
183
+ function loadSample() {
184
+ const td = current?.testData;
185
+ if (!td) return;
186
+ if (td.config) setConfig({ ...defaultsFromSchema(current!.configSchema), ...td.config });
187
+ if (td.inputs) setInputs(pretty(td.inputs));
188
+ if (!td.config && !td.inputs) setInputs(pretty(td)); // treat the whole blob as inputs
189
+ setFormKey((k) => k + 1); // remount the form so fields show the loaded values
190
+ setError(null);
191
+ }
192
+
193
+ const current = nodes.find((n) => n.type === selected);
194
+
195
+ async function run() {
196
+ if (!current) return;
197
+ let parsedInputs: unknown;
198
+ let parsedContext: unknown;
199
+ try {
200
+ parsedInputs = JSON.parse(inputs);
201
+ parsedContext = JSON.parse(context);
202
+ } catch (e: any) {
203
+ setError(`Invalid JSON in inputs/context: ${e.message}`);
204
+ return;
205
+ }
206
+ setError(null);
207
+ setOutput("");
208
+ setRunning(true);
209
+ const body = JSON.stringify({ nodeType: current.type, inputs: parsedInputs, config, context: parsedContext });
210
+
211
+ try {
212
+ if (current.executionMode === "generator") {
213
+ const res = await authedFetch("/execute-stream", { method: "POST", headers: { "content-type": "application/json" }, body });
214
+ if (!res.ok || !res.body) {
215
+ setError(`HTTP ${res.status}: ${await res.text()}`);
216
+ return;
217
+ }
218
+ const reader = res.body.getReader();
219
+ const dec = new TextDecoder();
220
+ let buf = "";
221
+ for (;;) {
222
+ const { done, value } = await reader.read();
223
+ if (done) break;
224
+ buf += dec.decode(value, { stream: true });
225
+ const parts = buf.split("\n\n");
226
+ buf = parts.pop() ?? "";
227
+ for (const part of parts) {
228
+ const line = part.split("\n").find((l) => l.startsWith("data: "));
229
+ if (!line) continue;
230
+ const frame = JSON.parse(line.slice(6));
231
+ const display = frame.type === "NODE_OUTPUT" ? unwrap(frame.output) : frame;
232
+ setOutput((o) => o + pretty(display) + "\n");
233
+ }
234
+ }
235
+ } else {
236
+ const res = await authedFetch("/execute", { method: "POST", headers: { "content-type": "application/json" }, body });
237
+ const data = await res.json();
238
+ if (!res.ok) setError(data.error ?? `HTTP ${res.status}`);
239
+ setOutput(pretty(unwrap(data.result ?? data)));
240
+ }
241
+ } catch (e: any) {
242
+ setError(String(e.message ?? e));
243
+ } finally {
244
+ setRunning(false);
245
+ }
246
+ }
247
+
248
+ // Browse view (empty box) + substring fallback (query in flight, or semantic
249
+ // ranking unavailable), grouped by category.
250
+ const byCategory = useMemo(() => {
251
+ const q = query.trim().toLowerCase();
252
+ const filtered = q
253
+ ? nodes.filter((n) => `${n.type} ${n.name} ${n.description ?? ""}`.toLowerCase().includes(q))
254
+ : nodes;
255
+ return Object.entries(
256
+ filtered.reduce<Record<string, NodeDef[]>>((acc, n) => {
257
+ (acc[n.category ?? "Other"] ??= []).push(n);
258
+ return acc;
259
+ }, {}),
260
+ ).sort(([a], [b]) => a.localeCompare(b));
261
+ }, [nodes, query]);
262
+
263
+ // Show ranked order once results land; until then (or if ranking is off) the
264
+ // grouped browse/substring view renders.
265
+ const showRanked = query.trim().length > 0 && ranked !== null;
266
+
267
+ function renderCard(n: NodeDef) {
268
+ const color = colorFor(n.category);
269
+ const active = selected === n.type;
270
+ return (
271
+ <button
272
+ key={n.type}
273
+ onClick={() => choose(n)}
274
+ className={`flex flex-col gap-1 rounded-lg border bg-[#232a34] p-3 text-left transition ${
275
+ active ? "border-emerald-500/60" : "border-gray-700 hover:border-gray-600"
276
+ }`}
277
+ style={{ borderLeftColor: color, borderLeftWidth: 3 }}
278
+ >
279
+ <div className="flex items-center gap-2">
280
+ <span className="truncate text-sm font-semibold text-gray-100">{n.name || n.type}</span>
281
+ {typeof n.relevance === "number" && (
282
+ <span className="shrink-0 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-medium text-emerald-300">
283
+ {Math.round(n.relevance * 100)}%
284
+ </span>
285
+ )}
286
+ <span className="ml-auto shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium" style={{ background: `${color}22`, color }}>
287
+ {n.category ?? "node"}
288
+ </span>
289
+ </div>
290
+ {n.description && <p className="line-clamp-2 text-[11px] leading-snug text-gray-400">{n.description}</p>}
291
+ {n.executionMode === "generator" && <span className="text-[9px] uppercase tracking-wide text-gray-500">stream</span>}
292
+ </button>
293
+ );
294
+ }
295
+
296
+ return (
297
+ <div className="grid min-h-0 flex-1 grid-cols-[330px_1fr] overflow-hidden">
298
+ {/* PALETTE — Canvas-style searchable node cards */}
299
+ <nav className="flex min-h-0 min-w-0 flex-col overflow-auto border-r border-gray-700 bg-[#1b2129] p-3">
300
+ <div className="relative mb-3">
301
+ <input
302
+ value={query}
303
+ onChange={(e) => setQuery(e.target.value)}
304
+ placeholder="Search nodes…"
305
+ className="w-full rounded-lg border border-gray-700 bg-[#0f141a] px-3 py-2 pr-9 text-sm text-gray-200 placeholder:text-gray-500 focus:border-emerald-500 focus:outline-none"
306
+ />
307
+ {nodesDir && (
308
+ <p className="px-1 pt-1 text-[10px] leading-snug text-gray-600" title={nodesDir}>
309
+ your nodes: <span className="text-gray-500">{nodesDir}</span>
310
+ </p>
311
+ )}
312
+ {searching && (
313
+ <svg className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-emerald-400" fill="none" viewBox="0 0 24 24">
314
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
315
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
316
+ </svg>
317
+ )}
318
+ </div>
319
+ {/* Marketplace — install/update packages inline (§4b). On change, reload the
320
+ palette so new/removed node types appear below. */}
321
+ {nodes.length === 0 && (
322
+ <div className="p-2 text-xs text-gray-400">{error ? `Couldn't load /nodes: ${error}` : "No nodes in Unoverse yet."}</div>
323
+ )}
324
+ {showRanked ? (
325
+ <div className="mb-4">
326
+ <div className="mb-2 px-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500">best matches</div>
327
+ <div className="flex flex-col gap-2">
328
+ {ranked!.length === 0 ? (
329
+ <div className="px-1 text-xs text-gray-500">No matching nodes.</div>
330
+ ) : (
331
+ ranked!.map(renderCard)
332
+ )}
333
+ </div>
334
+ </div>
335
+ ) : (
336
+ byCategory.map(([cat, items]) => (
337
+ <div key={cat} className="mb-4">
338
+ <div className="mb-2 px-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500">{cat}</div>
339
+ <div className="flex flex-col gap-2">{items.map(renderCard)}</div>
340
+ </div>
341
+ ))
342
+ )}
343
+ </nav>
344
+
345
+ {/* PANEL — request (left) · response (right), Run pinned in the header */}
346
+ <div className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-white">
347
+ {!current ? (
348
+ <div className="m-auto text-xs text-gray-500">Select a node.</div>
349
+ ) : (
350
+ <>
351
+ {/* HEADER BAR — identity + actions */}
352
+ <div className="flex items-start gap-3 border-b border-gray-800 px-6 py-4">
353
+ <div className="min-w-0">
354
+ <div className="flex items-center gap-2">
355
+ <h2 className="truncate text-lg font-semibold text-gray-900">{current.name || current.type}</h2>
356
+ <span className="shrink-0 rounded bg-white/5 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-gray-400">
357
+ {current.executionMode}
358
+ </span>
359
+ {current.category && (
360
+ <span
361
+ className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium"
362
+ style={{ background: `${colorFor(current.category)}22`, color: colorFor(current.category) }}
363
+ >
364
+ {current.category}
365
+ </span>
366
+ )}
367
+ <span className="ml-2 shrink-0">
368
+ <ToggleSwitch on={active.isEnabled(current.type)} onClick={() => active.toggle(current.type)} label="Active" />
369
+ </span>
370
+ </div>
371
+ {current.description && <p className="mt-0.5 text-sm text-gray-300">{current.description}</p>}
372
+ {current.whenToUse && (
373
+ <p className="mt-1 max-w-3xl text-[12px] leading-snug text-gray-600">
374
+ <span className="font-medium text-gray-500">When to use — </span>
375
+ {current.whenToUse}
376
+ </p>
377
+ )}
378
+ {(current.credentials?.length ?? 0) > 0 && (
379
+ <div className="mt-1 text-[11px] text-amber-700">
380
+ needs credentials: {current.credentials!.map((c) => (typeof c === "string" ? c : c?.name)).filter(Boolean).join(", ")}
381
+ </div>
382
+ )}
383
+ </div>
384
+ <div className="ml-auto flex shrink-0 items-center gap-2">
385
+ {current.testData && (
386
+ <button onClick={loadSample} className="rounded border border-gray-700 px-3 py-1.5 text-sm font-medium text-gray-300 hover:bg-white/5">
387
+ Load sample
388
+ </button>
389
+ )}
390
+ <button
391
+ onClick={run}
392
+ disabled={running}
393
+ className="rounded bg-emerald-500/90 px-5 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500 disabled:opacity-50"
394
+ >
395
+ {running ? "Running…" : current.executionMode === "generator" ? "Run ▸ stream" : "Run ▸"}
396
+ </button>
397
+ </div>
398
+ </div>
399
+ {error && (
400
+ <div className="border-b border-red-500/20 bg-red-500/5 px-6 py-2 text-xs font-medium text-red-400">{error}</div>
401
+ )}
402
+
403
+ {/* BODY — config/inputs (left) · output (right) */}
404
+ <div className="grid min-h-0 flex-1 grid-cols-2 overflow-hidden">
405
+ <div className="flex min-h-0 flex-col gap-5 overflow-auto border-r border-gray-800 p-6">
406
+ <div className="flex flex-col">
407
+ <span className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-400">configuration</span>
408
+ <div className="rounded-md border border-gray-200 bg-gray-50 p-4">
409
+ <ConfigForm key={`${selected}:${formKey}`} schema={current.configSchema} value={config} onChange={setField} />
410
+ </div>
411
+ </div>
412
+ <div>
413
+ <button onClick={() => setAdvanced((a) => !a)} className="text-[11px] font-medium text-gray-500 hover:text-gray-800">
414
+ {advanced ? "▾" : "▸"} inputs & context (JSON)
415
+ </button>
416
+ {/* Keys belong to the NODE being tested, not a global list: only the
417
+ credentials this node declares, built from their own definitions. */}
418
+ <NodeKeys
419
+ needs={(current.credentials ?? []).map((c: any) => (typeof c === "string" ? c : c?.name)).filter(Boolean)}
420
+ types={credentialTypes}
421
+ />
422
+ {advanced && (
423
+ <div className="mt-2 flex flex-col gap-3">
424
+ {jsonEditor("inputs", inputs, setInputs)}
425
+ {jsonEditor("context", context, setContext)}
426
+ </div>
427
+ )}
428
+ </div>
429
+ </div>
430
+
431
+ <div className="flex min-h-0 flex-col overflow-hidden p-6">
432
+ <span className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-400">output</span>
433
+ <pre className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap rounded-md border border-gray-200 bg-gray-50 p-4 font-mono text-xs text-gray-800">
434
+ {output || <span className="text-gray-500">— run the node to see output —</span>}
435
+ </pre>
436
+ </div>
437
+ </div>
438
+ </>
439
+ )}
440
+ </div>
441
+ </div>
442
+ );
443
+ }