@polpo-ai/dashboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +44 -0
  3. package/app/(dashboard)/error.tsx +47 -0
  4. package/app/(dashboard)/layout.tsx +41 -0
  5. package/app/(dashboard)/loading.tsx +30 -0
  6. package/app/(dashboard)/projects/[id]/agents/[name]/memory/view.tsx +36 -0
  7. package/app/(dashboard)/projects/[id]/agents/[name]/models-view.tsx +30 -0
  8. package/app/(dashboard)/projects/[id]/agents/[name]/page.tsx +51 -0
  9. package/app/(dashboard)/projects/[id]/agents/[name]/prompt/view.tsx +211 -0
  10. package/app/(dashboard)/projects/[id]/agents/[name]/skills/view.tsx +84 -0
  11. package/app/(dashboard)/projects/[id]/agents/[name]/tools/view.tsx +525 -0
  12. package/app/(dashboard)/projects/[id]/agents/[name]/vault/view.tsx +61 -0
  13. package/app/(dashboard)/projects/[id]/agents/page.tsx +39 -0
  14. package/app/(dashboard)/projects/[id]/agents/view.tsx +283 -0
  15. package/app/(dashboard)/projects/[id]/layout.tsx +11 -0
  16. package/app/(dashboard)/projects/[id]/logs/page.tsx +27 -0
  17. package/app/(dashboard)/projects/[id]/logs/view.tsx +184 -0
  18. package/app/(dashboard)/projects/[id]/memory/page.tsx +40 -0
  19. package/app/(dashboard)/projects/[id]/memory/view.tsx +17 -0
  20. package/app/(dashboard)/projects/[id]/missions/[missionId]/page.tsx +88 -0
  21. package/app/(dashboard)/projects/[id]/missions/[missionId]/view.tsx +569 -0
  22. package/app/(dashboard)/projects/[id]/missions/page.tsx +32 -0
  23. package/app/(dashboard)/projects/[id]/missions/view.tsx +282 -0
  24. package/app/(dashboard)/projects/[id]/onboarding-checklist.tsx +359 -0
  25. package/app/(dashboard)/projects/[id]/page.tsx +68 -0
  26. package/app/(dashboard)/projects/[id]/playbooks/[name]/page.tsx +5 -0
  27. package/app/(dashboard)/projects/[id]/playbooks/[name]/view.tsx +433 -0
  28. package/app/(dashboard)/projects/[id]/playbooks/page.tsx +5 -0
  29. package/app/(dashboard)/projects/[id]/playbooks/view.tsx +222 -0
  30. package/app/(dashboard)/projects/[id]/schedules/page.tsx +44 -0
  31. package/app/(dashboard)/projects/[id]/schedules/view.tsx +385 -0
  32. package/app/(dashboard)/projects/[id]/sessions/[sessionId]/page.tsx +55 -0
  33. package/app/(dashboard)/projects/[id]/sessions/[sessionId]/raw-view.tsx +373 -0
  34. package/app/(dashboard)/projects/[id]/sessions/[sessionId]/view.tsx +124 -0
  35. package/app/(dashboard)/projects/[id]/sessions/page.tsx +31 -0
  36. package/app/(dashboard)/projects/[id]/sessions/view.tsx +242 -0
  37. package/app/(dashboard)/projects/[id]/settings/page.tsx +52 -0
  38. package/app/(dashboard)/projects/[id]/skills/[name]/page.tsx +65 -0
  39. package/app/(dashboard)/projects/[id]/skills/[name]/view.tsx +227 -0
  40. package/app/(dashboard)/projects/[id]/skills/page.tsx +31 -0
  41. package/app/(dashboard)/projects/[id]/skills/view.tsx +243 -0
  42. package/app/(dashboard)/projects/[id]/storage/page.tsx +5 -0
  43. package/app/(dashboard)/projects/[id]/storage/view.tsx +385 -0
  44. package/app/(dashboard)/projects/[id]/tasks/[taskId]/activity/view.tsx +336 -0
  45. package/app/(dashboard)/projects/[id]/tasks/[taskId]/assessment/view.tsx +113 -0
  46. package/app/(dashboard)/projects/[id]/tasks/[taskId]/blueprint.ts +72 -0
  47. package/app/(dashboard)/projects/[id]/tasks/[taskId]/output/view.tsx +90 -0
  48. package/app/(dashboard)/projects/[id]/tasks/[taskId]/page.tsx +63 -0
  49. package/app/(dashboard)/projects/[id]/tasks/[taskId]/view.tsx +137 -0
  50. package/app/(dashboard)/projects/[id]/tasks/page.tsx +40 -0
  51. package/app/(dashboard)/projects/[id]/tasks/view.tsx +421 -0
  52. package/app/(dashboard)/projects/[id]/view.tsx +421 -0
  53. package/app/(dashboard)/projects/[id]/welcome-banner.tsx +156 -0
  54. package/app/(dashboard)/projects/projects-list.tsx +126 -0
  55. package/app/(playground)/error.tsx +42 -0
  56. package/app/(playground)/layout.tsx +27 -0
  57. package/app/(playground)/projects/[id]/playground/page.tsx +57 -0
  58. package/app/(playground)/projects/[id]/playground/view.tsx +167 -0
  59. package/app/(playground)/projects/[id]/playground-legacy/page.tsx +47 -0
  60. package/app/(playground)/projects/[id]/playground-legacy/skeleton.tsx +63 -0
  61. package/app/(playground)/projects/[id]/playground-legacy/view.tsx +237 -0
  62. package/app/favicon.ico +0 -0
  63. package/app/globals.css +385 -0
  64. package/app/icon.svg +6 -0
  65. package/app/layout.tsx +39 -0
  66. package/app/page.tsx +11 -0
  67. package/components/ai-elements/code-block.tsx +562 -0
  68. package/components/dashboard/agent-capabilities.tsx +326 -0
  69. package/components/dashboard/agent-identity-header.tsx +50 -0
  70. package/components/dashboard/agent-model-picker.tsx +195 -0
  71. package/components/dashboard/agent-model-selector.tsx +121 -0
  72. package/components/dashboard/agent-picker-card.tsx +46 -0
  73. package/components/dashboard/agent-studio.tsx +242 -0
  74. package/components/dashboard/agents-table.tsx +48 -0
  75. package/components/dashboard/breadcrumb.tsx +70 -0
  76. package/components/dashboard/builder-chat.tsx +418 -0
  77. package/components/dashboard/chat-shell.tsx +40 -0
  78. package/components/dashboard/client-picker.tsx +63 -0
  79. package/components/dashboard/command-snippet.tsx +77 -0
  80. package/components/dashboard/connect-dialog.tsx +680 -0
  81. package/components/dashboard/copy-button.tsx +45 -0
  82. package/components/dashboard/copy-card.tsx +48 -0
  83. package/components/dashboard/file-browser.tsx +161 -0
  84. package/components/dashboard/hint.tsx +22 -0
  85. package/components/dashboard/inference-mode.tsx +15 -0
  86. package/components/dashboard/manual-refresh-button.tsx +52 -0
  87. package/components/dashboard/markdown.tsx +31 -0
  88. package/components/dashboard/mcp-install-panel.tsx +196 -0
  89. package/components/dashboard/mission-graph.tsx +262 -0
  90. package/components/dashboard/nav-tabs.tsx +86 -0
  91. package/components/dashboard/polpo-chat.tsx +468 -0
  92. package/components/dashboard/project-copilot.tsx +253 -0
  93. package/components/dashboard/sdk-snippet-panel.tsx +194 -0
  94. package/components/dashboard/section-header.tsx +37 -0
  95. package/components/dashboard/settings-form.tsx +2477 -0
  96. package/components/dashboard/sidebar.tsx +385 -0
  97. package/components/dashboard/skeletons.tsx +1054 -0
  98. package/components/dashboard/skills-install-wizard.tsx +273 -0
  99. package/components/dashboard/swarm-runs.tsx +316 -0
  100. package/components/dashboard/tab-toggle.tsx +35 -0
  101. package/components/dashboard/task-studio.tsx +224 -0
  102. package/components/dashboard/top-header.tsx +203 -0
  103. package/components/dashboard/webhook-deliveries.tsx +234 -0
  104. package/components/icons/coding-agents.tsx +151 -0
  105. package/components/json-ld.tsx +8 -0
  106. package/components/providers.tsx +25 -0
  107. package/components/ui/badge.tsx +52 -0
  108. package/components/ui/button.tsx +60 -0
  109. package/components/ui/card.tsx +103 -0
  110. package/components/ui/chart.tsx +356 -0
  111. package/components/ui/command.tsx +196 -0
  112. package/components/ui/dialog.tsx +157 -0
  113. package/components/ui/input-group.tsx +158 -0
  114. package/components/ui/input.tsx +20 -0
  115. package/components/ui/multi-select.tsx +161 -0
  116. package/components/ui/popover.tsx +90 -0
  117. package/components/ui/select.tsx +201 -0
  118. package/components/ui/separator.tsx +25 -0
  119. package/components/ui/sheet.tsx +135 -0
  120. package/components/ui/skeleton.tsx +13 -0
  121. package/components/ui/tabs.tsx +56 -0
  122. package/components/ui/textarea.tsx +18 -0
  123. package/components/ui/tooltip.tsx +66 -0
  124. package/hooks/use-desktop-sidebar.tsx +50 -0
  125. package/hooks/use-mobile-sidebar.tsx +37 -0
  126. package/index.ts +14 -0
  127. package/lib/api.ts +194 -0
  128. package/lib/builder-context.ts +60 -0
  129. package/lib/data-client.ts +68 -0
  130. package/lib/get-query-client.ts +25 -0
  131. package/lib/polpo-client.tsx +49 -0
  132. package/lib/tool-catalog.ts +234 -0
  133. package/lib/use-event-catalog.ts +28 -0
  134. package/lib/utils.ts +6 -0
  135. package/package.json +99 -0
