@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,569 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
5
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
6
+ import Link from "next/link";
7
+ import { Loader2, Play, Square, RotateCcw, GitBranch, List, Braces, Copy, Check, History } from "lucide-react";
8
+ // Mixed imports — see OSS issue tracking SDK type-export bugs:
9
+ // 1. NotificationRule.condition is `unknown` in SDK 0.7.9 (typed in core)
10
+ // 2. Task type is missing `missionId` in SDK 0.7.9 (present in core)
11
+ // Until SDK 0.7.10 re-exports cleanly from core, we use the type that works.
12
+ // `Task` from core because we need `missionId` to filter. `Mission` doesn't matter
13
+ // here — both shapes are equivalent for what this file consumes.
14
+ import type { Mission, Task } from "@polpo-ai/core";
15
+ import { usePolpoClient } from "#/lib/polpo-client";
16
+ import { MissionGraph } from "#/components/dashboard/mission-graph";
17
+ import { ManualRefreshButton } from "#/components/dashboard/manual-refresh-button";
18
+
19
+ function highlightJson(json: string): string {
20
+ return json.replace(
21
+ /("(?:\\.|[^"\\])*")\s*(:)?|(\b(?:true|false|null)\b)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
22
+ (match, str, colon, bool, num) => {
23
+ if (str) {
24
+ if (colon) {
25
+ // key
26
+ return `<span style="color:oklch(0.75 0.1 250)">${str}</span>:`;
27
+ }
28
+ // string value
29
+ return `<span style="color:oklch(0.75 0.15 155)">${str}</span>`;
30
+ }
31
+ if (bool) return `<span style="color:oklch(0.7 0.15 30)">${bool}</span>`;
32
+ if (num) return `<span style="color:oklch(0.8 0.12 80)">${num}</span>`;
33
+ return match;
34
+ },
35
+ );
36
+ }
37
+
38
+ const statusColor: Record<string, string> = {
39
+ completed: "bg-brand",
40
+ active: "bg-foreground animate-pulse",
41
+ failed: "bg-destructive",
42
+ paused: "bg-yellow-500",
43
+ draft: "bg-muted-foreground/20",
44
+ done: "bg-brand",
45
+ in_progress: "bg-foreground animate-pulse",
46
+ pending: "bg-muted-foreground/30",
47
+ assigned: "bg-muted-foreground/50",
48
+ review: "bg-yellow-500",
49
+ awaiting_approval: "bg-yellow-500",
50
+ recurring: "bg-blue-500",
51
+ scheduled: "bg-blue-500/50",
52
+ };
53
+
54
+ function formatDate(d: string | undefined) {
55
+ if (!d) return "\u2014";
56
+ return new Date(d).toLocaleString("en-US", {
57
+ month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false,
58
+ });
59
+ }
60
+
61
+ export function MissionHeader({
62
+ projectId,
63
+ missionId,
64
+ initialMission,
65
+ }: {
66
+ projectId: string;
67
+ missionId: string;
68
+ initialMission: Mission | null;
69
+ }) {
70
+ const queryClient = useQueryClient();
71
+ const [recentlyExecuted, setRecentlyExecuted] = useState(false);
72
+ const polpo = usePolpoClient(projectId);
73
+
74
+ const { data: mission = null, isFetching, refetch } = useQuery({
75
+ queryKey: ["mission", projectId, missionId],
76
+ queryFn: () => polpo.getMission(missionId) as unknown as Promise<Mission | null>,
77
+ initialData: initialMission,
78
+ refetchInterval: (query) => {
79
+ const m = query.state.data;
80
+ return m?.status === "active" || recentlyExecuted ? 5000 : false;
81
+ },
82
+ });
83
+
84
+ const executeMutation = useMutation({
85
+ mutationFn: () => polpo.executeMission(missionId),
86
+ onMutate: () => {
87
+ setRecentlyExecuted(true);
88
+ },
89
+ onSuccess: () => {
90
+ queryClient.invalidateQueries({ queryKey: ["mission", projectId, missionId] });
91
+ queryClient.invalidateQueries({ queryKey: ["mission-tasks", projectId, missionId] });
92
+ },
93
+ onSettled: () => {
94
+ window.setTimeout(() => setRecentlyExecuted(false), 30000);
95
+ },
96
+ });
97
+
98
+ const resumeMutation = useMutation({
99
+ mutationFn: () => polpo.resumeMission(missionId),
100
+ onSuccess: () => {
101
+ queryClient.invalidateQueries({ queryKey: ["mission", projectId, missionId] });
102
+ queryClient.invalidateQueries({ queryKey: ["mission-tasks", projectId, missionId] });
103
+ },
104
+ });
105
+
106
+ const abortMutation = useMutation({
107
+ mutationFn: () =>
108
+ polpo.abortMission(missionId),
109
+ onSuccess: () => {
110
+ queryClient.invalidateQueries({ queryKey: ["mission", projectId, missionId] });
111
+ queryClient.invalidateQueries({ queryKey: ["mission-tasks", projectId, missionId] });
112
+ },
113
+ });
114
+
115
+ if (!mission) {
116
+ return (
117
+ <div className="mt-4 border border-border p-8 text-center text-sm text-muted-foreground">
118
+ Mission not found.
119
+ </div>
120
+ );
121
+ }
122
+
123
+ return (
124
+ <div>
125
+ {/* Header */}
126
+ <div className="mt-4 flex items-start justify-between gap-4">
127
+ <div className="flex items-center gap-3">
128
+ <span className={`h-2.5 w-2.5 rounded-full ${statusColor[mission.status ?? "draft"]}`} />
129
+ <h2 className="text-lg font-extrabold tracking-tight font-mono">{mission.name}</h2>
130
+ </div>
131
+ <ManualRefreshButton
132
+ onRefresh={() => Promise.all([
133
+ refetch(),
134
+ queryClient.invalidateQueries({ queryKey: ["mission-tasks", projectId, missionId] }),
135
+ ])}
136
+ isRefreshing={isFetching}
137
+ className="mt-1 shrink-0"
138
+ />
139
+ </div>
140
+ {mission.prompt && (
141
+ <p className="mt-2 text-sm text-muted-foreground">{mission.prompt}</p>
142
+ )}
143
+
144
+ {/* Meta inline */}
145
+ <div className="mt-4 flex items-center gap-4 flex-wrap text-xs text-muted-foreground">
146
+ <span>Status: <span className="font-medium text-foreground">{mission.status}</span></span>
147
+ <span>Runs: <span className="font-mono font-medium text-foreground">{mission.executionCount ?? 0}</span></span>
148
+ {mission.schedule && <span>Schedule: <span className="font-mono font-medium text-foreground">{mission.schedule}</span></span>}
149
+ {mission.deadline && <span>Deadline: <span className="font-medium text-foreground">{formatDate(mission.deadline)}</span></span>}
150
+ <span className="text-muted-foreground/40">Created {formatDate(mission.createdAt)}</span>
151
+ </div>
152
+
153
+ {/* Actions */}
154
+ <div className="mt-5 flex gap-3">
155
+ {mission.status === "draft" && (
156
+ <button
157
+ onClick={() => executeMutation.mutate()}
158
+ disabled={executeMutation.isPending}
159
+ className="inline-flex items-center gap-2 bg-foreground text-background px-4 py-2 text-sm font-medium transition-all hover:opacity-90 disabled:opacity-50"
160
+ >
161
+ {executeMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
162
+ Execute
163
+ </button>
164
+ )}
165
+ {(mission.status === "failed" || mission.status === "paused") && (
166
+ <button
167
+ onClick={() => resumeMutation.mutate()}
168
+ disabled={resumeMutation.isPending}
169
+ className="inline-flex items-center gap-2 bg-foreground text-background px-4 py-2 text-sm font-medium transition-all hover:opacity-90 disabled:opacity-50"
170
+ >
171
+ {resumeMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RotateCcw className="h-3.5 w-3.5" />}
172
+ Resume
173
+ </button>
174
+ )}
175
+ {mission.status === "active" && (
176
+ <button
177
+ onClick={() => abortMutation.mutate()}
178
+ disabled={abortMutation.isPending}
179
+ className="inline-flex items-center gap-2 border border-destructive/30 px-4 py-2 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50"
180
+ >
181
+ {abortMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Square className="h-3.5 w-3.5" />}
182
+ Abort
183
+ </button>
184
+ )}
185
+ </div>
186
+ </div>
187
+ );
188
+ }
189
+
190
+ type TaskView = "graph" | "table" | "json" | "runs";
191
+ const TASK_VIEWS: readonly TaskView[] = ["graph", "table", "json", "runs"] as const;
192
+
193
+ export function MissionTasksPanel({
194
+ projectId,
195
+ missionId,
196
+ initialTasks,
197
+ initialMission,
198
+ }: {
199
+ projectId: string;
200
+ missionId: string;
201
+ initialTasks: Task[];
202
+ initialMission?: Mission | null;
203
+ }) {
204
+ const router = useRouter();
205
+ const pathname = usePathname();
206
+ const searchParams = useSearchParams();
207
+ const isRecurringOrScheduled =
208
+ initialMission?.status === "recurring" || initialMission?.status === "scheduled";
209
+ // Task view is URL-driven (?tab=) → deep-linkable, like agent/task studio.
210
+ // "runs" only exists on recurring/scheduled missions; guard it.
211
+ const urlTab = searchParams.get("tab") as TaskView | null;
212
+ const validUrlTab =
213
+ urlTab && TASK_VIEWS.includes(urlTab) && (urlTab !== "runs" || isRecurringOrScheduled)
214
+ ? urlTab
215
+ : null;
216
+ const [taskView, setTaskView] = useState<TaskView>(validUrlTab ?? "graph");
217
+ useEffect(() => {
218
+ if (validUrlTab && validUrlTab !== taskView) setTaskView(validUrlTab);
219
+ // eslint-disable-next-line react-hooks/exhaustive-deps
220
+ }, [validUrlTab]);
221
+ function selectView(key: TaskView) {
222
+ setTaskView(key);
223
+ const params = new URLSearchParams(searchParams.toString());
224
+ if (key === "graph") params.delete("tab");
225
+ else params.set("tab", key);
226
+ const qs = params.toString();
227
+ router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
228
+ }
229
+ const [copied, setCopied] = useState(false);
230
+ const polpo = usePolpoClient(projectId);
231
+
232
+ const { data: missionTasks = [] } = useQuery({
233
+ queryKey: ["mission-tasks", projectId, missionId],
234
+ queryFn: async () => {
235
+ // SDK TaskFilters has status/group/assignTo but no missionId — track:
236
+ // todo-sdk-task-filters-missionid. Workaround: fetch all + filter
237
+ // client-side (same as the pre-SDK call did).
238
+ const all = (await polpo.getTasks()) as unknown as Task[];
239
+ return all.filter((t) => t.missionId === missionId);
240
+ },
241
+ initialData: initialTasks,
242
+ refetchInterval: (query) => {
243
+ const tasks = query.state.data ?? [];
244
+ const hasActive = tasks.some(
245
+ (t) => t.status === "in_progress" || t.status === "pending" || t.status === "assigned" || t.status === "review",
246
+ );
247
+ return hasActive ? 5000 : false;
248
+ },
249
+ });
250
+
251
+ // Preview: when no tasks have been spawned yet (mission is draft) but the
252
+ // mission document defines them in `mission.data`, synthesize Task-shaped
253
+ // objects so the user sees the planned DAG before clicking Execute. The
254
+ // mission document references dependsOn by task TITLE (not id, since
255
+ // titles are unique within the document); we mirror that by using the
256
+ // title as the synthetic id — MissionGraph then wires edges correctly.
257
+ const previewTasks = useMemo<Task[] | null>(() => {
258
+ if (missionTasks.length > 0) return null;
259
+ if (!initialMission?.data) return null;
260
+ try {
261
+ const raw = typeof initialMission.data === "string"
262
+ ? JSON.parse(initialMission.data)
263
+ : initialMission.data;
264
+ if (!raw?.tasks || !Array.isArray(raw.tasks)) return null;
265
+ const created = initialMission.createdAt ?? new Date().toISOString();
266
+ return raw.tasks.map((t: { title: string; description?: string; assignTo?: string; dependsOn?: string[]; expectations?: unknown[]; expectedOutcomes?: unknown[] }) => ({
267
+ id: t.title,
268
+ title: t.title,
269
+ description: t.description ?? "",
270
+ assignTo: t.assignTo ?? "—",
271
+ status: "draft" as const,
272
+ expectations: (t.expectations ?? []) as Task["expectations"],
273
+ expectedOutcomes: (t.expectedOutcomes ?? []) as Task["expectedOutcomes"],
274
+ dependsOn: t.dependsOn ?? [],
275
+ missionId,
276
+ retries: 0,
277
+ maxRetries: 0,
278
+ createdAt: created,
279
+ updatedAt: created,
280
+ })) as Task[];
281
+ } catch {
282
+ return null;
283
+ }
284
+ }, [missionTasks.length, initialMission, missionId]);
285
+
286
+ const isPreview = missionTasks.length === 0 && (previewTasks?.length ?? 0) > 0;
287
+ const tasksToRender = isPreview ? previewTasks! : missionTasks;
288
+
289
+ if (tasksToRender.length === 0) {
290
+ return (
291
+ <section className="mt-4">
292
+ <div className="rounded-xl border border-border bg-card p-8 text-center text-sm text-muted-foreground">
293
+ No tasks in this mission yet.
294
+ </div>
295
+ </section>
296
+ );
297
+ }
298
+
299
+ // Stats
300
+ const done = tasksToRender.filter(t => t.status === "done").length;
301
+ const failed = tasksToRender.filter(t => t.status === "failed").length;
302
+ const inProgress = tasksToRender.filter(t => t.status === "in_progress" || t.status === "review").length;
303
+
304
+ return (
305
+ <section className="mt-4">
306
+ {/* Preview banner — mission is draft, tasks shown are from mission.data,
307
+ not actual spawned task records. They'll be created on Execute. */}
308
+ {isPreview && (
309
+ <div className="mb-4 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
310
+ <span className="font-medium text-foreground">Preview</span> — these {tasksToRender.length} task{tasksToRender.length === 1 ? "" : "s"} will be created when you execute the mission.
311
+ </div>
312
+ )}
313
+
314
+ {/* Task stats — only for real spawned tasks (preview has no runtime state) */}
315
+ {!isPreview && (
316
+ <div className="mb-4 flex items-center gap-4 flex-wrap text-xs text-muted-foreground">
317
+ <span>Tasks: <span className="font-medium text-foreground">{done}/{tasksToRender.length} done</span></span>
318
+ {inProgress > 0 && <span>In progress: <span className="font-medium text-foreground">{inProgress}</span></span>}
319
+ {failed > 0 && <span>Failed: <span className="font-medium text-destructive">{failed}</span></span>}
320
+ </div>
321
+ )}
322
+
323
+ <div className="flex items-center justify-between">
324
+ <div className="flex items-center gap-1 rounded-lg bg-muted/50 p-1">
325
+ <button
326
+ onClick={() => selectView("graph")}
327
+ className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
328
+ taskView === "graph" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
329
+ }`}
330
+ >
331
+ <GitBranch className="h-3.5 w-3.5" />
332
+ Graph
333
+ </button>
334
+ <button
335
+ onClick={() => selectView("table")}
336
+ className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
337
+ taskView === "table" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
338
+ }`}
339
+ >
340
+ <List className="h-3.5 w-3.5" />
341
+ Table
342
+ </button>
343
+ <button
344
+ onClick={() => selectView("json")}
345
+ className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
346
+ taskView === "json" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
347
+ }`}
348
+ >
349
+ <Braces className="h-3.5 w-3.5" />
350
+ JSON
351
+ </button>
352
+ {isRecurringOrScheduled && !isPreview && (
353
+ <button
354
+ onClick={() => selectView("runs")}
355
+ className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
356
+ taskView === "runs" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
357
+ }`}
358
+ >
359
+ <History className="h-3.5 w-3.5" />
360
+ Runs
361
+ </button>
362
+ )}
363
+ </div>
364
+ </div>
365
+
366
+ <div className="mt-3">
367
+ {taskView === "runs" ? (
368
+ <RunsView tasks={missionTasks} projectId={projectId} />
369
+ ) : taskView === "graph" ? (
370
+ <MissionGraph tasks={tasksToRender} projectId={projectId} />
371
+ ) : taskView === "json" ? (
372
+ <div className="border border-border bg-card overflow-hidden">
373
+ <div className="flex items-center justify-between px-4 py-2 border-b border-border bg-muted/40">
374
+ <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Tasks JSON</span>
375
+ <button
376
+ onClick={() => {
377
+ navigator.clipboard.writeText(JSON.stringify({ tasks: tasksToRender }, null, 2));
378
+ setCopied(true);
379
+ setTimeout(() => setCopied(false), 2000);
380
+ }}
381
+ className="inline-flex items-center gap-1.5 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
382
+ >
383
+ {copied ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
384
+ {copied ? "Copied" : "Copy"}
385
+ </button>
386
+ </div>
387
+ <pre
388
+ className="p-4 text-xs font-mono overflow-y-auto max-h-[560px] whitespace-pre-wrap break-all"
389
+ dangerouslySetInnerHTML={{
390
+ __html: highlightJson(JSON.stringify({ tasks: tasksToRender }, null, 2)),
391
+ }}
392
+ />
393
+ </div>
394
+ ) : (
395
+ <div className="border border-border overflow-hidden">
396
+ <table className="w-full text-xs">
397
+ <thead>
398
+ <tr className="border-b border-border bg-muted/40 text-muted-foreground">
399
+ <th className="px-4 py-2.5 text-left font-medium w-8"></th>
400
+ <th className="px-4 py-2.5 text-left font-medium">Task</th>
401
+ <th className="px-4 py-2.5 text-left font-medium w-24">Agent</th>
402
+ <th className="px-4 py-2.5 text-left font-medium w-24">Status</th>
403
+ <th className="px-4 py-2.5 text-left font-medium w-32 hidden sm:table-cell">Dependencies</th>
404
+ </tr>
405
+ </thead>
406
+ <tbody className="font-mono">
407
+ {tasksToRender.map((task) => (
408
+ <tr key={task.id} className="border-b border-border last:border-0 hover:bg-muted/20 transition-colors">
409
+ <td className="px-4 py-3">
410
+ <span className={`h-2.5 w-2.5 rounded-full inline-block ${statusColor[task.status ?? "pending"]}`} />
411
+ </td>
412
+ <td className="px-4 py-3">
413
+ <Link
414
+ href={`/projects/${projectId}/tasks/${task.id}`}
415
+ className="text-sm font-medium hover:underline underline-offset-2"
416
+ >
417
+ {task.title}
418
+ </Link>
419
+ </td>
420
+ <td className="px-4 py-3 text-muted-foreground">{task.assignTo}</td>
421
+ <td className="px-4 py-3">
422
+ <span className={`inline-flex items-center gap-1.5 text-[10px] ${
423
+ task.status === "done" ? "text-brand" :
424
+ task.status === "failed" ? "text-destructive" :
425
+ task.status === "in_progress" ? "text-foreground" :
426
+ "text-muted-foreground"
427
+ }`}>
428
+ {task.status}
429
+ {task.phase && task.status === "in_progress" && (
430
+ <span className="text-muted-foreground/50">({task.phase})</span>
431
+ )}
432
+ </span>
433
+ </td>
434
+ <td className="px-4 py-3 text-muted-foreground/50 hidden sm:table-cell">
435
+ {task.dependsOn && task.dependsOn.length > 0
436
+ ? task.dependsOn.map(dep => {
437
+ const depTask = tasksToRender.find(t => t.id === dep);
438
+ return depTask?.title ?? dep.slice(0, 8);
439
+ }).join(", ")
440
+ : "\u2014"
441
+ }
442
+ </td>
443
+ </tr>
444
+ ))}
445
+ </tbody>
446
+ </table>
447
+ </div>
448
+ )}
449
+ </div>
450
+ </section>
451
+ );
452
+ }
453
+
454
+ // ── Runs view ───────────────────────────────────────────────────────────────
455
+ //
456
+ // For recurring / scheduled missions, the mission is fired repeatedly and
457
+ // each fire instantiates a fresh batch of tasks from `mission.data.tasks`.
458
+ // All tasks in a single fire are inserted within ~1 second of each other,
459
+ // so we cluster by `created_at` proximity (60s window) and treat each
460
+ // cluster as one "run". Newest fires first.
461
+ //
462
+ // Status badge per run is derived: failed > in-progress > done.
463
+
464
+ interface RunCluster {
465
+ startedAt: string;
466
+ tasks: Task[];
467
+ }
468
+
469
+ const RUN_CLUSTER_WINDOW_MS = 60_000;
470
+
471
+ function clusterTaskRuns(tasks: Task[]): RunCluster[] {
472
+ if (tasks.length === 0) return [];
473
+ // Sort newest first by createdAt
474
+ const sorted = [...tasks].sort((a, b) =>
475
+ new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime(),
476
+ );
477
+ const clusters: RunCluster[] = [];
478
+ let current: RunCluster | null = null;
479
+ for (const task of sorted) {
480
+ const ts = new Date(task.createdAt ?? 0).getTime();
481
+ if (!current || Math.abs(new Date(current.startedAt).getTime() - ts) > RUN_CLUSTER_WINDOW_MS) {
482
+ current = { startedAt: task.createdAt ?? new Date().toISOString(), tasks: [task] };
483
+ clusters.push(current);
484
+ } else {
485
+ current.tasks.push(task);
486
+ }
487
+ }
488
+ return clusters;
489
+ }
490
+
491
+ function runStatus(tasks: Task[]): { label: string; className: string } {
492
+ if (tasks.some(t => t.status === "failed")) return { label: "failed", className: "bg-destructive/10 text-destructive" };
493
+ if (tasks.some(t => t.status === "in_progress" || t.status === "assigned" || t.status === "review" || t.status === "pending")) {
494
+ return { label: "running", className: "bg-amber-500/10 text-amber-500" };
495
+ }
496
+ if (tasks.every(t => t.status === "done")) return { label: "done", className: "bg-emerald-500/10 text-emerald-500" };
497
+ return { label: "unknown", className: "bg-muted text-muted-foreground" };
498
+ }
499
+
500
+ function formatRunTimestamp(iso: string): string {
501
+ const d = new Date(iso);
502
+ const now = Date.now();
503
+ const diff = now - d.getTime();
504
+ const min = Math.floor(diff / 60_000);
505
+ if (min < 1) return "just now";
506
+ if (min < 60) return `${min}m ago`;
507
+ const hours = Math.floor(min / 60);
508
+ if (hours < 24) return `${hours}h ago`;
509
+ return d.toLocaleString();
510
+ }
511
+
512
+ function RunsView({ tasks, projectId }: { tasks: Task[]; projectId: string }) {
513
+ const runs = useMemo(() => clusterTaskRuns(tasks), [tasks]);
514
+
515
+ if (runs.length === 0) {
516
+ return (
517
+ <div className="border border-border bg-card p-8 text-center text-sm text-muted-foreground">
518
+ No runs yet. This mission hasn’t fired since it was scheduled.
519
+ </div>
520
+ );
521
+ }
522
+
523
+ return (
524
+ <div className="border border-border overflow-hidden">
525
+ <table className="w-full text-xs">
526
+ <thead>
527
+ <tr className="border-b border-border bg-muted/40 text-muted-foreground">
528
+ <th className="px-4 py-2.5 text-left font-medium w-32">Started</th>
529
+ <th className="px-4 py-2.5 text-left font-medium w-20">Tasks</th>
530
+ <th className="px-4 py-2.5 text-left font-medium w-24">Status</th>
531
+ <th className="px-4 py-2.5 text-left font-medium">Tasks in this run</th>
532
+ </tr>
533
+ </thead>
534
+ <tbody>
535
+ {runs.map((run, idx) => {
536
+ const status = runStatus(run.tasks);
537
+ return (
538
+ <tr key={`${run.startedAt}-${idx}`} className="border-b border-border last:border-0 hover:bg-muted/20 transition-colors">
539
+ <td className="px-4 py-3 text-muted-foreground" title={new Date(run.startedAt).toISOString()}>
540
+ {formatRunTimestamp(run.startedAt)}
541
+ </td>
542
+ <td className="px-4 py-3 text-foreground font-medium">{run.tasks.length}</td>
543
+ <td className="px-4 py-3">
544
+ <span className={`inline-flex items-center rounded px-1.5 py-0.5 font-medium ${status.className}`}>
545
+ {status.label}
546
+ </span>
547
+ </td>
548
+ <td className="px-4 py-3">
549
+ <div className="flex flex-wrap gap-1.5">
550
+ {run.tasks.map((t) => (
551
+ <Link
552
+ key={t.id}
553
+ href={`/projects/${projectId}/tasks/${t.id}`}
554
+ className="inline-flex items-center gap-1 rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[10px] hover:bg-muted/40 transition-colors"
555
+ >
556
+ <span className={`h-1.5 w-1.5 rounded-full ${statusColor[t.status ?? "pending"]}`} />
557
+ {t.title}
558
+ </Link>
559
+ ))}
560
+ </div>
561
+ </td>
562
+ </tr>
563
+ );
564
+ })}
565
+ </tbody>
566
+ </table>
567
+ </div>
568
+ );
569
+ }
@@ -0,0 +1,32 @@
1
+ import { Suspense } from "react";
2
+ import { dataApi } from "#/lib/api";
3
+ import type { Mission } from "@polpo-ai/core";
4
+ import { MissionsListSkeleton } from "#/components/dashboard/skeletons";
5
+ import MissionsView from "./view";
6
+
7
+ async function MissionsData({ id }: { id: string }) {
8
+ let missions: Mission[] = [];
9
+ try {
10
+ const res = await dataApi<{ ok: boolean; data: Mission[] }>(
11
+ id,
12
+ "/v1/missions",
13
+ );
14
+ missions = res.data ?? [];
15
+ } catch {}
16
+
17
+ return <MissionsView initialMissions={missions} />;
18
+ }
19
+
20
+ export default async function MissionsPage({
21
+ params,
22
+ }: {
23
+ params: Promise<{ id: string }>;
24
+ }) {
25
+ const { id } = await params;
26
+
27
+ return (
28
+ <Suspense fallback={<MissionsListSkeleton />}>
29
+ <MissionsData id={id} />
30
+ </Suspense>
31
+ );
32
+ }