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