@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.
@@ -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
+ }