@webgrow/skillhub 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.
- package/README.md +114 -0
- package/bridge/.env.example +14 -0
- package/bridge/README.md +74 -0
- package/bridge/adapters.mjs +209 -0
- package/bridge/convex-functions.mjs +13 -0
- package/bridge/doctor.mjs +448 -0
- package/bridge/harness.mjs +217 -0
- package/convex/_generated/ai/ai-files.state.json +6 -0
- package/convex/_generated/ai/guidelines.md +368 -0
- package/convex/_generated/api.d.ts +59 -0
- package/convex/_generated/api.js +23 -0
- package/convex/_generated/dataModel.d.ts +60 -0
- package/convex/_generated/server.d.ts +143 -0
- package/convex/_generated/server.js +93 -0
- package/convex/bridge.ts +709 -0
- package/convex/convex.config.ts +8 -0
- package/convex/http.ts +37 -0
- package/convex/loops.ts +546 -0
- package/convex/runs.ts +183 -0
- package/convex/schema.ts +220 -0
- package/convex/skills.ts +413 -0
- package/convex/tsconfig.json +25 -0
- package/dashboard/.env.example +1 -0
- package/dashboard/index.html +12 -0
- package/dashboard/src/App.jsx +1743 -0
- package/dashboard/src/convexRefs.js +32 -0
- package/dashboard/src/main.jsx +46 -0
- package/dashboard/src/styles.css +982 -0
- package/dashboard/tsconfig.json +17 -0
- package/dashboard/vite.config.mjs +23 -0
- package/package.json +48 -0
- package/src/bridge-command.mjs +101 -0
- package/src/cli.mjs +246 -0
- package/src/cloud-catalog.mjs +588 -0
- package/src/config.mjs +150 -0
- package/src/connect.mjs +410 -0
|
@@ -0,0 +1,1743 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Activity,
|
|
3
|
+
AlertTriangle,
|
|
4
|
+
BookOpen,
|
|
5
|
+
Bot,
|
|
6
|
+
CheckCircle2,
|
|
7
|
+
Clock3,
|
|
8
|
+
Database,
|
|
9
|
+
FileText,
|
|
10
|
+
GitBranch,
|
|
11
|
+
Layers,
|
|
12
|
+
ListChecks,
|
|
13
|
+
Loader2,
|
|
14
|
+
Pencil,
|
|
15
|
+
Play,
|
|
16
|
+
Plus,
|
|
17
|
+
Radio,
|
|
18
|
+
Rocket,
|
|
19
|
+
Save,
|
|
20
|
+
TerminalSquare,
|
|
21
|
+
Trash2,
|
|
22
|
+
XCircle,
|
|
23
|
+
} from "lucide-react";
|
|
24
|
+
import { useMutation, useQuery } from "convex/react";
|
|
25
|
+
import { useEffect, useMemo, useState } from "react";
|
|
26
|
+
import { refs } from "./convexRefs.js";
|
|
27
|
+
|
|
28
|
+
const emptyLoopForm = {
|
|
29
|
+
name: "",
|
|
30
|
+
description: "",
|
|
31
|
+
tags: "",
|
|
32
|
+
objective: "",
|
|
33
|
+
trigger: "manual",
|
|
34
|
+
primarySkill: "",
|
|
35
|
+
steps: [],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const emptySkillForm = {
|
|
39
|
+
name: "",
|
|
40
|
+
summary: "",
|
|
41
|
+
tags: "",
|
|
42
|
+
instructions: "",
|
|
43
|
+
inputSpec: "",
|
|
44
|
+
outputSpec: "",
|
|
45
|
+
testPrompt: "",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const statusTone = {
|
|
49
|
+
running: "good",
|
|
50
|
+
waiting: "warn",
|
|
51
|
+
paused: "neutral",
|
|
52
|
+
completed: "good",
|
|
53
|
+
failed: "bad",
|
|
54
|
+
canceled: "bad",
|
|
55
|
+
pending: "warn",
|
|
56
|
+
claimed: "info",
|
|
57
|
+
applying: "info",
|
|
58
|
+
skipped: "neutral",
|
|
59
|
+
expired: "bad",
|
|
60
|
+
draft: "warn",
|
|
61
|
+
published: "good",
|
|
62
|
+
archived: "neutral",
|
|
63
|
+
connected: "good",
|
|
64
|
+
disconnected: "neutral",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const statusIcons = {
|
|
68
|
+
running: Activity,
|
|
69
|
+
waiting: Clock3,
|
|
70
|
+
paused: Radio,
|
|
71
|
+
completed: CheckCircle2,
|
|
72
|
+
failed: XCircle,
|
|
73
|
+
canceled: XCircle,
|
|
74
|
+
pending: Clock3,
|
|
75
|
+
claimed: Bot,
|
|
76
|
+
applying: Loader2,
|
|
77
|
+
skipped: AlertTriangle,
|
|
78
|
+
expired: XCircle,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export function App() {
|
|
82
|
+
const activeRuns = useQuery(refs.runs.listActive, { limit: 80 });
|
|
83
|
+
const loopRows = useQuery(refs.loops.list, { limit: 100 });
|
|
84
|
+
const skillRows = useQuery(refs.skills.list, { limit: 100 });
|
|
85
|
+
const workerState = useQuery(refs.bridge.listWorkerState, { sessionLimit: 12 });
|
|
86
|
+
const createRun = useMutation(refs.runs.createRun);
|
|
87
|
+
const enqueueHostAction = useMutation(refs.bridge.enqueueHostAction);
|
|
88
|
+
const createLoopDraft = useMutation(refs.loops.createDraft);
|
|
89
|
+
const saveLoopDraftMutation = useMutation(refs.loops.saveDraft);
|
|
90
|
+
const publishLoopDraft = useMutation(refs.loops.publishDraft);
|
|
91
|
+
const runLoop = useMutation(refs.loops.run);
|
|
92
|
+
const createSkillDraft = useMutation(refs.skills.createDraft);
|
|
93
|
+
const saveSkillDraft = useMutation(refs.skills.saveDraft);
|
|
94
|
+
const publishSkillDraft = useMutation(refs.skills.publishDraft);
|
|
95
|
+
const runSkillTest = useMutation(refs.skills.runTest);
|
|
96
|
+
const [activeView, setActiveView] = useState("onboarding");
|
|
97
|
+
const [selectedRunId, setSelectedRunId] = useState(null);
|
|
98
|
+
const [selectedLoopId, setSelectedLoopId] = useState(null);
|
|
99
|
+
const [selectedSkillId, setSelectedSkillId] = useState(null);
|
|
100
|
+
const [loopForm, setLoopForm] = useState(emptyLoopForm);
|
|
101
|
+
const [skillForm, setSkillForm] = useState(emptySkillForm);
|
|
102
|
+
const [isLoopDirty, setIsLoopDirty] = useState(false);
|
|
103
|
+
const [isSkillDirty, setIsSkillDirty] = useState(false);
|
|
104
|
+
const [busyAction, setBusyAction] = useState("");
|
|
105
|
+
const [error, setError] = useState("");
|
|
106
|
+
const [notice, setNotice] = useState("");
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!selectedRunId && activeRuns?.length) {
|
|
110
|
+
setSelectedRunId(activeRuns[0]._id);
|
|
111
|
+
}
|
|
112
|
+
}, [activeRuns, selectedRunId]);
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (!selectedLoopId && loopRows?.length) {
|
|
116
|
+
setSelectedLoopId(loopRows[0].loop._id);
|
|
117
|
+
}
|
|
118
|
+
}, [loopRows, selectedLoopId]);
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!selectedSkillId && skillRows?.length) {
|
|
122
|
+
setSelectedSkillId(skillRows[0].skill._id);
|
|
123
|
+
}
|
|
124
|
+
}, [selectedSkillId, skillRows]);
|
|
125
|
+
|
|
126
|
+
const selectedPacket = useQuery(
|
|
127
|
+
refs.runs.getRun,
|
|
128
|
+
selectedRunId
|
|
129
|
+
? { runId: selectedRunId, eventLimit: 180, logLimit: 180 }
|
|
130
|
+
: "skip",
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const selectedLoopPacket = useQuery(
|
|
134
|
+
refs.loops.get,
|
|
135
|
+
selectedLoopId ? { loopId: selectedLoopId } : "skip",
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const selectedSkillPacket = useQuery(
|
|
139
|
+
refs.skills.get,
|
|
140
|
+
selectedSkillId ? { skillId: selectedSkillId } : "skip",
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (!selectedLoopPacket) {
|
|
145
|
+
setLoopForm(emptyLoopForm);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { loop, draftVersion, latestVersion } = selectedLoopPacket;
|
|
150
|
+
const editableVersion = draftVersion ?? latestVersion;
|
|
151
|
+
const manifest = editableVersion?.manifest ?? {};
|
|
152
|
+
setLoopForm({
|
|
153
|
+
name: loop.name ?? "",
|
|
154
|
+
description: loop.description ?? "",
|
|
155
|
+
tags: (loop.tags ?? []).join(", "),
|
|
156
|
+
objective: manifest.objective ?? "",
|
|
157
|
+
trigger: manifest.trigger ?? "manual",
|
|
158
|
+
primarySkill: loop.primarySkill ?? "",
|
|
159
|
+
steps: normalizeUiSteps(manifest.steps ?? []),
|
|
160
|
+
});
|
|
161
|
+
setIsLoopDirty(false);
|
|
162
|
+
}, [selectedLoopPacket?.loop?._id]);
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (!selectedSkillPacket) {
|
|
166
|
+
setSkillForm(emptySkillForm);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const { skill, draftVersion, latestVersion } = selectedSkillPacket;
|
|
171
|
+
const editableVersion = draftVersion ?? latestVersion;
|
|
172
|
+
setSkillForm({
|
|
173
|
+
name: skill.name ?? "",
|
|
174
|
+
summary: skill.summary ?? "",
|
|
175
|
+
tags: (skill.tags ?? []).join(", "),
|
|
176
|
+
instructions: editableVersion?.instructions ?? "",
|
|
177
|
+
inputSpec: editableVersion?.inputSpec ?? "",
|
|
178
|
+
outputSpec: editableVersion?.outputSpec ?? "",
|
|
179
|
+
testPrompt: editableVersion?.testPrompt ?? "",
|
|
180
|
+
});
|
|
181
|
+
setIsSkillDirty(false);
|
|
182
|
+
}, [selectedSkillPacket?.skill?._id]);
|
|
183
|
+
|
|
184
|
+
const selectedRun = selectedPacket?.run ?? null;
|
|
185
|
+
const hostActions = selectedPacket?.hostActions ?? [];
|
|
186
|
+
const events = selectedPacket?.events ?? [];
|
|
187
|
+
const logs = selectedPacket?.logs ?? [];
|
|
188
|
+
const loopItems = loopRows ?? [];
|
|
189
|
+
const skillItems = skillRows ?? [];
|
|
190
|
+
const selectedLoop = selectedLoopPacket?.loop ?? null;
|
|
191
|
+
const selectedLoopDraft = selectedLoopPacket?.draftVersion ?? null;
|
|
192
|
+
const selectedLoopLatest = selectedLoopPacket?.latestVersion ?? null;
|
|
193
|
+
const loopVersions = selectedLoopPacket?.versions ?? [];
|
|
194
|
+
const selectedSkill = selectedSkillPacket?.skill ?? null;
|
|
195
|
+
const draftVersion = selectedSkillPacket?.draftVersion ?? null;
|
|
196
|
+
const latestVersion = selectedSkillPacket?.latestVersion ?? null;
|
|
197
|
+
const versions = selectedSkillPacket?.versions ?? [];
|
|
198
|
+
|
|
199
|
+
const runMetrics = useMemo(() => {
|
|
200
|
+
const actionCounts = hostActions.reduce((counts, action) => {
|
|
201
|
+
counts[action.status] = (counts[action.status] ?? 0) + 1;
|
|
202
|
+
return counts;
|
|
203
|
+
}, {});
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
activeRuns: activeRuns?.length ?? 0,
|
|
207
|
+
pendingActions: actionCounts.pending ?? 0,
|
|
208
|
+
liveLogs: logs.length,
|
|
209
|
+
};
|
|
210
|
+
}, [activeRuns, hostActions, logs]);
|
|
211
|
+
|
|
212
|
+
const loopMetrics = useMemo(() => {
|
|
213
|
+
const published = loopItems.filter((row) => row.latestVersion).length;
|
|
214
|
+
const drafts = loopItems.filter((row) => row.draftVersion).length;
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
loops: loopItems.length,
|
|
218
|
+
published,
|
|
219
|
+
drafts,
|
|
220
|
+
};
|
|
221
|
+
}, [loopItems]);
|
|
222
|
+
|
|
223
|
+
const skillMetrics = useMemo(() => {
|
|
224
|
+
const published = skillItems.filter((row) => row.latestVersion).length;
|
|
225
|
+
const drafts = skillItems.filter((row) => row.draftVersion).length;
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
skills: skillItems.length,
|
|
229
|
+
published,
|
|
230
|
+
drafts,
|
|
231
|
+
};
|
|
232
|
+
}, [skillItems]);
|
|
233
|
+
|
|
234
|
+
const onboardingMetrics = useMemo(
|
|
235
|
+
() => ({
|
|
236
|
+
connectedWorkers:
|
|
237
|
+
workerState?.workers?.filter((worker) => worker.connected).length ?? 0,
|
|
238
|
+
pendingActions: workerState?.pendingCount ?? 0,
|
|
239
|
+
activeActions: workerState?.activeCount ?? 0,
|
|
240
|
+
}),
|
|
241
|
+
[workerState],
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
async function startDemoRun() {
|
|
245
|
+
setBusyAction("demo");
|
|
246
|
+
clearMessages();
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const runId = await createRun({
|
|
250
|
+
loopSlug: "codex-bridge-demo",
|
|
251
|
+
prompt: "Verify the SkillHub bridge connection to Codex.",
|
|
252
|
+
graph: {
|
|
253
|
+
entryNodeId: "codex.adapter",
|
|
254
|
+
nodes: [{ id: "codex.adapter", type: "hostAction" }],
|
|
255
|
+
},
|
|
256
|
+
controls: {
|
|
257
|
+
source: "dashboard",
|
|
258
|
+
mode: "demo",
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await enqueueHostAction({
|
|
263
|
+
runId,
|
|
264
|
+
nodeId: "codex.adapter",
|
|
265
|
+
actionType: "codex.cli",
|
|
266
|
+
title: "Run Codex bridge smoke task",
|
|
267
|
+
idempotencyKey: `dashboard-demo:${runId}`,
|
|
268
|
+
payload: {
|
|
269
|
+
prompt:
|
|
270
|
+
"Acknowledge the SkillHub bridge smoke task and report the next safe implementation step.",
|
|
271
|
+
expectedMode: "dry-run",
|
|
272
|
+
},
|
|
273
|
+
codexTool: {
|
|
274
|
+
adapter: "codex-cli",
|
|
275
|
+
safeDefault: "dry-run",
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
setSelectedRunId(runId);
|
|
280
|
+
setActiveView("runs");
|
|
281
|
+
} catch (cause) {
|
|
282
|
+
setError(errorMessage(cause));
|
|
283
|
+
} finally {
|
|
284
|
+
setBusyAction("");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function createNewLoop() {
|
|
289
|
+
setBusyAction("create-loop");
|
|
290
|
+
clearMessages();
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
const loopId = await createLoopDraft({
|
|
294
|
+
name: `Untitled loop ${loopItems.length + 1}`,
|
|
295
|
+
description: "Draft loop",
|
|
296
|
+
objective: "Describe the repeatable outcome this loop should achieve.",
|
|
297
|
+
trigger: "manual",
|
|
298
|
+
tags: ["draft"],
|
|
299
|
+
});
|
|
300
|
+
setSelectedLoopId(loopId);
|
|
301
|
+
setActiveView("loops");
|
|
302
|
+
setNotice("Loop draft created.");
|
|
303
|
+
} catch (cause) {
|
|
304
|
+
setError(errorMessage(cause));
|
|
305
|
+
} finally {
|
|
306
|
+
setBusyAction("");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function saveLoopDraft({ quiet = false } = {}) {
|
|
311
|
+
if (!selectedLoopId) {
|
|
312
|
+
throw new Error("Select or create a loop first.");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
await saveLoopDraftMutation({
|
|
316
|
+
loopId: selectedLoopId,
|
|
317
|
+
name: loopForm.name,
|
|
318
|
+
description: optionalValue(loopForm.description),
|
|
319
|
+
objective: loopForm.objective,
|
|
320
|
+
trigger: optionalValue(loopForm.trigger),
|
|
321
|
+
primarySkill: optionalValue(loopForm.primarySkill),
|
|
322
|
+
tags: parseTags(loopForm.tags),
|
|
323
|
+
steps: loopForm.steps,
|
|
324
|
+
});
|
|
325
|
+
setIsLoopDirty(false);
|
|
326
|
+
|
|
327
|
+
if (!quiet) {
|
|
328
|
+
setNotice("Loop draft saved.");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function handleSaveLoopDraft() {
|
|
333
|
+
setBusyAction("save-loop");
|
|
334
|
+
clearMessages();
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
await saveLoopDraft();
|
|
338
|
+
} catch (cause) {
|
|
339
|
+
setError(errorMessage(cause));
|
|
340
|
+
} finally {
|
|
341
|
+
setBusyAction("");
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function handlePublishLoop() {
|
|
346
|
+
setBusyAction("publish-loop");
|
|
347
|
+
clearMessages();
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
await saveLoopDraft({ quiet: true });
|
|
351
|
+
await publishLoopDraft({ loopId: selectedLoopId });
|
|
352
|
+
setIsLoopDirty(false);
|
|
353
|
+
setNotice("Loop published.");
|
|
354
|
+
} catch (cause) {
|
|
355
|
+
setError(errorMessage(cause));
|
|
356
|
+
} finally {
|
|
357
|
+
setBusyAction("");
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function handleRunLoop() {
|
|
362
|
+
setBusyAction("run-loop");
|
|
363
|
+
clearMessages();
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
if (isLoopDirty) {
|
|
367
|
+
await saveLoopDraft({ quiet: true });
|
|
368
|
+
}
|
|
369
|
+
const result = await runLoop({
|
|
370
|
+
loopId: selectedLoopId,
|
|
371
|
+
promptOverride: optionalValue(loopForm.objective),
|
|
372
|
+
});
|
|
373
|
+
setSelectedRunId(result.runId);
|
|
374
|
+
setActiveView("runs");
|
|
375
|
+
setNotice("Loop run queued.");
|
|
376
|
+
} catch (cause) {
|
|
377
|
+
setError(errorMessage(cause));
|
|
378
|
+
} finally {
|
|
379
|
+
setBusyAction("");
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function updateLoopField(field, value) {
|
|
384
|
+
setIsLoopDirty(true);
|
|
385
|
+
setLoopForm((current) => ({ ...current, [field]: value }));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function updateLoopStep(index, field, value) {
|
|
389
|
+
setIsLoopDirty(true);
|
|
390
|
+
setLoopForm((current) => ({
|
|
391
|
+
...current,
|
|
392
|
+
steps: current.steps.map((step, stepIndex) =>
|
|
393
|
+
stepIndex === index ? { ...step, [field]: value } : step,
|
|
394
|
+
),
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function addLoopStep() {
|
|
399
|
+
setIsLoopDirty(true);
|
|
400
|
+
setLoopForm((current) => {
|
|
401
|
+
const nextIndex = current.steps.length + 1;
|
|
402
|
+
return {
|
|
403
|
+
...current,
|
|
404
|
+
steps: [
|
|
405
|
+
...current.steps,
|
|
406
|
+
{
|
|
407
|
+
id: `step-${nextIndex}`,
|
|
408
|
+
label: `Step ${nextIndex}`,
|
|
409
|
+
actionType: "codex.cli",
|
|
410
|
+
skillSlug: "",
|
|
411
|
+
instructions: `Complete step ${nextIndex}.`,
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
};
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function removeLoopStep(index) {
|
|
419
|
+
setIsLoopDirty(true);
|
|
420
|
+
setLoopForm((current) => ({
|
|
421
|
+
...current,
|
|
422
|
+
steps: current.steps.filter((_, stepIndex) => stepIndex !== index),
|
|
423
|
+
}));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function createNewSkill() {
|
|
427
|
+
setBusyAction("create-skill");
|
|
428
|
+
clearMessages();
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
const skillId = await createSkillDraft({
|
|
432
|
+
name: `Untitled skill ${skillItems.length + 1}`,
|
|
433
|
+
summary: "Draft skill",
|
|
434
|
+
instructions:
|
|
435
|
+
"Describe the role, constraints, process, and final answer format for this skill.",
|
|
436
|
+
testPrompt: "Run this skill against a small realistic example.",
|
|
437
|
+
tags: ["draft"],
|
|
438
|
+
});
|
|
439
|
+
setSelectedSkillId(skillId);
|
|
440
|
+
setActiveView("skills");
|
|
441
|
+
setNotice("Draft created.");
|
|
442
|
+
} catch (cause) {
|
|
443
|
+
setError(errorMessage(cause));
|
|
444
|
+
} finally {
|
|
445
|
+
setBusyAction("");
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function saveDraft({ quiet = false } = {}) {
|
|
450
|
+
if (!selectedSkillId) {
|
|
451
|
+
throw new Error("Select or create a skill first.");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
await saveSkillDraft({
|
|
455
|
+
skillId: selectedSkillId,
|
|
456
|
+
name: skillForm.name,
|
|
457
|
+
summary: optionalValue(skillForm.summary),
|
|
458
|
+
instructions: skillForm.instructions,
|
|
459
|
+
inputSpec: optionalValue(skillForm.inputSpec),
|
|
460
|
+
outputSpec: optionalValue(skillForm.outputSpec),
|
|
461
|
+
testPrompt: optionalValue(skillForm.testPrompt),
|
|
462
|
+
tags: parseTags(skillForm.tags),
|
|
463
|
+
});
|
|
464
|
+
setIsSkillDirty(false);
|
|
465
|
+
|
|
466
|
+
if (!quiet) {
|
|
467
|
+
setNotice("Draft saved.");
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function handleSaveDraft() {
|
|
472
|
+
setBusyAction("save-skill");
|
|
473
|
+
clearMessages();
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
await saveDraft();
|
|
477
|
+
} catch (cause) {
|
|
478
|
+
setError(errorMessage(cause));
|
|
479
|
+
} finally {
|
|
480
|
+
setBusyAction("");
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function handlePublishSkill() {
|
|
485
|
+
setBusyAction("publish-skill");
|
|
486
|
+
clearMessages();
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
await saveDraft({ quiet: true });
|
|
490
|
+
await publishSkillDraft({ skillId: selectedSkillId });
|
|
491
|
+
setIsSkillDirty(false);
|
|
492
|
+
setNotice("Skill published.");
|
|
493
|
+
} catch (cause) {
|
|
494
|
+
setError(errorMessage(cause));
|
|
495
|
+
} finally {
|
|
496
|
+
setBusyAction("");
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function handleRunSkillTest() {
|
|
501
|
+
setBusyAction("run-skill");
|
|
502
|
+
clearMessages();
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
if (isSkillDirty || draftVersion) {
|
|
506
|
+
await saveDraft({ quiet: true });
|
|
507
|
+
}
|
|
508
|
+
const result = await runSkillTest({
|
|
509
|
+
skillId: selectedSkillId,
|
|
510
|
+
promptOverride: optionalValue(skillForm.testPrompt),
|
|
511
|
+
});
|
|
512
|
+
setSelectedRunId(result.runId);
|
|
513
|
+
setActiveView("runs");
|
|
514
|
+
setNotice("Skill test queued.");
|
|
515
|
+
} catch (cause) {
|
|
516
|
+
setError(errorMessage(cause));
|
|
517
|
+
} finally {
|
|
518
|
+
setBusyAction("");
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function clearMessages() {
|
|
523
|
+
setError("");
|
|
524
|
+
setNotice("");
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return (
|
|
528
|
+
<main className="app-shell">
|
|
529
|
+
<aside className="sidebar" aria-label="SkillHub navigation">
|
|
530
|
+
<div className="brand-row">
|
|
531
|
+
<div className="brand-mark">
|
|
532
|
+
<Database size={18} aria-hidden="true" />
|
|
533
|
+
</div>
|
|
534
|
+
<div>
|
|
535
|
+
<div className="brand-title">SkillHub</div>
|
|
536
|
+
<div className="brand-subtitle">Control plane</div>
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
|
|
540
|
+
<div className="view-tabs" role="tablist" aria-label="Workspace">
|
|
541
|
+
<button
|
|
542
|
+
className={activeView === "onboarding" ? "selected" : ""}
|
|
543
|
+
type="button"
|
|
544
|
+
role="tab"
|
|
545
|
+
aria-selected={activeView === "onboarding"}
|
|
546
|
+
onClick={() => setActiveView("onboarding")}
|
|
547
|
+
>
|
|
548
|
+
<Rocket size={16} aria-hidden="true" />
|
|
549
|
+
<span>Connect</span>
|
|
550
|
+
<strong>{onboardingMetrics.connectedWorkers}</strong>
|
|
551
|
+
</button>
|
|
552
|
+
<button
|
|
553
|
+
className={activeView === "loops" ? "selected" : ""}
|
|
554
|
+
type="button"
|
|
555
|
+
role="tab"
|
|
556
|
+
aria-selected={activeView === "loops"}
|
|
557
|
+
onClick={() => setActiveView("loops")}
|
|
558
|
+
>
|
|
559
|
+
<GitBranch size={16} aria-hidden="true" />
|
|
560
|
+
<span>Loops</span>
|
|
561
|
+
<strong>{loopMetrics.loops}</strong>
|
|
562
|
+
</button>
|
|
563
|
+
<button
|
|
564
|
+
className={activeView === "skills" ? "selected" : ""}
|
|
565
|
+
type="button"
|
|
566
|
+
role="tab"
|
|
567
|
+
aria-selected={activeView === "skills"}
|
|
568
|
+
onClick={() => setActiveView("skills")}
|
|
569
|
+
>
|
|
570
|
+
<BookOpen size={16} aria-hidden="true" />
|
|
571
|
+
<span>Skills</span>
|
|
572
|
+
<strong>{skillMetrics.skills}</strong>
|
|
573
|
+
</button>
|
|
574
|
+
<button
|
|
575
|
+
className={activeView === "runs" ? "selected" : ""}
|
|
576
|
+
type="button"
|
|
577
|
+
role="tab"
|
|
578
|
+
aria-selected={activeView === "runs"}
|
|
579
|
+
onClick={() => setActiveView("runs")}
|
|
580
|
+
>
|
|
581
|
+
<Activity size={16} aria-hidden="true" />
|
|
582
|
+
<span>Runs</span>
|
|
583
|
+
<strong>{runMetrics.activeRuns}</strong>
|
|
584
|
+
</button>
|
|
585
|
+
</div>
|
|
586
|
+
|
|
587
|
+
{activeView === "loops" ? (
|
|
588
|
+
<LoopSidebar
|
|
589
|
+
loading={loopRows === undefined}
|
|
590
|
+
rows={loopItems}
|
|
591
|
+
selectedLoopId={selectedLoopId}
|
|
592
|
+
onSelect={setSelectedLoopId}
|
|
593
|
+
/>
|
|
594
|
+
) : null}
|
|
595
|
+
{activeView === "skills" ? (
|
|
596
|
+
<SkillSidebar
|
|
597
|
+
loading={skillRows === undefined}
|
|
598
|
+
rows={skillItems}
|
|
599
|
+
selectedSkillId={selectedSkillId}
|
|
600
|
+
onSelect={setSelectedSkillId}
|
|
601
|
+
/>
|
|
602
|
+
) : null}
|
|
603
|
+
{activeView === "runs" ? (
|
|
604
|
+
<RunSidebar
|
|
605
|
+
loading={activeRuns === undefined}
|
|
606
|
+
runs={activeRuns ?? []}
|
|
607
|
+
selectedRunId={selectedRunId}
|
|
608
|
+
onSelect={setSelectedRunId}
|
|
609
|
+
/>
|
|
610
|
+
) : null}
|
|
611
|
+
{activeView === "onboarding" ? (
|
|
612
|
+
<OnboardingSidebar workerState={workerState} />
|
|
613
|
+
) : null}
|
|
614
|
+
</aside>
|
|
615
|
+
|
|
616
|
+
<section className="workspace">
|
|
617
|
+
{activeView === "onboarding" ? (
|
|
618
|
+
<OnboardingWorkspace
|
|
619
|
+
metrics={onboardingMetrics}
|
|
620
|
+
workerState={workerState}
|
|
621
|
+
/>
|
|
622
|
+
) : null}
|
|
623
|
+
{activeView === "loops" ? (
|
|
624
|
+
<LoopsWorkspace
|
|
625
|
+
busyAction={busyAction}
|
|
626
|
+
draftVersion={selectedLoopDraft}
|
|
627
|
+
form={loopForm}
|
|
628
|
+
latestVersion={selectedLoopLatest}
|
|
629
|
+
onAddStep={addLoopStep}
|
|
630
|
+
onCreate={createNewLoop}
|
|
631
|
+
onFieldChange={updateLoopField}
|
|
632
|
+
onPublish={handlePublishLoop}
|
|
633
|
+
onRemoveStep={removeLoopStep}
|
|
634
|
+
onRun={handleRunLoop}
|
|
635
|
+
onSave={handleSaveLoopDraft}
|
|
636
|
+
onStepChange={updateLoopStep}
|
|
637
|
+
selectedLoop={selectedLoop}
|
|
638
|
+
skillRows={skillItems}
|
|
639
|
+
versions={loopVersions}
|
|
640
|
+
/>
|
|
641
|
+
) : null}
|
|
642
|
+
{activeView === "skills" ? (
|
|
643
|
+
<SkillsWorkspace
|
|
644
|
+
busyAction={busyAction}
|
|
645
|
+
draftVersion={draftVersion}
|
|
646
|
+
form={skillForm}
|
|
647
|
+
latestVersion={latestVersion}
|
|
648
|
+
onCreate={createNewSkill}
|
|
649
|
+
onFieldChange={(field, value) => {
|
|
650
|
+
setIsSkillDirty(true);
|
|
651
|
+
setSkillForm((current) => ({ ...current, [field]: value }));
|
|
652
|
+
}}
|
|
653
|
+
onPublish={handlePublishSkill}
|
|
654
|
+
onRunTest={handleRunSkillTest}
|
|
655
|
+
onSave={handleSaveDraft}
|
|
656
|
+
selectedSkill={selectedSkill}
|
|
657
|
+
versions={versions}
|
|
658
|
+
/>
|
|
659
|
+
) : null}
|
|
660
|
+
{activeView === "runs" ? (
|
|
661
|
+
<RunsWorkspace
|
|
662
|
+
busyAction={busyAction}
|
|
663
|
+
events={events}
|
|
664
|
+
hostActions={hostActions}
|
|
665
|
+
logs={logs}
|
|
666
|
+
metrics={runMetrics}
|
|
667
|
+
onStartDemo={startDemoRun}
|
|
668
|
+
selectedRun={selectedRun}
|
|
669
|
+
/>
|
|
670
|
+
) : null}
|
|
671
|
+
|
|
672
|
+
{error ? <div className="error-banner" role="alert">{error}</div> : null}
|
|
673
|
+
{notice ? <div className="notice-banner" role="status">{notice}</div> : null}
|
|
674
|
+
</section>
|
|
675
|
+
</main>
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function LoopSidebar({ rows, selectedLoopId, loading, onSelect }) {
|
|
680
|
+
return (
|
|
681
|
+
<>
|
|
682
|
+
<div className="sidebar-header">
|
|
683
|
+
<span>Loops</span>
|
|
684
|
+
<span className="count-pill">{rows.length}</span>
|
|
685
|
+
</div>
|
|
686
|
+
<div className="run-list">
|
|
687
|
+
{loading ? (
|
|
688
|
+
<LoadingRow label="Loading loops" />
|
|
689
|
+
) : rows.length ? (
|
|
690
|
+
rows.map(({ loop, latestVersion, draftVersion }) => (
|
|
691
|
+
<LoopListItem
|
|
692
|
+
key={loop._id}
|
|
693
|
+
draftVersion={draftVersion}
|
|
694
|
+
latestVersion={latestVersion}
|
|
695
|
+
loop={loop}
|
|
696
|
+
selected={loop._id === selectedLoopId}
|
|
697
|
+
onSelect={() => onSelect(loop._id)}
|
|
698
|
+
/>
|
|
699
|
+
))
|
|
700
|
+
) : (
|
|
701
|
+
<div className="empty-state">No loops</div>
|
|
702
|
+
)}
|
|
703
|
+
</div>
|
|
704
|
+
</>
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function SkillSidebar({ rows, selectedSkillId, loading, onSelect }) {
|
|
709
|
+
return (
|
|
710
|
+
<>
|
|
711
|
+
<div className="sidebar-header">
|
|
712
|
+
<span>Skills</span>
|
|
713
|
+
<span className="count-pill">{rows.length}</span>
|
|
714
|
+
</div>
|
|
715
|
+
<div className="run-list">
|
|
716
|
+
{loading ? (
|
|
717
|
+
<LoadingRow label="Loading skills" />
|
|
718
|
+
) : rows.length ? (
|
|
719
|
+
rows.map(({ skill, latestVersion, draftVersion }) => (
|
|
720
|
+
<SkillListItem
|
|
721
|
+
key={skill._id}
|
|
722
|
+
draftVersion={draftVersion}
|
|
723
|
+
latestVersion={latestVersion}
|
|
724
|
+
selected={skill._id === selectedSkillId}
|
|
725
|
+
skill={skill}
|
|
726
|
+
onSelect={() => onSelect(skill._id)}
|
|
727
|
+
/>
|
|
728
|
+
))
|
|
729
|
+
) : (
|
|
730
|
+
<div className="empty-state">No skills</div>
|
|
731
|
+
)}
|
|
732
|
+
</div>
|
|
733
|
+
</>
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function RunSidebar({ runs, selectedRunId, loading, onSelect }) {
|
|
738
|
+
return (
|
|
739
|
+
<>
|
|
740
|
+
<div className="sidebar-header">
|
|
741
|
+
<span>Runs</span>
|
|
742
|
+
<span className="count-pill">{runs.length}</span>
|
|
743
|
+
</div>
|
|
744
|
+
<div className="run-list">
|
|
745
|
+
{loading ? (
|
|
746
|
+
<LoadingRow label="Loading runs" />
|
|
747
|
+
) : runs.length ? (
|
|
748
|
+
runs.map((run) => (
|
|
749
|
+
<RunListItem
|
|
750
|
+
key={run._id}
|
|
751
|
+
run={run}
|
|
752
|
+
selected={run._id === selectedRunId}
|
|
753
|
+
onSelect={() => onSelect(run._id)}
|
|
754
|
+
/>
|
|
755
|
+
))
|
|
756
|
+
) : (
|
|
757
|
+
<div className="empty-state">No active runs</div>
|
|
758
|
+
)}
|
|
759
|
+
</div>
|
|
760
|
+
</>
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function OnboardingSidebar({ workerState }) {
|
|
765
|
+
const workers = workerState?.workers ?? [];
|
|
766
|
+
const connected = workers.filter((worker) => worker.connected).length;
|
|
767
|
+
const label = connected ? "Connected" : "Disconnected";
|
|
768
|
+
|
|
769
|
+
return (
|
|
770
|
+
<>
|
|
771
|
+
<div className="sidebar-header">
|
|
772
|
+
<span>Codex</span>
|
|
773
|
+
<span className="count-pill">{connected}</span>
|
|
774
|
+
</div>
|
|
775
|
+
<div className="connect-summary">
|
|
776
|
+
<StatusBadge status={connected ? "connected" : "disconnected"} />
|
|
777
|
+
<span>{label}</span>
|
|
778
|
+
</div>
|
|
779
|
+
<div className="sidebar-header">
|
|
780
|
+
<span>Worker</span>
|
|
781
|
+
<span className="count-pill">{workers.length}</span>
|
|
782
|
+
</div>
|
|
783
|
+
<div className="run-list">
|
|
784
|
+
{workerState === undefined ? (
|
|
785
|
+
<LoadingRow label="Loading worker state" />
|
|
786
|
+
) : workers.length ? (
|
|
787
|
+
workers.map((worker) => (
|
|
788
|
+
<WorkerListItem key={worker.session._id} worker={worker} />
|
|
789
|
+
))
|
|
790
|
+
) : (
|
|
791
|
+
<div className="empty-state">No bridge worker</div>
|
|
792
|
+
)}
|
|
793
|
+
</div>
|
|
794
|
+
</>
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function WorkerListItem({ worker }) {
|
|
799
|
+
return (
|
|
800
|
+
<div className="worker-item">
|
|
801
|
+
<span className={`status-dot ${worker.connected ? "good" : "neutral"}`}>
|
|
802
|
+
<Bot size={14} aria-hidden="true" />
|
|
803
|
+
</span>
|
|
804
|
+
<span className="run-item-main">
|
|
805
|
+
<span className="run-name">{worker.session.name}</span>
|
|
806
|
+
<span className="run-meta">
|
|
807
|
+
{worker.connected ? "connected" : "disconnected"} -{" "}
|
|
808
|
+
{formatTime(worker.session.lastSeenAt)}
|
|
809
|
+
</span>
|
|
810
|
+
</span>
|
|
811
|
+
</div>
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function OnboardingWorkspace({ metrics, workerState }) {
|
|
816
|
+
const workers = workerState?.workers ?? [];
|
|
817
|
+
|
|
818
|
+
return (
|
|
819
|
+
<>
|
|
820
|
+
<header className="topbar">
|
|
821
|
+
<div>
|
|
822
|
+
<p className="eyebrow">Codex onboarding</p>
|
|
823
|
+
<h1>Connect SkillHub</h1>
|
|
824
|
+
</div>
|
|
825
|
+
</header>
|
|
826
|
+
|
|
827
|
+
<div className="metric-grid" aria-label="Connection metrics">
|
|
828
|
+
<Metric label="Workers connected" value={metrics.connectedWorkers} icon={Bot} />
|
|
829
|
+
<Metric label="Queued actions" value={metrics.pendingActions} icon={Clock3} />
|
|
830
|
+
<Metric label="Active actions" value={metrics.activeActions} icon={Activity} />
|
|
831
|
+
</div>
|
|
832
|
+
|
|
833
|
+
<div className="onboarding-grid">
|
|
834
|
+
<section className="panel onboarding-panel" aria-label="Setup checklist">
|
|
835
|
+
<PanelTitle icon={Rocket} title="Setup" />
|
|
836
|
+
<div className="checklist">
|
|
837
|
+
<ChecklistItem done label="Install SkillHub" detail="Run npx skillhub connect once." />
|
|
838
|
+
<ChecklistItem
|
|
839
|
+
done={metrics.connectedWorkers > 0}
|
|
840
|
+
label="Connect Codex"
|
|
841
|
+
detail="The SkillHub MCP tools are registered in Codex."
|
|
842
|
+
/>
|
|
843
|
+
<ChecklistItem
|
|
844
|
+
done={(workerState?.pendingCount ?? 0) >= 0}
|
|
845
|
+
label="Run a smoke test"
|
|
846
|
+
detail='Ask Codex: "Show my SkillHub options."'
|
|
847
|
+
/>
|
|
848
|
+
<ChecklistItem
|
|
849
|
+
done={metrics.activeActions > 0 || metrics.pendingActions > 0}
|
|
850
|
+
label="Run an option"
|
|
851
|
+
detail='Ask Codex: "Run option 1."'
|
|
852
|
+
/>
|
|
853
|
+
</div>
|
|
854
|
+
<details className="advanced-disclosure">
|
|
855
|
+
<summary>
|
|
856
|
+
<TerminalSquare size={15} aria-hidden="true" />
|
|
857
|
+
<span>Advanced</span>
|
|
858
|
+
</summary>
|
|
859
|
+
<pre className="prompt-preview">skillhub connect{"\n"}skillhub bridge start{"\n"}skillhub status</pre>
|
|
860
|
+
</details>
|
|
861
|
+
</section>
|
|
862
|
+
|
|
863
|
+
<section className="panel onboarding-panel" aria-label="Bridge worker state">
|
|
864
|
+
<PanelTitle icon={Radio} title="Bridge Worker" />
|
|
865
|
+
{workerState === undefined ? (
|
|
866
|
+
<LoadingRow label="Loading worker state" />
|
|
867
|
+
) : workers.length ? (
|
|
868
|
+
<div className="worker-table">
|
|
869
|
+
{workers.map((worker) => (
|
|
870
|
+
<WorkerStateRow key={worker.session._id} worker={worker} />
|
|
871
|
+
))}
|
|
872
|
+
</div>
|
|
873
|
+
) : (
|
|
874
|
+
<EmptyPanel label="No worker connected" />
|
|
875
|
+
)}
|
|
876
|
+
</section>
|
|
877
|
+
</div>
|
|
878
|
+
</>
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function ChecklistItem({ detail, done, label }) {
|
|
883
|
+
return (
|
|
884
|
+
<div className="checklist-item">
|
|
885
|
+
<span className={`status-dot ${done ? "good" : "neutral"}`}>
|
|
886
|
+
{done ? (
|
|
887
|
+
<CheckCircle2 size={15} aria-hidden="true" />
|
|
888
|
+
) : (
|
|
889
|
+
<Clock3 size={15} aria-hidden="true" />
|
|
890
|
+
)}
|
|
891
|
+
</span>
|
|
892
|
+
<span>
|
|
893
|
+
<strong>{label}</strong>
|
|
894
|
+
<small>{detail}</small>
|
|
895
|
+
</span>
|
|
896
|
+
</div>
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function WorkerStateRow({ worker }) {
|
|
901
|
+
return (
|
|
902
|
+
<article className="worker-row">
|
|
903
|
+
<div>
|
|
904
|
+
<strong>{worker.session.name}</strong>
|
|
905
|
+
<span>{worker.session.kind}</span>
|
|
906
|
+
</div>
|
|
907
|
+
<StatusBadge status={worker.connected ? "connected" : "disconnected"} />
|
|
908
|
+
<WorkerDetail label="Last seen" value={formatDateTime(worker.session.lastSeenAt)} />
|
|
909
|
+
<WorkerDetail
|
|
910
|
+
label="Current action"
|
|
911
|
+
value={worker.currentAction?.title ?? "none"}
|
|
912
|
+
/>
|
|
913
|
+
{worker.lastError ? (
|
|
914
|
+
<WorkerDetail label="Last error" value={worker.lastError} />
|
|
915
|
+
) : null}
|
|
916
|
+
</article>
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function WorkerDetail({ label, value }) {
|
|
921
|
+
return (
|
|
922
|
+
<div className="worker-detail">
|
|
923
|
+
<span>{label}</span>
|
|
924
|
+
<strong>{value}</strong>
|
|
925
|
+
</div>
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function LoopsWorkspace({
|
|
930
|
+
busyAction,
|
|
931
|
+
draftVersion,
|
|
932
|
+
form,
|
|
933
|
+
latestVersion,
|
|
934
|
+
onAddStep,
|
|
935
|
+
onCreate,
|
|
936
|
+
onFieldChange,
|
|
937
|
+
onPublish,
|
|
938
|
+
onRemoveStep,
|
|
939
|
+
onRun,
|
|
940
|
+
onSave,
|
|
941
|
+
onStepChange,
|
|
942
|
+
selectedLoop,
|
|
943
|
+
skillRows,
|
|
944
|
+
versions,
|
|
945
|
+
}) {
|
|
946
|
+
const isBusy = Boolean(busyAction);
|
|
947
|
+
const hasLoop = Boolean(selectedLoop);
|
|
948
|
+
const status = draftVersion ? "draft" : latestVersion ? "published" : "draft";
|
|
949
|
+
|
|
950
|
+
return (
|
|
951
|
+
<>
|
|
952
|
+
<header className="topbar">
|
|
953
|
+
<div>
|
|
954
|
+
<p className="eyebrow">Loop builder</p>
|
|
955
|
+
<h1>{selectedLoop?.name ?? "Create a loop"}</h1>
|
|
956
|
+
</div>
|
|
957
|
+
<button
|
|
958
|
+
className="primary-button"
|
|
959
|
+
type="button"
|
|
960
|
+
onClick={onCreate}
|
|
961
|
+
disabled={busyAction === "create-loop"}
|
|
962
|
+
>
|
|
963
|
+
{busyAction === "create-loop" ? (
|
|
964
|
+
<Loader2 size={17} className="spin" aria-hidden="true" />
|
|
965
|
+
) : (
|
|
966
|
+
<Plus size={17} aria-hidden="true" />
|
|
967
|
+
)}
|
|
968
|
+
<span>New loop</span>
|
|
969
|
+
</button>
|
|
970
|
+
</header>
|
|
971
|
+
|
|
972
|
+
<div className="loops-grid">
|
|
973
|
+
<section className="panel editor-panel" aria-label="Loop editor">
|
|
974
|
+
<PanelTitle icon={GitBranch} title="Loop" />
|
|
975
|
+
{hasLoop ? (
|
|
976
|
+
<div className="form-stack">
|
|
977
|
+
<div className="form-two">
|
|
978
|
+
<Field label="Name" htmlFor="loop-name">
|
|
979
|
+
<input
|
|
980
|
+
id="loop-name"
|
|
981
|
+
value={form.name}
|
|
982
|
+
onChange={(event) => onFieldChange("name", event.target.value)}
|
|
983
|
+
/>
|
|
984
|
+
</Field>
|
|
985
|
+
<Field label="Trigger" htmlFor="loop-trigger">
|
|
986
|
+
<input
|
|
987
|
+
id="loop-trigger"
|
|
988
|
+
value={form.trigger}
|
|
989
|
+
onChange={(event) => onFieldChange("trigger", event.target.value)}
|
|
990
|
+
/>
|
|
991
|
+
</Field>
|
|
992
|
+
</div>
|
|
993
|
+
<Field label="Description" htmlFor="loop-description">
|
|
994
|
+
<input
|
|
995
|
+
id="loop-description"
|
|
996
|
+
value={form.description}
|
|
997
|
+
onChange={(event) => onFieldChange("description", event.target.value)}
|
|
998
|
+
/>
|
|
999
|
+
</Field>
|
|
1000
|
+
<div className="form-two">
|
|
1001
|
+
<Field label="Tags" htmlFor="loop-tags">
|
|
1002
|
+
<input
|
|
1003
|
+
id="loop-tags"
|
|
1004
|
+
value={form.tags}
|
|
1005
|
+
onChange={(event) => onFieldChange("tags", event.target.value)}
|
|
1006
|
+
/>
|
|
1007
|
+
</Field>
|
|
1008
|
+
<Field label="Primary skill" htmlFor="loop-primary-skill">
|
|
1009
|
+
<select
|
|
1010
|
+
id="loop-primary-skill"
|
|
1011
|
+
className="select-control"
|
|
1012
|
+
value={form.primarySkill}
|
|
1013
|
+
onChange={(event) =>
|
|
1014
|
+
onFieldChange("primarySkill", event.target.value)
|
|
1015
|
+
}
|
|
1016
|
+
>
|
|
1017
|
+
<option value="">None</option>
|
|
1018
|
+
{skillRows.map(({ skill }) => (
|
|
1019
|
+
<option key={skill._id} value={skill.slug}>
|
|
1020
|
+
{skill.name}
|
|
1021
|
+
</option>
|
|
1022
|
+
))}
|
|
1023
|
+
</select>
|
|
1024
|
+
</Field>
|
|
1025
|
+
</div>
|
|
1026
|
+
<Field label="Objective" htmlFor="loop-objective">
|
|
1027
|
+
<textarea
|
|
1028
|
+
id="loop-objective"
|
|
1029
|
+
value={form.objective}
|
|
1030
|
+
onChange={(event) => onFieldChange("objective", event.target.value)}
|
|
1031
|
+
/>
|
|
1032
|
+
</Field>
|
|
1033
|
+
|
|
1034
|
+
<div className="step-toolbar">
|
|
1035
|
+
<PanelTitle icon={Layers} title="Steps" />
|
|
1036
|
+
<button
|
|
1037
|
+
className="secondary-button"
|
|
1038
|
+
type="button"
|
|
1039
|
+
onClick={onAddStep}
|
|
1040
|
+
disabled={isBusy}
|
|
1041
|
+
>
|
|
1042
|
+
<Plus size={16} aria-hidden="true" />
|
|
1043
|
+
<span>Add step</span>
|
|
1044
|
+
</button>
|
|
1045
|
+
</div>
|
|
1046
|
+
|
|
1047
|
+
<div className="step-list">
|
|
1048
|
+
{form.steps.map((step, index) => (
|
|
1049
|
+
<StepEditor
|
|
1050
|
+
key={`${step.id}-${index}`}
|
|
1051
|
+
index={index}
|
|
1052
|
+
onChange={onStepChange}
|
|
1053
|
+
onRemove={onRemoveStep}
|
|
1054
|
+
skillRows={skillRows}
|
|
1055
|
+
step={step}
|
|
1056
|
+
totalSteps={form.steps.length}
|
|
1057
|
+
/>
|
|
1058
|
+
))}
|
|
1059
|
+
</div>
|
|
1060
|
+
|
|
1061
|
+
<div className="form-actions">
|
|
1062
|
+
<button
|
|
1063
|
+
className="secondary-button"
|
|
1064
|
+
type="button"
|
|
1065
|
+
onClick={onSave}
|
|
1066
|
+
disabled={isBusy}
|
|
1067
|
+
>
|
|
1068
|
+
{busyAction === "save-loop" ? (
|
|
1069
|
+
<Loader2 size={16} className="spin" aria-hidden="true" />
|
|
1070
|
+
) : (
|
|
1071
|
+
<Save size={16} aria-hidden="true" />
|
|
1072
|
+
)}
|
|
1073
|
+
<span>Save draft</span>
|
|
1074
|
+
</button>
|
|
1075
|
+
<button
|
|
1076
|
+
className="secondary-button"
|
|
1077
|
+
type="button"
|
|
1078
|
+
onClick={onRun}
|
|
1079
|
+
disabled={isBusy}
|
|
1080
|
+
>
|
|
1081
|
+
{busyAction === "run-loop" ? (
|
|
1082
|
+
<Loader2 size={16} className="spin" aria-hidden="true" />
|
|
1083
|
+
) : (
|
|
1084
|
+
<Play size={16} aria-hidden="true" />
|
|
1085
|
+
)}
|
|
1086
|
+
<span>Run loop</span>
|
|
1087
|
+
</button>
|
|
1088
|
+
<button
|
|
1089
|
+
className="primary-button"
|
|
1090
|
+
type="button"
|
|
1091
|
+
onClick={onPublish}
|
|
1092
|
+
disabled={isBusy}
|
|
1093
|
+
>
|
|
1094
|
+
{busyAction === "publish-loop" ? (
|
|
1095
|
+
<Loader2 size={16} className="spin" aria-hidden="true" />
|
|
1096
|
+
) : (
|
|
1097
|
+
<Rocket size={16} aria-hidden="true" />
|
|
1098
|
+
)}
|
|
1099
|
+
<span>Publish</span>
|
|
1100
|
+
</button>
|
|
1101
|
+
</div>
|
|
1102
|
+
</div>
|
|
1103
|
+
) : (
|
|
1104
|
+
<EmptyPanel label="Create a loop" />
|
|
1105
|
+
)}
|
|
1106
|
+
</section>
|
|
1107
|
+
|
|
1108
|
+
<section className="panel preview-panel" aria-label="Loop preview">
|
|
1109
|
+
<PanelTitle icon={FileText} title="Preview" />
|
|
1110
|
+
{hasLoop ? (
|
|
1111
|
+
<div className="preview-stack">
|
|
1112
|
+
<div className="preview-meta">
|
|
1113
|
+
<StatusBadge status={status} />
|
|
1114
|
+
<span>{selectedLoop.slug}</span>
|
|
1115
|
+
</div>
|
|
1116
|
+
<pre className="prompt-preview">{renderLoopPreview(form)}</pre>
|
|
1117
|
+
<VersionHistory versions={versions} />
|
|
1118
|
+
</div>
|
|
1119
|
+
) : (
|
|
1120
|
+
<EmptyPanel label="No loop selected" />
|
|
1121
|
+
)}
|
|
1122
|
+
</section>
|
|
1123
|
+
</div>
|
|
1124
|
+
</>
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function StepEditor({ index, onChange, onRemove, skillRows, step, totalSteps }) {
|
|
1129
|
+
const prefix = `loop-step-${index}`;
|
|
1130
|
+
|
|
1131
|
+
return (
|
|
1132
|
+
<section className="step-card" aria-label={`Loop step ${index + 1}`}>
|
|
1133
|
+
<div className="step-header">
|
|
1134
|
+
<span className="step-index">{index + 1}</span>
|
|
1135
|
+
<strong>{step.label || `Step ${index + 1}`}</strong>
|
|
1136
|
+
<button
|
|
1137
|
+
className="icon-button"
|
|
1138
|
+
type="button"
|
|
1139
|
+
onClick={() => onRemove(index)}
|
|
1140
|
+
disabled={totalSteps <= 1}
|
|
1141
|
+
aria-label={`Remove step ${index + 1}`}
|
|
1142
|
+
title="Remove step"
|
|
1143
|
+
>
|
|
1144
|
+
<Trash2 size={16} aria-hidden="true" />
|
|
1145
|
+
</button>
|
|
1146
|
+
</div>
|
|
1147
|
+
<div className="step-fields">
|
|
1148
|
+
<Field label="Step id" htmlFor={`${prefix}-id`}>
|
|
1149
|
+
<input
|
|
1150
|
+
id={`${prefix}-id`}
|
|
1151
|
+
value={step.id}
|
|
1152
|
+
onChange={(event) => onChange(index, "id", event.target.value)}
|
|
1153
|
+
/>
|
|
1154
|
+
</Field>
|
|
1155
|
+
<Field label="Label" htmlFor={`${prefix}-label`}>
|
|
1156
|
+
<input
|
|
1157
|
+
id={`${prefix}-label`}
|
|
1158
|
+
value={step.label}
|
|
1159
|
+
onChange={(event) => onChange(index, "label", event.target.value)}
|
|
1160
|
+
/>
|
|
1161
|
+
</Field>
|
|
1162
|
+
<Field label="Capability" htmlFor={`${prefix}-action-type`}>
|
|
1163
|
+
<select
|
|
1164
|
+
id={`${prefix}-action-type`}
|
|
1165
|
+
className="select-control"
|
|
1166
|
+
value={step.actionType}
|
|
1167
|
+
onChange={(event) => onChange(index, "actionType", event.target.value)}
|
|
1168
|
+
>
|
|
1169
|
+
<option value="codex.cli">Codex CLI</option>
|
|
1170
|
+
</select>
|
|
1171
|
+
</Field>
|
|
1172
|
+
<Field label="Skill" htmlFor={`${prefix}-skill`}>
|
|
1173
|
+
<select
|
|
1174
|
+
id={`${prefix}-skill`}
|
|
1175
|
+
className="select-control"
|
|
1176
|
+
value={step.skillSlug ?? ""}
|
|
1177
|
+
onChange={(event) => onChange(index, "skillSlug", event.target.value)}
|
|
1178
|
+
>
|
|
1179
|
+
<option value="">None</option>
|
|
1180
|
+
{skillRows.map(({ skill }) => (
|
|
1181
|
+
<option key={skill._id} value={skill.slug}>
|
|
1182
|
+
{skill.name}
|
|
1183
|
+
</option>
|
|
1184
|
+
))}
|
|
1185
|
+
</select>
|
|
1186
|
+
</Field>
|
|
1187
|
+
</div>
|
|
1188
|
+
<Field label="Instructions" htmlFor={`${prefix}-instructions`}>
|
|
1189
|
+
<textarea
|
|
1190
|
+
id={`${prefix}-instructions`}
|
|
1191
|
+
value={step.instructions}
|
|
1192
|
+
onChange={(event) => onChange(index, "instructions", event.target.value)}
|
|
1193
|
+
/>
|
|
1194
|
+
</Field>
|
|
1195
|
+
</section>
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function SkillsWorkspace({
|
|
1200
|
+
busyAction,
|
|
1201
|
+
draftVersion,
|
|
1202
|
+
form,
|
|
1203
|
+
latestVersion,
|
|
1204
|
+
onCreate,
|
|
1205
|
+
onFieldChange,
|
|
1206
|
+
onPublish,
|
|
1207
|
+
onRunTest,
|
|
1208
|
+
onSave,
|
|
1209
|
+
selectedSkill,
|
|
1210
|
+
versions,
|
|
1211
|
+
}) {
|
|
1212
|
+
const isBusy = Boolean(busyAction);
|
|
1213
|
+
const hasSkill = Boolean(selectedSkill);
|
|
1214
|
+
const status = draftVersion ? "draft" : latestVersion ? "published" : "draft";
|
|
1215
|
+
|
|
1216
|
+
return (
|
|
1217
|
+
<>
|
|
1218
|
+
<header className="topbar">
|
|
1219
|
+
<div>
|
|
1220
|
+
<p className="eyebrow">Skill authoring</p>
|
|
1221
|
+
<h1>{selectedSkill?.name ?? "Create a skill"}</h1>
|
|
1222
|
+
</div>
|
|
1223
|
+
<button
|
|
1224
|
+
className="primary-button"
|
|
1225
|
+
type="button"
|
|
1226
|
+
onClick={onCreate}
|
|
1227
|
+
disabled={busyAction === "create-skill"}
|
|
1228
|
+
>
|
|
1229
|
+
{busyAction === "create-skill" ? (
|
|
1230
|
+
<Loader2 size={17} className="spin" aria-hidden="true" />
|
|
1231
|
+
) : (
|
|
1232
|
+
<Plus size={17} aria-hidden="true" />
|
|
1233
|
+
)}
|
|
1234
|
+
<span>New skill</span>
|
|
1235
|
+
</button>
|
|
1236
|
+
</header>
|
|
1237
|
+
|
|
1238
|
+
<div className="skills-grid">
|
|
1239
|
+
<section className="panel editor-panel" aria-label="Skill editor">
|
|
1240
|
+
<PanelTitle icon={Pencil} title="Editor" />
|
|
1241
|
+
{hasSkill ? (
|
|
1242
|
+
<div className="form-stack">
|
|
1243
|
+
<Field label="Name" htmlFor="skill-name">
|
|
1244
|
+
<input
|
|
1245
|
+
id="skill-name"
|
|
1246
|
+
value={form.name}
|
|
1247
|
+
onChange={(event) => onFieldChange("name", event.target.value)}
|
|
1248
|
+
/>
|
|
1249
|
+
</Field>
|
|
1250
|
+
<Field label="Summary" htmlFor="skill-summary">
|
|
1251
|
+
<input
|
|
1252
|
+
id="skill-summary"
|
|
1253
|
+
value={form.summary}
|
|
1254
|
+
onChange={(event) => onFieldChange("summary", event.target.value)}
|
|
1255
|
+
/>
|
|
1256
|
+
</Field>
|
|
1257
|
+
<Field label="Tags" htmlFor="skill-tags">
|
|
1258
|
+
<input
|
|
1259
|
+
id="skill-tags"
|
|
1260
|
+
value={form.tags}
|
|
1261
|
+
onChange={(event) => onFieldChange("tags", event.target.value)}
|
|
1262
|
+
/>
|
|
1263
|
+
</Field>
|
|
1264
|
+
<Field label="Instructions" htmlFor="skill-instructions">
|
|
1265
|
+
<textarea
|
|
1266
|
+
id="skill-instructions"
|
|
1267
|
+
className="textarea-large"
|
|
1268
|
+
value={form.instructions}
|
|
1269
|
+
onChange={(event) =>
|
|
1270
|
+
onFieldChange("instructions", event.target.value)
|
|
1271
|
+
}
|
|
1272
|
+
/>
|
|
1273
|
+
</Field>
|
|
1274
|
+
<div className="form-two">
|
|
1275
|
+
<Field label="Inputs" htmlFor="skill-inputs">
|
|
1276
|
+
<textarea
|
|
1277
|
+
id="skill-inputs"
|
|
1278
|
+
value={form.inputSpec}
|
|
1279
|
+
onChange={(event) =>
|
|
1280
|
+
onFieldChange("inputSpec", event.target.value)
|
|
1281
|
+
}
|
|
1282
|
+
/>
|
|
1283
|
+
</Field>
|
|
1284
|
+
<Field label="Expected output" htmlFor="skill-output">
|
|
1285
|
+
<textarea
|
|
1286
|
+
id="skill-output"
|
|
1287
|
+
value={form.outputSpec}
|
|
1288
|
+
onChange={(event) =>
|
|
1289
|
+
onFieldChange("outputSpec", event.target.value)
|
|
1290
|
+
}
|
|
1291
|
+
/>
|
|
1292
|
+
</Field>
|
|
1293
|
+
</div>
|
|
1294
|
+
<Field label="Test prompt" htmlFor="skill-test-prompt">
|
|
1295
|
+
<textarea
|
|
1296
|
+
id="skill-test-prompt"
|
|
1297
|
+
value={form.testPrompt}
|
|
1298
|
+
onChange={(event) => onFieldChange("testPrompt", event.target.value)}
|
|
1299
|
+
/>
|
|
1300
|
+
</Field>
|
|
1301
|
+
<div className="form-actions">
|
|
1302
|
+
<button
|
|
1303
|
+
className="secondary-button"
|
|
1304
|
+
type="button"
|
|
1305
|
+
onClick={onSave}
|
|
1306
|
+
disabled={isBusy}
|
|
1307
|
+
>
|
|
1308
|
+
{busyAction === "save-skill" ? (
|
|
1309
|
+
<Loader2 size={16} className="spin" aria-hidden="true" />
|
|
1310
|
+
) : (
|
|
1311
|
+
<Save size={16} aria-hidden="true" />
|
|
1312
|
+
)}
|
|
1313
|
+
<span>Save draft</span>
|
|
1314
|
+
</button>
|
|
1315
|
+
<button
|
|
1316
|
+
className="secondary-button"
|
|
1317
|
+
type="button"
|
|
1318
|
+
onClick={onRunTest}
|
|
1319
|
+
disabled={isBusy}
|
|
1320
|
+
>
|
|
1321
|
+
{busyAction === "run-skill" ? (
|
|
1322
|
+
<Loader2 size={16} className="spin" aria-hidden="true" />
|
|
1323
|
+
) : (
|
|
1324
|
+
<Play size={16} aria-hidden="true" />
|
|
1325
|
+
)}
|
|
1326
|
+
<span>Run test</span>
|
|
1327
|
+
</button>
|
|
1328
|
+
<button
|
|
1329
|
+
className="primary-button"
|
|
1330
|
+
type="button"
|
|
1331
|
+
onClick={onPublish}
|
|
1332
|
+
disabled={isBusy}
|
|
1333
|
+
>
|
|
1334
|
+
{busyAction === "publish-skill" ? (
|
|
1335
|
+
<Loader2 size={16} className="spin" aria-hidden="true" />
|
|
1336
|
+
) : (
|
|
1337
|
+
<Rocket size={16} aria-hidden="true" />
|
|
1338
|
+
)}
|
|
1339
|
+
<span>Publish</span>
|
|
1340
|
+
</button>
|
|
1341
|
+
</div>
|
|
1342
|
+
</div>
|
|
1343
|
+
) : (
|
|
1344
|
+
<EmptyPanel label="Create a skill" />
|
|
1345
|
+
)}
|
|
1346
|
+
</section>
|
|
1347
|
+
|
|
1348
|
+
<section className="panel preview-panel" aria-label="Skill preview">
|
|
1349
|
+
<PanelTitle icon={FileText} title="Preview" />
|
|
1350
|
+
{hasSkill ? (
|
|
1351
|
+
<div className="preview-stack">
|
|
1352
|
+
<div className="preview-meta">
|
|
1353
|
+
<StatusBadge status={status} />
|
|
1354
|
+
<span>{selectedSkill.slug}</span>
|
|
1355
|
+
</div>
|
|
1356
|
+
<pre className="prompt-preview">{renderSkillPreview(form)}</pre>
|
|
1357
|
+
<VersionHistory versions={versions} />
|
|
1358
|
+
</div>
|
|
1359
|
+
) : (
|
|
1360
|
+
<EmptyPanel label="No skill selected" />
|
|
1361
|
+
)}
|
|
1362
|
+
</section>
|
|
1363
|
+
</div>
|
|
1364
|
+
</>
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function RunsWorkspace({
|
|
1369
|
+
busyAction,
|
|
1370
|
+
events,
|
|
1371
|
+
hostActions,
|
|
1372
|
+
logs,
|
|
1373
|
+
metrics,
|
|
1374
|
+
onStartDemo,
|
|
1375
|
+
selectedRun,
|
|
1376
|
+
}) {
|
|
1377
|
+
return (
|
|
1378
|
+
<>
|
|
1379
|
+
<header className="topbar">
|
|
1380
|
+
<div>
|
|
1381
|
+
<p className="eyebrow">Realtime bridge</p>
|
|
1382
|
+
<h1>{selectedRun?.loopSlug ?? "No run selected"}</h1>
|
|
1383
|
+
</div>
|
|
1384
|
+
<button
|
|
1385
|
+
className="primary-button"
|
|
1386
|
+
type="button"
|
|
1387
|
+
onClick={onStartDemo}
|
|
1388
|
+
disabled={busyAction === "demo"}
|
|
1389
|
+
>
|
|
1390
|
+
{busyAction === "demo" ? (
|
|
1391
|
+
<Loader2 size={17} className="spin" aria-hidden="true" />
|
|
1392
|
+
) : (
|
|
1393
|
+
<Play size={17} aria-hidden="true" />
|
|
1394
|
+
)}
|
|
1395
|
+
<span>Demo run</span>
|
|
1396
|
+
</button>
|
|
1397
|
+
</header>
|
|
1398
|
+
|
|
1399
|
+
<div className="metric-grid" aria-label="Run metrics">
|
|
1400
|
+
<Metric label="Active runs" value={metrics.activeRuns} icon={Activity} />
|
|
1401
|
+
<Metric label="Pending actions" value={metrics.pendingActions} icon={Clock3} />
|
|
1402
|
+
<Metric label="Live logs" value={metrics.liveLogs} icon={TerminalSquare} />
|
|
1403
|
+
</div>
|
|
1404
|
+
|
|
1405
|
+
<div className="content-grid">
|
|
1406
|
+
<section className="panel run-panel">
|
|
1407
|
+
<PanelTitle icon={Radio} title="Run State" />
|
|
1408
|
+
{selectedRun ? <RunState run={selectedRun} /> : <EmptyPanel label="Select a run" />}
|
|
1409
|
+
</section>
|
|
1410
|
+
|
|
1411
|
+
<section className="panel actions-panel">
|
|
1412
|
+
<PanelTitle icon={Bot} title="Host Actions" />
|
|
1413
|
+
{hostActions.length ? (
|
|
1414
|
+
<div className="action-list">
|
|
1415
|
+
{hostActions.map((action) => (
|
|
1416
|
+
<ActionItem key={action._id} action={action} />
|
|
1417
|
+
))}
|
|
1418
|
+
</div>
|
|
1419
|
+
) : (
|
|
1420
|
+
<EmptyPanel label="No host actions" />
|
|
1421
|
+
)}
|
|
1422
|
+
</section>
|
|
1423
|
+
|
|
1424
|
+
<section className="panel stream-panel">
|
|
1425
|
+
<PanelTitle icon={TerminalSquare} title="Events And Logs" />
|
|
1426
|
+
<Stream events={events} logs={logs} />
|
|
1427
|
+
</section>
|
|
1428
|
+
</div>
|
|
1429
|
+
</>
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function LoopListItem({ loop, latestVersion, draftVersion, selected, onSelect }) {
|
|
1434
|
+
const status = draftVersion ? "draft" : latestVersion ? "published" : "draft";
|
|
1435
|
+
|
|
1436
|
+
return (
|
|
1437
|
+
<button
|
|
1438
|
+
className={`loop-item ${selected ? "selected" : ""}`}
|
|
1439
|
+
type="button"
|
|
1440
|
+
onClick={onSelect}
|
|
1441
|
+
>
|
|
1442
|
+
<span className={`status-dot ${statusTone[status] ?? "neutral"}`}>
|
|
1443
|
+
<GitBranch size={14} aria-hidden="true" />
|
|
1444
|
+
</span>
|
|
1445
|
+
<span className="run-item-main">
|
|
1446
|
+
<span className="run-name">{loop.name}</span>
|
|
1447
|
+
<span className="run-meta">
|
|
1448
|
+
{status} - {formatTime(loop.updatedAt)}
|
|
1449
|
+
</span>
|
|
1450
|
+
</span>
|
|
1451
|
+
</button>
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function SkillListItem({ skill, latestVersion, draftVersion, selected, onSelect }) {
|
|
1456
|
+
const status = draftVersion ? "draft" : latestVersion ? "published" : "draft";
|
|
1457
|
+
|
|
1458
|
+
return (
|
|
1459
|
+
<button
|
|
1460
|
+
className={`skill-item ${selected ? "selected" : ""}`}
|
|
1461
|
+
type="button"
|
|
1462
|
+
onClick={onSelect}
|
|
1463
|
+
>
|
|
1464
|
+
<span className={`status-dot ${statusTone[status] ?? "neutral"}`}>
|
|
1465
|
+
<BookOpen size={14} aria-hidden="true" />
|
|
1466
|
+
</span>
|
|
1467
|
+
<span className="run-item-main">
|
|
1468
|
+
<span className="run-name">{skill.name}</span>
|
|
1469
|
+
<span className="run-meta">
|
|
1470
|
+
{status} - {formatTime(skill.updatedAt)}
|
|
1471
|
+
</span>
|
|
1472
|
+
</span>
|
|
1473
|
+
</button>
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function RunListItem({ run, selected, onSelect }) {
|
|
1478
|
+
const Icon = statusIcons[run.status] ?? Activity;
|
|
1479
|
+
|
|
1480
|
+
return (
|
|
1481
|
+
<button
|
|
1482
|
+
className={`run-item ${selected ? "selected" : ""}`}
|
|
1483
|
+
type="button"
|
|
1484
|
+
onClick={onSelect}
|
|
1485
|
+
>
|
|
1486
|
+
<span className={`status-dot ${statusTone[run.status] ?? "neutral"}`}>
|
|
1487
|
+
<Icon size={14} aria-hidden="true" />
|
|
1488
|
+
</span>
|
|
1489
|
+
<span className="run-item-main">
|
|
1490
|
+
<span className="run-name">{run.loopSlug}</span>
|
|
1491
|
+
<span className="run-meta">{formatTime(run.updatedAt)}</span>
|
|
1492
|
+
</span>
|
|
1493
|
+
</button>
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function RunState({ run }) {
|
|
1498
|
+
return (
|
|
1499
|
+
<dl className="state-grid">
|
|
1500
|
+
<StateItem label="Status" value={<StatusBadge status={run.status} />} />
|
|
1501
|
+
<StateItem label="Active node" value={run.activeNodeId ?? "none"} />
|
|
1502
|
+
<StateItem label="Transitions" value={run.transitionCount} />
|
|
1503
|
+
<StateItem label="Started" value={formatDateTime(run.startedAt)} />
|
|
1504
|
+
<StateItem label="Updated" value={formatDateTime(run.updatedAt)} />
|
|
1505
|
+
<StateItem label="Prompt" value={run.prompt ?? "none"} wide />
|
|
1506
|
+
</dl>
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function StateItem({ label, value, wide = false }) {
|
|
1511
|
+
return (
|
|
1512
|
+
<div className={`state-item ${wide ? "wide" : ""}`}>
|
|
1513
|
+
<dt>{label}</dt>
|
|
1514
|
+
<dd>{value}</dd>
|
|
1515
|
+
</div>
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
function ActionItem({ action }) {
|
|
1520
|
+
return (
|
|
1521
|
+
<article className="action-item">
|
|
1522
|
+
<div className="action-main">
|
|
1523
|
+
<h3>{action.title}</h3>
|
|
1524
|
+
<p>{action.actionType}</p>
|
|
1525
|
+
</div>
|
|
1526
|
+
<StatusBadge status={action.status} />
|
|
1527
|
+
</article>
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function Stream({ events, logs }) {
|
|
1532
|
+
const rows = [
|
|
1533
|
+
...events.map((event) => ({
|
|
1534
|
+
id: `event:${event._id}`,
|
|
1535
|
+
level: event.level,
|
|
1536
|
+
message: event.message ?? event.eventType,
|
|
1537
|
+
timestamp: event.createdAt,
|
|
1538
|
+
detail: event.eventType,
|
|
1539
|
+
})),
|
|
1540
|
+
...logs.map((log) => ({
|
|
1541
|
+
id: `log:${log._id}`,
|
|
1542
|
+
level: log.level,
|
|
1543
|
+
message: log.message,
|
|
1544
|
+
timestamp: log.createdAt,
|
|
1545
|
+
detail: log.source,
|
|
1546
|
+
})),
|
|
1547
|
+
].sort((a, b) => a.timestamp - b.timestamp);
|
|
1548
|
+
|
|
1549
|
+
if (!rows.length) {
|
|
1550
|
+
return <EmptyPanel label="No stream entries" />;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
return (
|
|
1554
|
+
<div className="stream-list">
|
|
1555
|
+
{rows.map((row) => (
|
|
1556
|
+
<div className="stream-row" key={row.id}>
|
|
1557
|
+
<span className={`stream-level ${row.level}`}>{row.level}</span>
|
|
1558
|
+
<div className="stream-copy">
|
|
1559
|
+
<span>{row.message}</span>
|
|
1560
|
+
<small>
|
|
1561
|
+
{row.detail} - {formatTime(row.timestamp)}
|
|
1562
|
+
</small>
|
|
1563
|
+
</div>
|
|
1564
|
+
</div>
|
|
1565
|
+
))}
|
|
1566
|
+
</div>
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
function Field({ children, htmlFor, label }) {
|
|
1571
|
+
return (
|
|
1572
|
+
<label className="field" htmlFor={htmlFor}>
|
|
1573
|
+
<span>{label}</span>
|
|
1574
|
+
{children}
|
|
1575
|
+
</label>
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function Metric({ label, value, icon: Icon }) {
|
|
1580
|
+
return (
|
|
1581
|
+
<section className="metric">
|
|
1582
|
+
<Icon size={18} aria-hidden="true" />
|
|
1583
|
+
<span>{label}</span>
|
|
1584
|
+
<strong>{value}</strong>
|
|
1585
|
+
</section>
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
function PanelTitle({ icon: Icon, title }) {
|
|
1590
|
+
return (
|
|
1591
|
+
<div className="panel-title">
|
|
1592
|
+
<Icon size={17} aria-hidden="true" />
|
|
1593
|
+
<h2>{title}</h2>
|
|
1594
|
+
</div>
|
|
1595
|
+
);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function VersionHistory({ versions }) {
|
|
1599
|
+
return (
|
|
1600
|
+
<details className="version-disclosure">
|
|
1601
|
+
<summary>
|
|
1602
|
+
<ListChecks size={15} aria-hidden="true" />
|
|
1603
|
+
<span>Version history</span>
|
|
1604
|
+
</summary>
|
|
1605
|
+
<VersionList versions={versions} />
|
|
1606
|
+
</details>
|
|
1607
|
+
);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function VersionList({ versions }) {
|
|
1611
|
+
return (
|
|
1612
|
+
<div className="version-list">
|
|
1613
|
+
{versions.length ? (
|
|
1614
|
+
versions.map((version) => (
|
|
1615
|
+
<div className="version-row" key={version._id}>
|
|
1616
|
+
<div>
|
|
1617
|
+
<strong>{version.version}</strong>
|
|
1618
|
+
<span>{formatDateTime(version.updatedAt)}</span>
|
|
1619
|
+
</div>
|
|
1620
|
+
<StatusBadge status={version.status} />
|
|
1621
|
+
</div>
|
|
1622
|
+
))
|
|
1623
|
+
) : (
|
|
1624
|
+
<EmptyPanel label="No versions" />
|
|
1625
|
+
)}
|
|
1626
|
+
</div>
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
function StatusBadge({ status }) {
|
|
1631
|
+
return <span className={`status-badge ${statusTone[status] ?? "neutral"}`}>{status}</span>;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
function EmptyPanel({ label }) {
|
|
1635
|
+
return <div className="empty-panel">{label}</div>;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function LoadingRow({ label }) {
|
|
1639
|
+
return (
|
|
1640
|
+
<div className="loading-row">
|
|
1641
|
+
<Loader2 size={15} className="spin" aria-hidden="true" />
|
|
1642
|
+
<span>{label}</span>
|
|
1643
|
+
</div>
|
|
1644
|
+
);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
function renderLoopPreview(form) {
|
|
1648
|
+
return [
|
|
1649
|
+
form.name ? `Loop: ${form.name}` : "Loop: Untitled",
|
|
1650
|
+
form.description ? `Description: ${form.description}` : null,
|
|
1651
|
+
form.tags ? `Tags: ${form.tags}` : null,
|
|
1652
|
+
form.primarySkill ? `Primary skill: ${form.primarySkill}` : null,
|
|
1653
|
+
"",
|
|
1654
|
+
"Objective:",
|
|
1655
|
+
form.objective || "No objective yet.",
|
|
1656
|
+
"",
|
|
1657
|
+
"Steps:",
|
|
1658
|
+
...form.steps.map((step, index) =>
|
|
1659
|
+
[
|
|
1660
|
+
`${index + 1}. ${step.label || `Step ${index + 1}`}`,
|
|
1661
|
+
` Capability: ${step.actionType || "codex.cli"}`,
|
|
1662
|
+
step.skillSlug ? ` Skill: ${step.skillSlug}` : null,
|
|
1663
|
+
` ${step.instructions || "No instructions yet."}`,
|
|
1664
|
+
]
|
|
1665
|
+
.filter(Boolean)
|
|
1666
|
+
.join("\n"),
|
|
1667
|
+
),
|
|
1668
|
+
]
|
|
1669
|
+
.filter(Boolean)
|
|
1670
|
+
.join("\n");
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function renderSkillPreview(form) {
|
|
1674
|
+
return [
|
|
1675
|
+
form.name ? `Skill: ${form.name}` : "Skill: Untitled",
|
|
1676
|
+
form.summary ? `Summary: ${form.summary}` : null,
|
|
1677
|
+
form.tags ? `Tags: ${form.tags}` : null,
|
|
1678
|
+
"",
|
|
1679
|
+
"Instructions:",
|
|
1680
|
+
form.instructions || "No instructions yet.",
|
|
1681
|
+
"",
|
|
1682
|
+
form.inputSpec ? `Inputs:\n${form.inputSpec}` : null,
|
|
1683
|
+
form.outputSpec ? `Expected output:\n${form.outputSpec}` : null,
|
|
1684
|
+
form.testPrompt ? `Test prompt:\n${form.testPrompt}` : null,
|
|
1685
|
+
]
|
|
1686
|
+
.filter(Boolean)
|
|
1687
|
+
.join("\n");
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function normalizeUiSteps(steps) {
|
|
1691
|
+
if (!steps.length) {
|
|
1692
|
+
return [
|
|
1693
|
+
{
|
|
1694
|
+
id: "step-1",
|
|
1695
|
+
label: "Plan the work",
|
|
1696
|
+
actionType: "codex.cli",
|
|
1697
|
+
skillSlug: "",
|
|
1698
|
+
instructions: "Read the objective, identify risks, and produce a short plan.",
|
|
1699
|
+
},
|
|
1700
|
+
];
|
|
1701
|
+
}
|
|
1702
|
+
return steps.map((step, index) => ({
|
|
1703
|
+
id: step.id ?? `step-${index + 1}`,
|
|
1704
|
+
label: step.label ?? `Step ${index + 1}`,
|
|
1705
|
+
actionType: step.actionType ?? "codex.cli",
|
|
1706
|
+
skillSlug: step.skillSlug ?? "",
|
|
1707
|
+
instructions: step.instructions ?? "",
|
|
1708
|
+
}));
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
function parseTags(value) {
|
|
1712
|
+
return value
|
|
1713
|
+
.split(",")
|
|
1714
|
+
.map((tag) => tag.trim())
|
|
1715
|
+
.filter(Boolean);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
function optionalValue(value) {
|
|
1719
|
+
const cleaned = value.trim();
|
|
1720
|
+
return cleaned ? cleaned : undefined;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
function errorMessage(cause) {
|
|
1724
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
function formatTime(value) {
|
|
1728
|
+
if (!value) return "unknown";
|
|
1729
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
1730
|
+
hour: "2-digit",
|
|
1731
|
+
minute: "2-digit",
|
|
1732
|
+
}).format(new Date(value));
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
function formatDateTime(value) {
|
|
1736
|
+
if (!value) return "unknown";
|
|
1737
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
1738
|
+
month: "short",
|
|
1739
|
+
day: "2-digit",
|
|
1740
|
+
hour: "2-digit",
|
|
1741
|
+
minute: "2-digit",
|
|
1742
|
+
}).format(new Date(value));
|
|
1743
|
+
}
|