@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.
- package/LICENSE +13 -0
- package/README.md +44 -0
- package/app/(dashboard)/error.tsx +47 -0
- package/app/(dashboard)/layout.tsx +41 -0
- package/app/(dashboard)/loading.tsx +30 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/memory/view.tsx +36 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/models-view.tsx +30 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/page.tsx +51 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/prompt/view.tsx +211 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/skills/view.tsx +84 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/tools/view.tsx +525 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/vault/view.tsx +61 -0
- package/app/(dashboard)/projects/[id]/agents/page.tsx +39 -0
- package/app/(dashboard)/projects/[id]/agents/view.tsx +283 -0
- package/app/(dashboard)/projects/[id]/layout.tsx +11 -0
- package/app/(dashboard)/projects/[id]/logs/page.tsx +27 -0
- package/app/(dashboard)/projects/[id]/logs/view.tsx +184 -0
- package/app/(dashboard)/projects/[id]/memory/page.tsx +40 -0
- package/app/(dashboard)/projects/[id]/memory/view.tsx +17 -0
- package/app/(dashboard)/projects/[id]/missions/[missionId]/page.tsx +88 -0
- package/app/(dashboard)/projects/[id]/missions/[missionId]/view.tsx +569 -0
- package/app/(dashboard)/projects/[id]/missions/page.tsx +32 -0
- package/app/(dashboard)/projects/[id]/missions/view.tsx +282 -0
- package/app/(dashboard)/projects/[id]/onboarding-checklist.tsx +359 -0
- package/app/(dashboard)/projects/[id]/page.tsx +68 -0
- package/app/(dashboard)/projects/[id]/playbooks/[name]/page.tsx +5 -0
- package/app/(dashboard)/projects/[id]/playbooks/[name]/view.tsx +433 -0
- package/app/(dashboard)/projects/[id]/playbooks/page.tsx +5 -0
- package/app/(dashboard)/projects/[id]/playbooks/view.tsx +222 -0
- package/app/(dashboard)/projects/[id]/schedules/page.tsx +44 -0
- package/app/(dashboard)/projects/[id]/schedules/view.tsx +385 -0
- package/app/(dashboard)/projects/[id]/sessions/[sessionId]/page.tsx +55 -0
- package/app/(dashboard)/projects/[id]/sessions/[sessionId]/raw-view.tsx +373 -0
- package/app/(dashboard)/projects/[id]/sessions/[sessionId]/view.tsx +124 -0
- package/app/(dashboard)/projects/[id]/sessions/page.tsx +31 -0
- package/app/(dashboard)/projects/[id]/sessions/view.tsx +242 -0
- package/app/(dashboard)/projects/[id]/settings/page.tsx +52 -0
- package/app/(dashboard)/projects/[id]/skills/[name]/page.tsx +65 -0
- package/app/(dashboard)/projects/[id]/skills/[name]/view.tsx +227 -0
- package/app/(dashboard)/projects/[id]/skills/page.tsx +31 -0
- package/app/(dashboard)/projects/[id]/skills/view.tsx +243 -0
- package/app/(dashboard)/projects/[id]/storage/page.tsx +5 -0
- package/app/(dashboard)/projects/[id]/storage/view.tsx +385 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/activity/view.tsx +336 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/assessment/view.tsx +113 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/blueprint.ts +72 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/output/view.tsx +90 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/page.tsx +63 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/view.tsx +137 -0
- package/app/(dashboard)/projects/[id]/tasks/page.tsx +40 -0
- package/app/(dashboard)/projects/[id]/tasks/view.tsx +421 -0
- package/app/(dashboard)/projects/[id]/view.tsx +421 -0
- package/app/(dashboard)/projects/[id]/welcome-banner.tsx +156 -0
- package/app/(dashboard)/projects/projects-list.tsx +126 -0
- package/app/(playground)/error.tsx +42 -0
- package/app/(playground)/layout.tsx +27 -0
- package/app/(playground)/projects/[id]/playground/page.tsx +57 -0
- package/app/(playground)/projects/[id]/playground/view.tsx +167 -0
- package/app/(playground)/projects/[id]/playground-legacy/page.tsx +47 -0
- package/app/(playground)/projects/[id]/playground-legacy/skeleton.tsx +63 -0
- package/app/(playground)/projects/[id]/playground-legacy/view.tsx +237 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +385 -0
- package/app/icon.svg +6 -0
- package/app/layout.tsx +39 -0
- package/app/page.tsx +11 -0
- package/components/ai-elements/code-block.tsx +562 -0
- package/components/dashboard/agent-capabilities.tsx +326 -0
- package/components/dashboard/agent-identity-header.tsx +50 -0
- package/components/dashboard/agent-model-picker.tsx +195 -0
- package/components/dashboard/agent-model-selector.tsx +121 -0
- package/components/dashboard/agent-picker-card.tsx +46 -0
- package/components/dashboard/agent-studio.tsx +242 -0
- package/components/dashboard/agents-table.tsx +48 -0
- package/components/dashboard/breadcrumb.tsx +70 -0
- package/components/dashboard/builder-chat.tsx +418 -0
- package/components/dashboard/chat-shell.tsx +40 -0
- package/components/dashboard/client-picker.tsx +63 -0
- package/components/dashboard/command-snippet.tsx +77 -0
- package/components/dashboard/connect-dialog.tsx +680 -0
- package/components/dashboard/copy-button.tsx +45 -0
- package/components/dashboard/copy-card.tsx +48 -0
- package/components/dashboard/file-browser.tsx +161 -0
- package/components/dashboard/hint.tsx +22 -0
- package/components/dashboard/inference-mode.tsx +15 -0
- package/components/dashboard/manual-refresh-button.tsx +52 -0
- package/components/dashboard/markdown.tsx +31 -0
- package/components/dashboard/mcp-install-panel.tsx +196 -0
- package/components/dashboard/mission-graph.tsx +262 -0
- package/components/dashboard/nav-tabs.tsx +86 -0
- package/components/dashboard/polpo-chat.tsx +468 -0
- package/components/dashboard/project-copilot.tsx +253 -0
- package/components/dashboard/sdk-snippet-panel.tsx +194 -0
- package/components/dashboard/section-header.tsx +37 -0
- package/components/dashboard/settings-form.tsx +2477 -0
- package/components/dashboard/sidebar.tsx +385 -0
- package/components/dashboard/skeletons.tsx +1054 -0
- package/components/dashboard/skills-install-wizard.tsx +273 -0
- package/components/dashboard/swarm-runs.tsx +316 -0
- package/components/dashboard/tab-toggle.tsx +35 -0
- package/components/dashboard/task-studio.tsx +224 -0
- package/components/dashboard/top-header.tsx +203 -0
- package/components/dashboard/webhook-deliveries.tsx +234 -0
- package/components/icons/coding-agents.tsx +151 -0
- package/components/json-ld.tsx +8 -0
- package/components/providers.tsx +25 -0
- package/components/ui/badge.tsx +52 -0
- package/components/ui/button.tsx +60 -0
- package/components/ui/card.tsx +103 -0
- package/components/ui/chart.tsx +356 -0
- package/components/ui/command.tsx +196 -0
- package/components/ui/dialog.tsx +157 -0
- package/components/ui/input-group.tsx +158 -0
- package/components/ui/input.tsx +20 -0
- package/components/ui/multi-select.tsx +161 -0
- package/components/ui/popover.tsx +90 -0
- package/components/ui/select.tsx +201 -0
- package/components/ui/separator.tsx +25 -0
- package/components/ui/sheet.tsx +135 -0
- package/components/ui/skeleton.tsx +13 -0
- package/components/ui/tabs.tsx +56 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/tooltip.tsx +66 -0
- package/hooks/use-desktop-sidebar.tsx +50 -0
- package/hooks/use-mobile-sidebar.tsx +37 -0
- package/index.ts +14 -0
- package/lib/api.ts +194 -0
- package/lib/builder-context.ts +60 -0
- package/lib/data-client.ts +68 -0
- package/lib/get-query-client.ts +25 -0
- package/lib/polpo-client.tsx +49 -0
- package/lib/tool-catalog.ts +234 -0
- package/lib/use-event-catalog.ts +28 -0
- package/lib/utils.ts +6 -0
- 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' 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
|
+
}
|