@@ -0,0 +1,273 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useParams } from "next/navigation";
5
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6
+ import {
7
+ ArrowLeft,
8
+ BookMarked,
9
+ Check,
10
+ Download,
11
+ FilePlus2,
12
+ Link2,
13
+ Loader2,
14
+ Search,
15
+ } from "lucide-react";
16
+ import { usePolpoClient } from "#/lib/polpo-client";
17
+ import { usePolpoSkillsAdapter } from "@lumea-labs/skills-polpo";
18
+ import { SkillsProvider, SkillCreateForm } from "@lumea-labs/skills";
19
+ import {
20
+ Dialog,
21
+ DialogContent,
22
+ DialogDescription,
23
+ DialogHeader,
24
+ DialogTitle,
25
+ DialogTrigger,
26
+ } from "#/components/ui/dialog";
27
+
28
+ /**
29
+ * SkillsInstallWizard — replaces the old read-only "how to install" dialog
30
+ * with a real, guided stepper that ACTUALLY installs skills, dogfooding the
31
+ * public SDK end to end:
32
+ * - registry browse → the Mastra `skills-api` / skills.sh registry (the
33
+ * browse surface Polpo itself doesn't expose) → client.installSkills(url)
34
+ * - from a URL → client.installSkills(githubUrl)
35
+ * - paste / create → @lumea-labs/skills <SkillCreateForm> over the
36
+ * Polpo-wired adapter (adapter.createSkill)
37
+ *
38
+ * The wizard lives inside <SkillsProvider adapter={polpoAdapter}> so the
39
+ * lumea-agents skills UI (SkillCreateForm here, more later) is wired to Polpo.
40
+ */
41
+
42
+ const SKILLS_API = "https://skills.sh/api/skills";
43
+
44
+ type Source = "registry" | "url" | "paste";
45
+
46
+ interface RegistrySkill {
47
+ id?: string;
48
+ name: string;
49
+ description?: string;
50
+ owner?: string;
51
+ repo?: string;
52
+ repository?: string;
53
+ repositoryUrl?: string;
54
+ installs?: number;
55
+ }
56
+
57
+ /** Best-effort GitHub source for a skills.sh registry record. */
58
+ function sourceFor(s: RegistrySkill): string {
59
+ if (s.repositoryUrl) return s.repositoryUrl;
60
+ if (s.repository?.startsWith("http")) return s.repository;
61
+ if (s.owner && s.repo) return `https://github.com/${s.owner}/${s.repo}`;
62
+ return s.repository ?? s.name;
63
+ }
64
+
65
+ export function SkillsInstallWizard() {
66
+ const { adapter } = usePolpoSkillsAdapter();
67
+ return (
68
+ <SkillsProvider adapter={adapter}>
69
+ <WizardDialog />
70
+ </SkillsProvider>
71
+ );
72
+ }
73
+
74
+ function WizardDialog() {
75
+ const { id } = useParams<{ id: string }>();
76
+ const client = usePolpoClient(id);
77
+ const qc = useQueryClient();
78
+
79
+ const [open, setOpen] = useState(false);
80
+ const [source, setSource] = useState<Source | null>(null);
81
+ const [search, setSearch] = useState("");
82
+ const [url, setUrl] = useState("");
83
+ const [done, setDone] = useState<string | null>(null);
84
+
85
+ const reset = () => {
86
+ setSource(null);
87
+ setSearch("");
88
+ setUrl("");
89
+ setDone(null);
90
+ install.reset();
91
+ };
92
+
93
+ const { data: registry = [], isFetching, error: regError } = useQuery({
94
+ queryKey: ["skills-registry", search],
95
+ queryFn: async (): Promise<RegistrySkill[]> => {
96
+ const u = new URL(SKILLS_API);
97
+ u.searchParams.set("sortBy", "installs");
98
+ u.searchParams.set("pageSize", "30");
99
+ if (search.trim()) u.searchParams.set("query", search.trim());
100
+ const res = await fetch(u.toString());
101
+ if (!res.ok) throw new Error(`registry ${res.status}`);
102
+ const json = await res.json();
103
+ return (json.skills ?? json.data ?? json.results ?? json) as RegistrySkill[];
104
+ },
105
+ enabled: open && source === "registry",
106
+ staleTime: 60_000,
107
+ });
108
+
109
+ const install = useMutation({
110
+ mutationFn: (src: string) => client.installSkills(src),
111
+ onSuccess: (_res, src) => {
112
+ qc.invalidateQueries({ queryKey: ["skills", id] });
113
+ setDone(src);
114
+ },
115
+ });
116
+
117
+ const afterCreate = () => {
118
+ qc.invalidateQueries({ queryKey: ["skills", id] });
119
+ setDone("created");
120
+ };
121
+
122
+ return (
123
+ <Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) reset(); }}>
124
+ <DialogTrigger className="inline-flex items-center gap-1.5 border border-foreground bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-colors hover:bg-foreground/90">
125
+ <Download className="h-3.5 w-3.5" strokeWidth={2} />
126
+ Install skills
127
+ </DialogTrigger>
128
+ <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
129
+ <DialogHeader>
130
+ <DialogTitle>Install skills</DialogTitle>
131
+ <DialogDescription>
132
+ Skills inject domain knowledge into your agents&apos; prompts — no code
133
+ changes. Pick a source, install, then assign to agents.
134
+ </DialogDescription>
135
+ </DialogHeader>
136
+
137
+ {/* ── Result ─────────────────────────────────────────── */}
138
+ {done ? (
139
+ <div className="flex flex-col items-center gap-3 py-8 text-center">
140
+ <div className="grid h-10 w-10 place-items-center rounded-full bg-emerald-500/15 text-emerald-500">
141
+ <Check className="h-5 w-5" />
142
+ </div>
143
+ <p className="text-sm font-medium">Skill installed</p>
144
+ <p className="max-w-sm font-mono text-[11px] text-muted-foreground break-all">{done}</p>
145
+ <div className="mt-2 flex gap-2">
146
+ <button onClick={reset} className="border border-border px-3 py-1.5 text-xs hover:border-foreground/30">
147
+ Install another
148
+ </button>
149
+ <button onClick={() => setOpen(false)} className="border border-foreground bg-foreground px-3 py-1.5 text-xs text-background hover:bg-foreground/90">
150
+ Done
151
+ </button>
152
+ </div>
153
+ </div>
154
+ ) : !source ? (
155
+ /* ── Step 1: choose a source ──────────────────────── */
156
+ <div className="grid gap-2 pt-1">
157
+ <SourceCard
158
+ icon={<BookMarked className="h-4 w-4" />}
159
+ title="Browse the registry"
160
+ desc="34k+ skills from skills.sh — search and install."
161
+ onClick={() => setSource("registry")}
162
+ />
163
+ <SourceCard
164
+ icon={<Link2 className="h-4 w-4" />}
165
+ title="From a URL"
166
+ desc="Any public GitHub repo with a skill pack."
167
+ onClick={() => setSource("url")}
168
+ />
169
+ <SourceCard
170
+ icon={<FilePlus2 className="h-4 w-4" />}
171
+ title="Paste / create"
172
+ desc="Author a skill inline from its contents."
173
+ onClick={() => setSource("paste")}
174
+ />
175
+ </div>
176
+ ) : (
177
+ <div className="flex flex-col gap-3 pt-1">
178
+ <button onClick={() => { setSource(null); install.reset(); }} className="inline-flex w-fit items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground">
179
+ <ArrowLeft className="h-3 w-3" /> Back
180
+ </button>
181
+
182
+ {/* ── Registry ──────────────────────────────────── */}
183
+ {source === "registry" && (
184
+ <>
185
+ <div className="flex items-center gap-2 border border-border px-2.5">
186
+ <Search className="h-3.5 w-3.5 text-muted-foreground" />
187
+ <input
188
+ value={search}
189
+ onChange={(e) => setSearch(e.target.value)}
190
+ placeholder="Search skills…"
191
+ className="w-full bg-transparent py-2 text-sm outline-none"
192
+ />
193
+ </div>
194
+ {isFetching ? (
195
+ <div className="flex items-center gap-2 py-6 text-xs text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin" /> loading registry…</div>
196
+ ) : regError ? (
197
+ <p className="py-6 text-center text-xs text-muted-foreground">Registry unavailable. Try the URL option.</p>
198
+ ) : (
199
+ <ul className="flex max-h-[40vh] flex-col gap-1 overflow-y-auto">
200
+ {registry.map((s) => (
201
+ <li key={s.id ?? `${s.owner}/${s.repo}/${s.name}`} className="flex items-center justify-between gap-3 border border-border px-3 py-2">
202
+ <div className="min-w-0">
203
+ <div className="truncate text-sm font-medium">{s.name}</div>
204
+ {s.description && <div className="truncate text-[11px] text-muted-foreground">{s.description}</div>}
205
+ </div>
206
+ <button
207
+ disabled={install.isPending}
208
+ onClick={() => install.mutate(sourceFor(s))}
209
+ className="shrink-0 border border-foreground bg-foreground px-2.5 py-1 text-[11px] text-background hover:bg-foreground/90 disabled:opacity-50"
210
+ >
211
+ {install.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : "Install"}
212
+ </button>
213
+ </li>
214
+ ))}
215
+ </ul>
216
+ )}
217
+ </>
218
+ )}
219
+
220
+ {/* ── URL ────────────────────────────────────────── */}
221
+ {source === "url" && (
222
+ <form
223
+ onSubmit={(e) => { e.preventDefault(); if (url.trim()) install.mutate(url.trim()); }}
224
+ className="flex flex-col gap-2"
225
+ >
226
+ <input
227
+ value={url}
228
+ autoFocus
229
+ onChange={(e) => setUrl(e.target.value)}
230
+ placeholder="https://github.com/you/your-skills"
231
+ className="border border-border px-3 py-2 text-sm outline-none focus:border-foreground/30"
232
+ />
233
+ <button
234
+ type="submit"
235
+ disabled={!url.trim() || install.isPending}
236
+ className="inline-flex items-center justify-center gap-1.5 border border-foreground bg-foreground px-3 py-2 text-xs text-background hover:bg-foreground/90 disabled:opacity-50"
237
+ >
238
+ {install.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />}
239
+ Install
240
+ </button>
241
+ {install.isError && <p className="text-xs text-destructive">{(install.error as Error).message}</p>}
242
+ </form>
243
+ )}
244
+
245
+ {/* ── Paste / create (dogfood lumea-agents UI) ──── */}
246
+ {source === "paste" && (
247
+ <SkillCreateForm onCreated={afterCreate} onCancel={() => setSource(null)} />
248
+ )}
249
+
250
+ {install.isError && source === "registry" && (
251
+ <p className="text-xs text-destructive">{(install.error as Error).message}</p>
252
+ )}
253
+ </div>
254
+ )}
255
+ </DialogContent>
256
+ </Dialog>
257
+ );
258
+ }
259
+
260
+ function SourceCard({ icon, title, desc, onClick }: { icon: React.ReactNode; title: string; desc: string; onClick: () => void }) {
261
+ return (
262
+ <button
263
+ onClick={onClick}
264
+ className="flex items-start gap-3 border border-border px-4 py-3 text-left transition-colors hover:border-foreground/30 hover:bg-secondary/30"
265
+ >
266
+ <span className="mt-0.5 text-muted-foreground">{icon}</span>
267
+ <span className="min-w-0">
268
+ <span className="block text-sm font-medium">{title}</span>
269
+ <span className="block text-[11px] text-muted-foreground">{desc}</span>
270
+ </span>
271
+ </button>
272
+ );
273
+ }
@@ -0,0 +1,316 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Custom Swarm run row + list — replaces `@lumea-labs/orchestrator`'s
5
+ * SwarmRunRow / SwarmRunList for the project dashboard so the design
6
+ * matches Polpo's neo-brutalist palette (sharp edges, mono labels,
7
+ * theme tokens, no rounded corners except the status pulse dot).
8
+ *
9
+ * Scope is intentionally narrow:
10
+ * - Accepts a thin `RunRow` shape, not the full SwarmRun (no
11
+ * toolCalls / artifacts / events here). The dashboard surface is
12
+ * a scannable list, not a drill-down.
13
+ * - No cancel / retry handlers yet — those land in this file when
14
+ * we wire the SDK to abort + re-queue tasks.
15
+ * - Skeleton variant lives next to the real component so layout
16
+ * drift is impossible.
17
+ *
18
+ * Columns left → right:
19
+ * 1. Status dot (pulses on running)
20
+ * 2. Status label (mono uppercase, e.g. RUNNING)
21
+ * 3. Agent monogram tile + name (mono uppercase)
22
+ * 4. Task title (truncated)
23
+ * 5. Last log (right-aligned, muted, hidden < md)
24
+ * 6. Elapsed / relative time (right-aligned mono)
25
+ */
26
+
27
+ import { useEffect, useState } from "react";
28
+ import Link from "next/link";
29
+
30
+ export type RunStatus =
31
+ | "scheduled"
32
+ | "pending"
33
+ | "running"
34
+ | "review"
35
+ | "done"
36
+ | "failed"
37
+ | "cancelled";
38
+
39
+ export interface RunRow {
40
+ id: string;
41
+ status: RunStatus;
42
+ agentName: string;
43
+ agentColor?: string;
44
+ title: string;
45
+ lastLog?: string;
46
+ /** ISO timestamp. Used to compute elapsed (running) or duration (done). */
47
+ startedAt?: string;
48
+ /** ISO timestamp. Set when status is `done`, `failed`, or `cancelled`. */
49
+ finishedAt?: string;
50
+ /** Optional href — when provided, the whole row becomes a Link. */
51
+ href?: string;
52
+ }
53
+
54
+ /** Tailwind bg + fg color tokens per status. Mapped to our theme so
55
+ * the palette flips correctly between light + dark mode. */
56
+ const STATUS_STYLE: Record<RunStatus, { dot: string; text: string }> = {
57
+ scheduled: { dot: "bg-violet-500", text: "text-violet-500" },
58
+ pending: { dot: "bg-muted-foreground/60", text: "text-muted-foreground" },
59
+ running: { dot: "bg-brand", text: "text-brand" },
60
+ review: { dot: "bg-amber-500", text: "text-amber-500" },
61
+ done: { dot: "bg-emerald-500", text: "text-emerald-500" },
62
+ failed: { dot: "bg-destructive", text: "text-destructive" },
63
+ cancelled: { dot: "bg-muted-foreground/30", text: "text-muted-foreground/60" },
64
+ };
65
+
66
+ const STATUS_LABEL: Record<RunStatus, string> = {
67
+ scheduled: "scheduled",
68
+ pending: "pending",
69
+ running: "running",
70
+ review: "review",
71
+ done: "done",
72
+ failed: "failed",
73
+ cancelled: "cancelled",
74
+ };
75
+
76
+ /** Counts for every run status. Mirrors @lumea-labs/orchestrator's
77
+ * SwarmCounts but is the source of truth for our own status bar so we
78
+ * can render ALL states (the package bar only shows running/done/failed). */
79
+ export interface RunCounts {
80
+ scheduled: number;
81
+ pending: number;
82
+ running: number;
83
+ review: number;
84
+ done: number;
85
+ failed: number;
86
+ cancelled: number;
87
+ total: number;
88
+ }
89
+
90
+ /** The order states appear in the bar, left → right (lifecycle order). */
91
+ const BAR_ORDER: RunStatus[] = [
92
+ "running",
93
+ "pending",
94
+ "scheduled",
95
+ "review",
96
+ "done",
97
+ "failed",
98
+ "cancelled",
99
+ ];
100
+
101
+ /**
102
+ * Horizontal status bar — one stat per run status. Always rendered (shows
103
+ * 0 across the board when a project has no tasks yet). Replaces
104
+ * @lumea-labs/orchestrator's SwarmStatusBar, which is hardcoded to three
105
+ * buckets. Running pulses when > 0; all states use the shared
106
+ * STATUS_STYLE tokens so the dot colours match the run rows exactly.
107
+ */
108
+ export function SwarmStatusBar({ counts }: { counts: RunCounts }) {
109
+ return (
110
+ <div className="flex flex-wrap items-center gap-x-5 gap-y-2 border border-border bg-card px-4 py-2.5">
111
+ {BAR_ORDER.map((status, i) => {
112
+ const value = counts[status];
113
+ const style = STATUS_STYLE[status];
114
+ const dim = value === 0;
115
+ return (
116
+ <span key={status} className="flex items-center gap-3">
117
+ {i > 0 && <span className="h-4 w-px bg-border" aria-hidden />}
118
+ <span className="inline-flex items-center gap-1.5">
119
+ <span className="relative inline-flex size-2 shrink-0">
120
+ {status === "running" && value > 0 && (
121
+ <span className={`absolute inset-0 rounded-full opacity-60 animate-ping ${style.dot}`} aria-hidden />
122
+ )}
123
+ <span
124
+ className={`relative inline-block size-2 rounded-full ${style.dot} ${dim ? "opacity-30" : ""}`}
125
+ aria-hidden
126
+ />
127
+ </span>
128
+ <span className={`font-mono text-sm font-bold tabular-nums ${dim ? "text-muted-foreground/40" : style.text}`}>
129
+ {value}
130
+ </span>
131
+ <span className={`font-mono text-[10px] font-medium uppercase tracking-[0.12em] ${dim ? "text-muted-foreground/40" : "text-muted-foreground"}`}>
132
+ {STATUS_LABEL[status]}
133
+ </span>
134
+ </span>
135
+ </span>
136
+ );
137
+ })}
138
+ </div>
139
+ );
140
+ }
141
+
142
+ function formatElapsed(seconds: number): string {
143
+ if (seconds < 60) return `${Math.max(0, Math.round(seconds))}s`;
144
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
145
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
146
+ return `${Math.floor(seconds / 86400)}d`;
147
+ }
148
+
149
+ /**
150
+ * Returns the elapsed seconds between startedAt and finishedAt (or
151
+ * `now` when still running). Recomputed every second via the parent
152
+ * `now` prop so running rows tick live without forcing each row to
153
+ * own a timer.
154
+ */
155
+ function elapsedSeconds(
156
+ startedAt: string | undefined,
157
+ finishedAt: string | undefined,
158
+ now: number,
159
+ ): number {
160
+ if (!startedAt) return 0;
161
+ const start = new Date(startedAt).getTime();
162
+ const end = finishedAt ? new Date(finishedAt).getTime() : now;
163
+ return Math.max(0, (end - start) / 1000);
164
+ }
165
+
166
+ function AgentBadge({
167
+ name,
168
+ color,
169
+ }: {
170
+ name: string;
171
+ color?: string;
172
+ }) {
173
+ const initial = (name || "?").charAt(0).toUpperCase();
174
+ return (
175
+ <span
176
+ aria-hidden
177
+ className="grid size-4 shrink-0 place-items-center text-[9px] font-bold leading-none text-white"
178
+ style={{ background: color ?? "hsl(220 8% 50%)" }}
179
+ >
180
+ {initial}
181
+ </span>
182
+ );
183
+ }
184
+
185
+ export function SwarmRunRowItem({
186
+ run,
187
+ now,
188
+ }: {
189
+ run: RunRow;
190
+ now: number;
191
+ }) {
192
+ const isLive = run.status === "running" || run.status === "pending";
193
+ const elapsed = elapsedSeconds(run.startedAt, run.finishedAt, now);
194
+ const elapsedLabel = run.startedAt ? formatElapsed(elapsed) : "—";
195
+ const style = STATUS_STYLE[run.status];
196
+ const label = STATUS_LABEL[run.status];
197
+
198
+ const inner = (
199
+ <div className="group flex items-center gap-3 border-b border-border px-3 py-2 transition-colors last:border-0 hover:bg-foreground/5">
200
+ {/* Status dot — pulses on running */}
201
+ <span className="relative inline-flex size-2 shrink-0">
202
+ {run.status === "running" && (
203
+ <span
204
+ aria-hidden
205
+ className={`absolute inset-0 rounded-full opacity-60 animate-ping ${style.dot}`}
206
+ />
207
+ )}
208
+ <span
209
+ aria-hidden
210
+ className={`relative inline-block size-2 rounded-full ${style.dot}`}
211
+ />
212
+ </span>
213
+
214
+ {/* Status label — mono uppercase */}
215
+ <span
216
+ className={`w-[68px] shrink-0 truncate font-mono text-[10px] font-bold uppercase tracking-[0.14em] ${style.text}`}
217
+ >
218
+ {label}
219
+ </span>
220
+
221
+ {/* Agent — monogram tile + name */}
222
+ <span className="flex shrink-0 items-center gap-1.5">
223
+ <AgentBadge name={run.agentName} color={run.agentColor} />
224
+ <span className="w-[88px] truncate font-mono text-[10.5px] font-medium uppercase tracking-[0.1em] text-muted-foreground">
225
+ {run.agentName || "—"}
226
+ </span>
227
+ </span>
228
+
229
+ {/* Title — flex-1, truncates */}
230
+ <span className="min-w-0 flex-1 truncate text-[12.5px] leading-tight text-foreground">
231
+ {run.title}
232
+ </span>
233
+
234
+ {/* Last log — hidden below md */}
235
+ {run.lastLog ? (
236
+ <span className="hidden min-w-0 max-w-[28%] truncate font-mono text-[10px] text-muted-foreground/70 md:inline">
237
+ {run.lastLog}
238
+ </span>
239
+ ) : null}
240
+
241
+ {/* Elapsed / duration */}
242
+ <span className="w-12 shrink-0 text-right font-mono text-[10.5px] tabular-nums text-muted-foreground">
243
+ {isLive && run.startedAt ? `${elapsedLabel} ago` : elapsedLabel}
244
+ </span>
245
+ </div>
246
+ );
247
+
248
+ if (run.href) {
249
+ return (
250
+ <Link
251
+ href={run.href}
252
+ className="block"
253
+ data-testid={`swarm-run-row-${run.id}`}
254
+ >
255
+ {inner}
256
+ </Link>
257
+ );
258
+ }
259
+ return <div data-testid={`swarm-run-row-${run.id}`}>{inner}</div>;
260
+ }
261
+
262
+ export function SwarmRunList({
263
+ runs,
264
+ emptyLabel = "No runs yet.",
265
+ }: {
266
+ runs: RunRow[];
267
+ emptyLabel?: string;
268
+ }) {
269
+ // Tick every second so running rows show live elapsed time without
270
+ // each row owning its own setInterval. Pauses when nothing is live.
271
+ const hasLive = runs.some(
272
+ (r) => r.status === "running" || r.status === "pending",
273
+ );
274
+ const [now, setNow] = useState(() => Date.now());
275
+ useEffect(() => {
276
+ if (!hasLive) return;
277
+ const id = setInterval(() => setNow(Date.now()), 1000);
278
+ return () => clearInterval(id);
279
+ }, [hasLive]);
280
+
281
+ if (runs.length === 0) {
282
+ return (
283
+ <div className="px-3 py-8 text-center text-xs text-muted-foreground">
284
+ {emptyLabel}
285
+ </div>
286
+ );
287
+ }
288
+
289
+ return (
290
+ <div data-testid="swarm-run-list">
291
+ {runs.map((run) => (
292
+ <SwarmRunRowItem key={run.id} run={run} now={now} />
293
+ ))}
294
+ </div>
295
+ );
296
+ }
297
+
298
+ /**
299
+ * Skeleton row — matches the real row's column layout exactly so the
300
+ * transition from skeleton → real content has zero layout shift.
301
+ */
302
+ export function SwarmRunRowSkeleton() {
303
+ return (
304
+ <div className="flex items-center gap-3 border-b border-border px-3 py-2 last:border-0">
305
+ <span className="size-2 shrink-0 rounded-full bg-muted-foreground/20" />
306
+ <span className="h-3 w-[68px] shrink-0 bg-muted-foreground/15" />
307
+ <span className="flex shrink-0 items-center gap-1.5">
308
+ <span className="size-4 shrink-0 bg-muted-foreground/15" />
309
+ <span className="h-3 w-[88px] bg-muted-foreground/15" />
310
+ </span>
311
+ <span className="h-3 min-w-0 flex-1 bg-muted-foreground/10" />
312
+ <span className="hidden h-3 w-[18%] bg-muted-foreground/10 md:inline-block" />
313
+ <span className="h-3 w-12 shrink-0 bg-muted-foreground/15" />
314
+ </div>
315
+ );
316
+ }
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Inline tab toggle — squared pill buttons in a shared border strip.
5
+ * Used across SdkSnippetPanel, SkillStepDetail, and anywhere we need
6
+ * a compact tab switcher inside a card or detail panel.
7
+ */
8
+ export function TabToggle<T extends string>({
9
+ value,
10
+ onChange,
11
+ options,
12
+ }: {
13
+ value: T;
14
+ onChange: (v: T) => void;
15
+ options: Array<{ value: T; label: string }>;
16
+ }) {
17
+ return (
18
+ <div className="inline-flex items-center gap-1 border border-border p-0.5 w-fit">
19
+ {options.map((opt) => (
20
+ <button
21
+ key={opt.value}
22
+ type="button"
23
+ onClick={() => onChange(opt.value)}
24
+ className={`px-2.5 py-1 text-xs font-medium transition-colors ${
25
+ value === opt.value
26
+ ? "bg-secondary text-foreground"
27
+ : "text-muted-foreground hover:text-foreground"
28
+ }`}
29
+ >
30
+ {opt.label}
31
+ </button>
32
+ ))}
33
+ </div>
34
+ );
35
+ }