@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,421 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef } from "react";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import { useParams } from "next/navigation";
6
+ import Link from "next/link";
7
+ import type { AgentConfig, Task, Mission } from "@polpo-ai/core";
8
+ import { Users, ListChecks, Target, MessageSquare } from "lucide-react";
9
+ import { usePolpoClient } from "#/lib/polpo-client";
10
+ import { ManualRefreshButton } from "#/components/dashboard/manual-refresh-button";
11
+ import OnboardingChecklist, { deriveChecklistSteps, hasDeployedCheck } from "./onboarding-checklist";
12
+ import {
13
+ SwarmStatusBar,
14
+ SwarmRunList,
15
+ type RunRow,
16
+ type RunStatus,
17
+ type RunCounts,
18
+ } from "#/components/dashboard/swarm-runs";
19
+
20
+ interface SessionInfo {
21
+ id: string;
22
+ title?: string;
23
+ agent?: string;
24
+ createdAt: string;
25
+ }
26
+
27
+ // Polpo Task.status → our RunStatus. The swarm taxonomy is broader
28
+ // (scheduled / cancelled exist as well) but Polpo doesn't surface those
29
+ // on a Task today — only on Missions / runs. "draft" + "assigned" both
30
+ // roll up to "pending" because users perceive them as "not yet started".
31
+ function toRunStatus(status: string): RunStatus {
32
+ switch (status) {
33
+ case "in_progress": return "running";
34
+ case "review": return "review";
35
+ case "done": return "done";
36
+ case "failed": return "failed";
37
+ case "cancelled": return "cancelled";
38
+ case "draft":
39
+ case "assigned":
40
+ case "pending":
41
+ default: return "pending";
42
+ }
43
+ }
44
+
45
+ function buildSwarmCounts(tasks: Array<{ status: string }>): RunCounts {
46
+ const c: RunCounts = {
47
+ scheduled: 0, pending: 0, running: 0, review: 0,
48
+ done: 0, failed: 0, cancelled: 0, total: tasks.length,
49
+ };
50
+ for (const t of tasks) c[toRunStatus(t.status)]++;
51
+ return c;
52
+ }
53
+
54
+
55
+ export default function ProjectOverviewView({
56
+ initialAgents,
57
+ initialTasks,
58
+ initialMissions,
59
+ projectName,
60
+ projectSlug,
61
+ initialChecklist,
62
+ welcomeBanner,
63
+ }: {
64
+ initialAgents: AgentConfig[];
65
+ initialTasks: Task[];
66
+ initialMissions: Mission[];
67
+ projectName: string;
68
+ projectSlug?: string;
69
+ initialChecklist?: import("#/lib/api").OnboardingChecklist;
70
+ welcomeBanner?: React.ReactNode;
71
+ }) {
72
+ const { id } = useParams<{ id: string }>();
73
+
74
+ // Polling interval for checklist auto-completion (15s)
75
+ const CHECKLIST_POLL = 15_000;
76
+
77
+ const polpo = usePolpoClient(id);
78
+ const { data: agents = [], isFetching: agentsFetching, refetch: refetchAgents } = useQuery({
79
+ queryKey: ["agents", id],
80
+ queryFn: () => polpo.getAgents() as unknown as Promise<AgentConfig[]>,
81
+ initialData: initialAgents,
82
+ refetchInterval: CHECKLIST_POLL,
83
+ });
84
+
85
+ const { data: tasks = [], isFetching: tasksFetching, refetch: refetchTasks } = useQuery({
86
+ queryKey: ["tasks", id],
87
+ queryFn: () => polpo.getTasks() as unknown as Promise<Task[]>,
88
+ initialData: initialTasks,
89
+ refetchInterval: CHECKLIST_POLL,
90
+ });
91
+
92
+ const { data: missions = [], isFetching: missionsFetching, refetch: refetchMissions } = useQuery({
93
+ queryKey: ["missions", id],
94
+ queryFn: () => polpo.getMissions() as unknown as Promise<Mission[]>,
95
+ initialData: initialMissions,
96
+ refetchInterval: CHECKLIST_POLL,
97
+ });
98
+
99
+ const { data: sessions = [], isFetching: sessionsFetching, refetch: refetchSessions } = useQuery({
100
+ queryKey: ["sessions", id],
101
+ queryFn: async () => {
102
+ const r = await polpo.getSessions();
103
+ return (r.sessions ?? []) as unknown as SessionInfo[];
104
+ },
105
+ refetchInterval: CHECKLIST_POLL,
106
+ });
107
+
108
+ const { data: skillsList = [], isFetching: skillsFetching, refetch: refetchSkills } = useQuery({
109
+ queryKey: ["skills", id],
110
+ queryFn: () => polpo.getSkills() as unknown as Promise<Array<{ name: string }>>,
111
+ refetchInterval: CHECKLIST_POLL,
112
+ });
113
+
114
+ // Has any memory? Either shared or on any agent. A single truthy
115
+ // signals the step done.
116
+ const { data: hasMemory = false, isFetching: memoryFetching, refetch: refetchMemory } = useQuery({
117
+ queryKey: ["memory-any", id, agents.map((a) => a.name).join(",")],
118
+ queryFn: async () => {
119
+ const shared = await polpo.getMemory().then((r) => r.exists === true).catch(() => false);
120
+ if (shared) return true;
121
+ for (const a of agents) {
122
+ const agentMem = await polpo
123
+ .getAgentMemory(a.name)
124
+ .then((r) => r.exists === true)
125
+ .catch(() => false);
126
+ if (agentMem) return true;
127
+ }
128
+ return false;
129
+ },
130
+ enabled: agents.length > 0,
131
+ refetchInterval: CHECKLIST_POLL,
132
+ });
133
+
134
+ // Persist onboarding checklist flags to control plane (idempotent).
135
+ // initialChecklist comes from the SSR-fetched project row. We track
136
+ // which flags we've already PATCHed in this session to avoid repeat
137
+ // calls (the PATCH is idempotent anyway, but there's no point).
138
+ const persistedFlags = useRef<Set<string>>(
139
+ new Set(
140
+ Object.entries(initialChecklist ?? {})
141
+ .filter(([, v]) => typeof v === "string")
142
+ .map(([k]) => k),
143
+ ),
144
+ );
145
+
146
+ const derived = useMemo(() => ({
147
+ firstCompletion: sessions.length > 0,
148
+ firstSkill: skillsList.length > 0,
149
+ firstMemory: hasMemory,
150
+ firstTask: tasks.length > 0,
151
+ firstDeploy: hasDeployedCheck(agents),
152
+ }), [sessions, skillsList, hasMemory, agents, tasks]);
153
+
154
+ useEffect(() => {
155
+ const toSend: Record<string, boolean> = {};
156
+ for (const [key, value] of Object.entries(derived)) {
157
+ if (value && !persistedFlags.current.has(key)) {
158
+ toSend[key] = true;
159
+ }
160
+ }
161
+ if (Object.keys(toSend).length === 0) return;
162
+
163
+ const ctrl = new AbortController();
164
+ fetch(`${process.env.NEXT_PUBLIC_API_URL}/v1/projects/${id}/onboarding-checklist`, {
165
+ method: "PATCH",
166
+ credentials: "include",
167
+ headers: { "Content-Type": "application/json" },
168
+ body: JSON.stringify(toSend),
169
+ signal: ctrl.signal,
170
+ })
171
+ .then(() => {
172
+ for (const k of Object.keys(toSend)) persistedFlags.current.add(k);
173
+ })
174
+ .catch(() => { /* best-effort */ });
175
+
176
+ return () => ctrl.abort();
177
+ }, [derived, id]);
178
+
179
+ // Single-pass derivation of all stats — avoids 13+ filter passes on every render
180
+ const stats = useMemo(() => {
181
+ let taskDone = 0, taskInProgress = 0, taskPending = 0, taskFailed = 0, taskReview = 0;
182
+ const busyAgentNames = new Set<string>();
183
+
184
+ for (const t of tasks) {
185
+ const agent = t.assignTo ?? "";
186
+
187
+ switch (t.status) {
188
+ case "done": taskDone++; break;
189
+ case "in_progress": taskInProgress++; busyAgentNames.add(agent); break;
190
+ case "failed": taskFailed++; break;
191
+ case "review": taskReview++; break;
192
+ case "pending":
193
+ case "assigned":
194
+ taskPending++; break;
195
+ case "draft": taskPending++; break;
196
+ }
197
+ }
198
+
199
+ let missionActive = 0, missionCompleted = 0;
200
+ for (const m of missions) {
201
+ if (m.status === "active") missionActive++;
202
+ else if (m.status === "completed") missionCompleted++;
203
+ }
204
+
205
+ const recentActivity = tasks
206
+ .filter(t => t.updatedAt)
207
+ .sort((a, b) => new Date(b.updatedAt!).getTime() - new Date(a.updatedAt!).getTime())
208
+ .slice(0, 8)
209
+ .map(t => ({
210
+ time: new Date(t.updatedAt!).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }),
211
+ date: new Date(t.updatedAt!).toLocaleDateString("en-US", { month: "short", day: "numeric" }),
212
+ event: `${t.assignTo ?? "agent"} ${t.status === "done" ? "completed" : t.status === "in_progress" ? "started" : t.status === "failed" ? "failed" : "updated"} "${t.title}"`,
213
+ type: t.status === "done" ? "success" : t.status === "failed" ? "error" : t.status === "in_progress" ? "active" : "info",
214
+ }));
215
+
216
+ return {
217
+ taskDone, taskInProgress, taskPending, taskFailed, taskReview,
218
+ missionActive, missionCompleted,
219
+ isActive: taskInProgress > 0 || missionActive > 0,
220
+ busyAgentNames,
221
+ recentActivity,
222
+ };
223
+ }, [tasks, missions, agents]);
224
+
225
+ const {
226
+ taskDone, taskInProgress, taskFailed,
227
+ missionActive, missionCompleted,
228
+ isActive, busyAgentNames,
229
+ recentActivity,
230
+ } = stats;
231
+
232
+ const isRefreshing = agentsFetching || tasksFetching || missionsFetching || sessionsFetching || skillsFetching || memoryFetching;
233
+
234
+ async function refreshOverview() {
235
+ await Promise.all([
236
+ refetchAgents(),
237
+ refetchTasks(),
238
+ refetchMissions(),
239
+ refetchSessions(),
240
+ refetchSkills(),
241
+ refetchMemory(),
242
+ ]);
243
+ }
244
+
245
+ return (
246
+ <div>
247
+ {/* Project title + live state badge — always visible at page top */}
248
+ <div className="mb-6 flex items-center justify-between gap-4">
249
+ <div className="flex items-center gap-3">
250
+ <h1 className="text-xl tracking-tight text-foreground">
251
+ {projectName}
252
+ </h1>
253
+ <span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-secondary/40 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
254
+ <span className={`h-1.5 w-1.5 rounded-full bg-brand ${isActive ? "animate-pulse" : ""}`} />
255
+ {isActive ? "Running" : "Ready"}
256
+ </span>
257
+ </div>
258
+ <ManualRefreshButton onRefresh={refreshOverview} isRefreshing={isRefreshing} className="shrink-0" />
259
+ </div>
260
+
261
+ {/* First-visit welcome banner — rendered by server parent when the
262
+ ?welcome=1 flag is set. Sits between the title and the stats. */}
263
+ {welcomeBanner}
264
+
265
+ {/* Stats grid */}
266
+ <div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
267
+ <Link href={`/projects/${id}/agents`} className="border border-border bg-card p-4 hover:border-foreground/10 transition-colors">
268
+ <div className="flex items-center gap-2 mb-2">
269
+ <Users className="h-3.5 w-3.5 text-muted-foreground" />
270
+ <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Agents</span>
271
+ </div>
272
+ <span className="text-2xl font-extrabold">{agents.length}</span>
273
+ {busyAgentNames.size > 0 && (
274
+ <p className="text-[10px] text-brand mt-1">{busyAgentNames.size} busy</p>
275
+ )}
276
+ </Link>
277
+
278
+ <Link href={`/projects/${id}/sessions`} className="border border-border bg-card p-4 hover:border-foreground/10 transition-colors">
279
+ <div className="flex items-center gap-2 mb-2">
280
+ <MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
281
+ <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Chat Sessions</span>
282
+ </div>
283
+ <span className="text-2xl font-extrabold">{sessions.length}</span>
284
+ </Link>
285
+
286
+ <Link href={`/projects/${id}/tasks`} className="border border-border bg-card p-4 hover:border-foreground/10 transition-colors">
287
+ <div className="flex items-center gap-2 mb-2">
288
+ <ListChecks className="h-3.5 w-3.5 text-muted-foreground" />
289
+ <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Tasks</span>
290
+ </div>
291
+ <span className="text-2xl font-extrabold">{tasks.length}</span>
292
+ <div className="flex items-center gap-2 mt-1 text-[10px]">
293
+ {taskDone > 0 && <span className="text-brand">{taskDone} done</span>}
294
+ {taskInProgress > 0 && <span className="text-foreground">{taskInProgress} run</span>}
295
+ {taskFailed > 0 && <span className="text-destructive">{taskFailed} fail</span>}
296
+ </div>
297
+ </Link>
298
+
299
+ <Link href={`/projects/${id}/missions`} className="border border-border bg-card p-4 hover:border-foreground/10 transition-colors">
300
+ <div className="flex items-center gap-2 mb-2">
301
+ <Target className="h-3.5 w-3.5 text-muted-foreground" />
302
+ <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Missions</span>
303
+ </div>
304
+ <span className="text-2xl font-extrabold">{missions.length}</span>
305
+ <div className="flex items-center gap-2 mt-1 text-[10px]">
306
+ {missionActive > 0 && <span className="text-foreground">{missionActive} active</span>}
307
+ {missionCompleted > 0 && <span className="text-brand">{missionCompleted} done</span>}
308
+ </div>
309
+ </Link>
310
+
311
+ </div>
312
+
313
+ {/* Onboarding checklist — below stats, auto-derived from project data.
314
+ Replaces recent activity while visible. */}
315
+ {(() => {
316
+ const checklistSteps = deriveChecklistSteps({
317
+ hasSession: sessions.length > 0,
318
+ hasSkill: skillsList.length > 0,
319
+ hasMemory,
320
+ hasTask: tasks.length > 0,
321
+ hasDeployed: hasDeployedCheck(agents),
322
+ projectSlug,
323
+ projectId: id,
324
+ apiUrl: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000",
325
+ agentName: agents[0]?.name,
326
+ agentNames: agents.map((a) => a.name),
327
+ agentConfigs: agents,
328
+ });
329
+ const allChecklistDone = checklistSteps.every((s) => s.completed);
330
+ if (allChecklistDone) return null;
331
+ return (
332
+ <div className="mt-6">
333
+ <OnboardingChecklist steps={checklistSteps} projectId={id} />
334
+ </div>
335
+ );
336
+ })()}
337
+
338
+ {/* Swarm status — custom status bar (all 7 run states, always
339
+ shown — 0 across the board for a fresh project) + a scannable
340
+ recent-runs list (only when there are tasks). The status bar is
341
+ our own component, not @lumea-labs/orchestrator's, because that
342
+ one is hardcoded to 3 buckets (running/done/failed). */}
343
+ <div className="mt-8 flex flex-col gap-4">
344
+ <SwarmStatusBar counts={buildSwarmCounts(tasks)} />
345
+
346
+ {tasks.length > 0 && (
347
+ <div className="border border-border bg-card">
348
+ <div className="flex items-center justify-between border-b border-border px-4 py-2.5">
349
+ <h3 className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
350
+ Recent runs
351
+ </h3>
352
+ <Link
353
+ href={`/projects/${id}/tasks`}
354
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors"
355
+ >
356
+ View all →
357
+ </Link>
358
+ </div>
359
+ <SwarmRunList
360
+ runs={tasks
361
+ .slice()
362
+ .sort((a, b) =>
363
+ new Date(b.updatedAt ?? b.createdAt ?? 0).getTime()
364
+ - new Date(a.updatedAt ?? a.createdAt ?? 0).getTime(),
365
+ )
366
+ .slice(0, 8)
367
+ .map<RunRow>((t) => ({
368
+ id: t.id,
369
+ status: toRunStatus(t.status),
370
+ agentName: t.assignTo ?? "—",
371
+ title: t.title ?? "Untitled task",
372
+ startedAt: t.updatedAt,
373
+ finishedAt: t.status === "done" || t.status === "failed"
374
+ ? t.updatedAt
375
+ : undefined,
376
+ href: `/projects/${id}/tasks/${t.id}`,
377
+ }))}
378
+ />
379
+ </div>
380
+ )}
381
+ </div>
382
+
383
+ {/* Recent activity — only shown once onboarding checklist is fully completed */}
384
+ {(() => {
385
+ const checkDone = sessions.length > 0
386
+ && skillsList.length > 0
387
+ && hasMemory
388
+ && tasks.length > 0
389
+ && hasDeployedCheck(agents);
390
+ if (!checkDone) return null;
391
+ return (
392
+ <section className="mt-8">
393
+ <h3 className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mb-3">Recent activity</h3>
394
+ {recentActivity.length > 0 ? (
395
+ <div className="border border-border overflow-hidden">
396
+ {recentActivity.map((a, i) => (
397
+ <div key={`${a.date}-${a.time}-${a.event}-${i}`} className="flex items-center gap-3 border-b border-border last:border-0 px-4 py-2.5">
398
+ <span className="font-mono text-[10px] text-muted-foreground/40 w-20 shrink-0">
399
+ {a.date} {a.time}
400
+ </span>
401
+ <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${
402
+ a.type === "success" ? "bg-brand"
403
+ : a.type === "error" ? "bg-destructive"
404
+ : a.type === "active" ? "bg-foreground animate-pulse"
405
+ : "bg-muted-foreground/30"
406
+ }`} />
407
+ <span className="text-xs text-muted-foreground truncate">{a.event}</span>
408
+ </div>
409
+ ))}
410
+ </div>
411
+ ) : (
412
+ <div className="border border-border p-8 text-center text-sm text-muted-foreground">
413
+ No recent activity.
414
+ </div>
415
+ )}
416
+ </section>
417
+ );
418
+ })()}
419
+ </div>
420
+ );
421
+ }
@@ -0,0 +1,156 @@
1
+ "use client";
2
+
3
+ /**
4
+ * First-visit Get-started banner — rendered at the top of the project
5
+ * dashboard when the URL carries `?welcome=1`.
6
+ *
7
+ * Two steps matching the first two tabs of the ConnectDialog:
8
+ * 1. CLI — `polpo link --project-id <uuid>`
9
+ * 2. Coding Agent — `polpo install --client <selected>`
10
+ *
11
+ * The ClientPicker and CopyCard are shared with ConnectDialog (DRY).
12
+ */
13
+
14
+ import { useEffect, useState } from "react";
15
+ import { useRouter } from "next/navigation";
16
+ import { X } from "lucide-react";
17
+ import { CopyCard } from "#/components/dashboard/copy-card";
18
+ import { ClientPicker, buildInstallCommand } from "#/components/dashboard/client-picker";
19
+
20
+ interface Props {
21
+ projectId: string;
22
+ projectName: string;
23
+ projectSlug: string | undefined;
24
+ agentName: string | null;
25
+ }
26
+
27
+ // Per-project localStorage key — skip is scoped to this project so a user
28
+ // can still see the banner on a freshly created project even after
29
+ // dismissing it elsewhere.
30
+ const skipKey = (projectId: string) => `polpo:welcome-skipped:${projectId}`;
31
+
32
+ export default function WelcomeBanner({ projectId }: Props) {
33
+ const router = useRouter();
34
+ const [show, setShow] = useState(false);
35
+ const [dismissed, setDismissed] = useState(false);
36
+ const [clients, setClients] = useState<string[]>(["claude-code"]);
37
+
38
+ useEffect(() => {
39
+ if (typeof window === "undefined") return;
40
+ // Permanent skip wins over ?welcome=1 — if the user previously chose
41
+ // "Don't show again", we never re-render the banner for this project
42
+ // even when they re-land here via a welcome=1 link.
43
+ if (localStorage.getItem(skipKey(projectId))) {
44
+ setShow(false);
45
+ return;
46
+ }
47
+ const hasFlag = new URLSearchParams(window.location.search).get("welcome") === "1";
48
+ setShow(hasFlag);
49
+ }, [projectId]);
50
+
51
+ function clearWelcomeParam() {
52
+ if (typeof window === "undefined") return;
53
+ const url = new URL(window.location.href);
54
+ url.searchParams.delete("welcome");
55
+ window.history.replaceState({}, "", url.toString());
56
+ }
57
+
58
+ function handleDismiss() {
59
+ setDismissed(true);
60
+ clearWelcomeParam();
61
+ router.refresh();
62
+ }
63
+
64
+ // Permanent skip — sets the localStorage flag so the banner never shows
65
+ // again on this project, even if the user re-visits with ?welcome=1.
66
+ function handleSkipForever() {
67
+ if (typeof window !== "undefined") {
68
+ localStorage.setItem(skipKey(projectId), "1");
69
+ }
70
+ setDismissed(true);
71
+ clearWelcomeParam();
72
+ router.refresh();
73
+ }
74
+
75
+ if (!show || dismissed) return null;
76
+
77
+ const linkCmd = `npx @polpo-ai/cli link --project-id ${projectId}`;
78
+ const installCmd = buildInstallCommand(clients);
79
+
80
+ return (
81
+ <div className="mb-8 w-full max-w-2xl border border-border bg-card p-6">
82
+ <div className="flex items-start justify-between gap-3">
83
+ <div>
84
+ <h2 className="text-2xl tracking-tight text-foreground">
85
+ Get started
86
+ </h2>
87
+ <p className="mt-2 text-sm text-muted-foreground">
88
+ Connect your codebase and set up your coding agent.
89
+ </p>
90
+ </div>
91
+ <button
92
+ type="button"
93
+ onClick={handleDismiss}
94
+ aria-label="Dismiss"
95
+ className="p-1 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
96
+ >
97
+ <X className="h-4 w-4" />
98
+ </button>
99
+ </div>
100
+
101
+ <div className="mt-5 flex flex-col gap-5">
102
+ {/* Step 1: Link */}
103
+ <div className="flex gap-4">
104
+ <div className="flex flex-col items-center gap-2 pt-0.5 shrink-0">
105
+ <div className="flex size-5 items-center justify-center rounded-full bg-secondary">
106
+ <span className="text-xs font-medium leading-4 text-foreground">1</span>
107
+ </div>
108
+ <div className="w-px flex-1 bg-border" />
109
+ </div>
110
+ <div className="flex-1 min-w-0 flex flex-col gap-2 pb-2">
111
+ <div>
112
+ <p className="text-sm font-medium leading-6 text-foreground">Link project</p>
113
+ <p className="text-xs leading-5 text-muted-foreground">
114
+ Connect this cloud project to a local folder.
115
+ </p>
116
+ </div>
117
+ <CopyCard label="terminal" value={linkCmd} />
118
+ </div>
119
+ </div>
120
+
121
+ {/* Step 2: Install coding agent skills */}
122
+ <div className="flex gap-4">
123
+ <div className="flex flex-col items-center gap-2 pt-0.5 shrink-0">
124
+ <div className="flex size-5 items-center justify-center rounded-full bg-secondary">
125
+ <span className="text-xs font-medium leading-4 text-foreground">2</span>
126
+ </div>
127
+ </div>
128
+ <div className="flex-1 min-w-0 flex flex-col gap-3">
129
+ <div>
130
+ <p className="text-sm font-medium leading-6 text-foreground">Set up your coding agent</p>
131
+ <p className="text-xs leading-5 text-muted-foreground">
132
+ Install Polpo skills so your coding agent knows how to work with the project.
133
+ </p>
134
+ </div>
135
+ <ClientPicker value={clients} onChange={setClients} />
136
+ <CopyCard label="terminal" value={installCmd} />
137
+ </div>
138
+ </div>
139
+ </div>
140
+
141
+ {/* Footer — explicit "don't show again". The X above is a one-shot
142
+ dismiss (banner returns if the user revisits with ?welcome=1);
143
+ this is the permanent opt-out. */}
144
+ <div className="mt-5 flex justify-end border-t border-border pt-3">
145
+ <button
146
+ type="button"
147
+ onClick={handleSkipForever}
148
+ data-testid="welcome-skip-forever"
149
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors"
150
+ >
151
+ Skip — don&apos;t show again
152
+ </button>
153
+ </div>
154
+ </div>
155
+ );
156
+ }