@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,137 @@
|
|
|
1
|
+
import type { Task } from "@polpo-ai/core";
|
|
2
|
+
import { ManualRefreshButton } from "#/components/dashboard/manual-refresh-button";
|
|
3
|
+
import type { BlueprintContext } from "./blueprint";
|
|
4
|
+
|
|
5
|
+
const statusColor: Record<string, string> = {
|
|
6
|
+
done: "bg-brand",
|
|
7
|
+
in_progress: "bg-foreground animate-pulse",
|
|
8
|
+
pending: "bg-muted-foreground/30",
|
|
9
|
+
failed: "bg-destructive",
|
|
10
|
+
review: "bg-yellow-500",
|
|
11
|
+
assigned: "bg-muted-foreground/50",
|
|
12
|
+
draft: "bg-muted-foreground/20",
|
|
13
|
+
awaiting_approval: "bg-yellow-500",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default function TaskOverviewView({
|
|
17
|
+
task,
|
|
18
|
+
blueprint,
|
|
19
|
+
}: {
|
|
20
|
+
task: Task | null;
|
|
21
|
+
blueprint?: BlueprintContext;
|
|
22
|
+
}) {
|
|
23
|
+
if (!task) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="mt-4 border border-border p-8 text-center text-sm text-muted-foreground">
|
|
26
|
+
Task not found.
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div>
|
|
33
|
+
{blueprint && (
|
|
34
|
+
<div className="mt-4 rounded-md border border-border bg-muted/40 px-4 py-3 text-xs">
|
|
35
|
+
<p className="font-medium text-foreground">
|
|
36
|
+
Blueprint task
|
|
37
|
+
{blueprint.missionStatus === "recurring" && " — recurring mission"}
|
|
38
|
+
{blueprint.missionStatus === "draft" && " — draft mission"}
|
|
39
|
+
</p>
|
|
40
|
+
<p className="mt-1 text-muted-foreground">
|
|
41
|
+
This is the template definition of a task from mission{" "}
|
|
42
|
+
<span className="font-mono">{blueprint.missionName}</span>. It isn’t a real run —
|
|
43
|
+
a fresh task instance is created each time the mission is executed
|
|
44
|
+
{blueprint.missionStatus === "recurring" ? " on its schedule" : ""}. Status,
|
|
45
|
+
retries, expectations and outputs only fill in once an instance starts.
|
|
46
|
+
</p>
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
|
|
50
|
+
{/* Header */}
|
|
51
|
+
<div className={blueprint ? "mt-4 flex items-start justify-between gap-4" : "flex items-start justify-between gap-4"}>
|
|
52
|
+
<div className="flex items-center gap-3">
|
|
53
|
+
<span className={`h-2.5 w-2.5 rounded-full ${statusColor[task.status ?? "pending"]}`} />
|
|
54
|
+
<h2 className="text-lg font-extrabold tracking-tight">{task.title}</h2>
|
|
55
|
+
</div>
|
|
56
|
+
<ManualRefreshButton className="mt-1 shrink-0" />
|
|
57
|
+
</div>
|
|
58
|
+
<p className="mt-2 text-sm text-muted-foreground">{task.description}</p>
|
|
59
|
+
|
|
60
|
+
{/* Meta */}
|
|
61
|
+
<div className="mt-6 grid grid-cols-2 gap-3 sm:gap-4 sm:grid-cols-4">
|
|
62
|
+
{[
|
|
63
|
+
["Agent", task.assignTo ?? "\u2014"],
|
|
64
|
+
["Status", task.status ?? "\u2014"],
|
|
65
|
+
["Phase", task.phase ?? "\u2014"],
|
|
66
|
+
["Retries", `${task.retries ?? 0}/${task.maxRetries ?? 3}`],
|
|
67
|
+
].map(([label, value]) => (
|
|
68
|
+
<div key={label} className="border border-border bg-card p-4">
|
|
69
|
+
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{label}</p>
|
|
70
|
+
<p className="mt-1 text-sm font-medium">{value}</p>
|
|
71
|
+
</div>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{/* Expectations */}
|
|
76
|
+
{task.expectations && task.expectations.length > 0 && (
|
|
77
|
+
<section className="mt-8">
|
|
78
|
+
<h3 className="text-sm font-semibold">Expectations</h3>
|
|
79
|
+
<div className="mt-3 border border-border overflow-hidden">
|
|
80
|
+
{task.expectations.map((exp, i) => (
|
|
81
|
+
<div key={i} className="flex items-center justify-between border-b border-border last:border-0 px-4 py-3">
|
|
82
|
+
<div>
|
|
83
|
+
<span className="font-mono text-xs">{exp.type}</span>
|
|
84
|
+
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
|
85
|
+
{"command" in exp ? exp.command : "paths" in exp ? (exp.paths as string[]).join(", ") : ""}
|
|
86
|
+
</p>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
</section>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
{/* Expected outcomes */}
|
|
95
|
+
{task.expectedOutcomes && task.expectedOutcomes.length > 0 && (
|
|
96
|
+
<section className="mt-8">
|
|
97
|
+
<h3 className="text-sm font-semibold">Expected outcomes</h3>
|
|
98
|
+
<div className="mt-3 border border-border overflow-hidden">
|
|
99
|
+
{task.expectedOutcomes.map((o, i) => (
|
|
100
|
+
<div key={i} className="border-b border-border last:border-0 px-4 py-3">
|
|
101
|
+
<div className="flex items-center gap-2">
|
|
102
|
+
<span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{o.type}</span>
|
|
103
|
+
<span className="text-sm font-medium">{o.label}</span>
|
|
104
|
+
</div>
|
|
105
|
+
{o.description && <p className="mt-1 text-xs text-muted-foreground">{o.description}</p>}
|
|
106
|
+
</div>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
</section>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
{/* Config */}
|
|
113
|
+
<section className="mt-8">
|
|
114
|
+
<h3 className="text-sm font-semibold">Details</h3>
|
|
115
|
+
<div className="mt-3 border border-border bg-card overflow-hidden overflow-x-auto">
|
|
116
|
+
<table className="w-full text-sm min-w-[320px]">
|
|
117
|
+
<tbody>
|
|
118
|
+
{[
|
|
119
|
+
["Mission", task.missionId ?? "\u2014"],
|
|
120
|
+
["Dependencies", task.dependsOn?.join(", ") || "none"],
|
|
121
|
+
["Side effects", task.sideEffects ? "yes" : "no"],
|
|
122
|
+
["Session", task.sessionId ?? "\u2014"],
|
|
123
|
+
["Created", task.createdAt ?? "\u2014"],
|
|
124
|
+
["Updated", task.updatedAt ?? "\u2014"],
|
|
125
|
+
].map(([label, value]) => (
|
|
126
|
+
<tr key={label} className="border-b border-border last:border-0">
|
|
127
|
+
<td className="px-4 py-2.5 text-xs text-muted-foreground w-32">{label}</td>
|
|
128
|
+
<td className="px-4 py-2.5 text-xs font-mono">{value}</td>
|
|
129
|
+
</tr>
|
|
130
|
+
))}
|
|
131
|
+
</tbody>
|
|
132
|
+
</table>
|
|
133
|
+
</div>
|
|
134
|
+
</section>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Suspense } from "react";
|
|
2
|
+
import { dataApi } from "#/lib/api";
|
|
3
|
+
import type { Task, Mission } from "@polpo-ai/core";
|
|
4
|
+
import { TasksListSkeleton } from "#/components/dashboard/skeletons";
|
|
5
|
+
import TasksView from "./view";
|
|
6
|
+
|
|
7
|
+
async function TasksData({ id }: { id: string }) {
|
|
8
|
+
let tasks: Task[] = [];
|
|
9
|
+
let missions: Mission[] = [];
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const [tasksRes, missionsRes] = await Promise.all([
|
|
13
|
+
dataApi<{ ok: boolean; data: Task[] }>(id, "/v1/tasks").catch(() => ({
|
|
14
|
+
ok: false,
|
|
15
|
+
data: [] as Task[],
|
|
16
|
+
})),
|
|
17
|
+
dataApi<{ ok: boolean; data: Mission[] }>(id, "/v1/missions").catch(
|
|
18
|
+
() => ({ ok: false, data: [] as Mission[] }),
|
|
19
|
+
),
|
|
20
|
+
]);
|
|
21
|
+
tasks = tasksRes.data ?? [];
|
|
22
|
+
missions = missionsRes.data ?? [];
|
|
23
|
+
} catch {}
|
|
24
|
+
|
|
25
|
+
return <TasksView initialTasks={tasks} initialMissions={missions} />;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default async function TasksPage({
|
|
29
|
+
params,
|
|
30
|
+
}: {
|
|
31
|
+
params: Promise<{ id: string }>;
|
|
32
|
+
}) {
|
|
33
|
+
const { id } = await params;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Suspense fallback={<TasksListSkeleton />}>
|
|
37
|
+
<TasksData id={id} />
|
|
38
|
+
</Suspense>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { useParams } from "next/navigation";
|
|
5
|
+
import { useState, useMemo } from "react";
|
|
6
|
+
import Link from "next/link";
|
|
7
|
+
import { ChevronRight, RefreshCw, X, ListFilter, Search } from "lucide-react";
|
|
8
|
+
// Mixed imports — SDK 0.7.9 Task is missing `missionId` (present in core).
|
|
9
|
+
// Mission from SDK is fine. Track in OSS issue: SDK types should re-export from core.
|
|
10
|
+
import type { Task } from "@polpo-ai/core";
|
|
11
|
+
import type { Mission } from "@polpo-ai/sdk";
|
|
12
|
+
import { usePolpoClient } from "#/lib/polpo-client";
|
|
13
|
+
import { Button } from "#/components/ui/button";
|
|
14
|
+
import { Badge } from "#/components/ui/badge";
|
|
15
|
+
import { Popover, PopoverContent, PopoverTrigger } from "#/components/ui/popover";
|
|
16
|
+
import {
|
|
17
|
+
Command, CommandInput, CommandList, CommandEmpty,
|
|
18
|
+
CommandGroup, CommandItem,
|
|
19
|
+
} from "#/components/ui/command";
|
|
20
|
+
|
|
21
|
+
const ALL_STATUSES = [
|
|
22
|
+
"draft", "pending", "awaiting_approval", "assigned",
|
|
23
|
+
"in_progress", "review", "done", "failed",
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
const statusColor: Record<string, string> = {
|
|
27
|
+
done: "bg-brand",
|
|
28
|
+
in_progress: "bg-foreground animate-pulse",
|
|
29
|
+
pending: "bg-muted-foreground/30",
|
|
30
|
+
failed: "bg-destructive",
|
|
31
|
+
review: "bg-yellow-500",
|
|
32
|
+
assigned: "bg-muted-foreground/50",
|
|
33
|
+
draft: "bg-muted-foreground/20",
|
|
34
|
+
awaiting_approval: "bg-yellow-500",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const LIVE_STATUSES = new Set(["pending", "assigned", "in_progress", "review", "awaiting_approval"]);
|
|
38
|
+
|
|
39
|
+
function timeAgo(date?: string) {
|
|
40
|
+
if (!date) return "\u2014";
|
|
41
|
+
const diff = Date.now() - new Date(date).getTime();
|
|
42
|
+
const min = Math.floor(diff / 60000);
|
|
43
|
+
if (min < 60) return `${min}m ago`;
|
|
44
|
+
const hours = Math.floor(min / 60);
|
|
45
|
+
if (hours < 24) return `${hours}h ago`;
|
|
46
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function duration(ms?: number) {
|
|
50
|
+
if (!ms) return "\u2014";
|
|
51
|
+
const s = Math.floor(ms / 1000);
|
|
52
|
+
if (s < 60) return `${s}s`;
|
|
53
|
+
const m = Math.floor(s / 60);
|
|
54
|
+
return `${m}m ${s % 60}s`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default function TasksView({
|
|
58
|
+
initialTasks,
|
|
59
|
+
initialMissions,
|
|
60
|
+
}: {
|
|
61
|
+
initialTasks: Task[];
|
|
62
|
+
initialMissions: Mission[];
|
|
63
|
+
}) {
|
|
64
|
+
const { id } = useParams<{ id: string }>();
|
|
65
|
+
const [filterStatus, setFilterStatus] = useState<string | null>(null);
|
|
66
|
+
const [filterMission, setFilterMission] = useState<string | null>(null);
|
|
67
|
+
const [filterAgent, setFilterAgent] = useState<string | null>(null);
|
|
68
|
+
const [search, setSearch] = useState("");
|
|
69
|
+
const [page, setPage] = useState(0);
|
|
70
|
+
const [statusOpen, setStatusOpen] = useState(false);
|
|
71
|
+
const [missionOpen, setMissionOpen] = useState(false);
|
|
72
|
+
const [agentOpen, setAgentOpen] = useState(false);
|
|
73
|
+
const PER_PAGE = 15;
|
|
74
|
+
|
|
75
|
+
const polpo = usePolpoClient(id);
|
|
76
|
+
const { data: tasks = [], isFetching, refetch } = useQuery({
|
|
77
|
+
queryKey: ["tasks", id],
|
|
78
|
+
queryFn: () => polpo.getTasks() as unknown as Promise<Task[]>,
|
|
79
|
+
initialData: initialTasks,
|
|
80
|
+
refetchInterval: (query) => {
|
|
81
|
+
const rows = query.state.data ?? [];
|
|
82
|
+
return rows.some((task) => LIVE_STATUSES.has(task.status)) ? 5000 : false;
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const { data: missions = [] } = useQuery({
|
|
87
|
+
queryKey: ["missions", id],
|
|
88
|
+
queryFn: () => polpo.getMissions(),
|
|
89
|
+
initialData: initialMissions,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const activeStatuses = useMemo(
|
|
93
|
+
() => [...new Set(tasks.map(t => t.status).filter(Boolean))].sort(
|
|
94
|
+
(a, b) => ALL_STATUSES.indexOf(a as typeof ALL_STATUSES[number]) - ALL_STATUSES.indexOf(b as typeof ALL_STATUSES[number])
|
|
95
|
+
),
|
|
96
|
+
[tasks]
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const taskMissionIds = useMemo(
|
|
100
|
+
() => [...new Set(tasks.map(t => t.missionId).filter(Boolean))] as string[],
|
|
101
|
+
[tasks]
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const activeAgents = useMemo(
|
|
105
|
+
() => [...new Set(tasks.map(t => t.assignTo).filter(Boolean))].sort() as string[],
|
|
106
|
+
[tasks]
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const missionMap = useMemo(() => {
|
|
110
|
+
const map = new Map<string, string>();
|
|
111
|
+
for (const m of missions) map.set(m.id, m.name ?? m.id.slice(0, 8));
|
|
112
|
+
return map;
|
|
113
|
+
}, [missions]);
|
|
114
|
+
|
|
115
|
+
const filtered = useMemo(() => {
|
|
116
|
+
let result = tasks;
|
|
117
|
+
if (filterStatus) result = result.filter(t => t.status === filterStatus);
|
|
118
|
+
if (filterMission === "__none__") result = result.filter(t => !t.missionId);
|
|
119
|
+
else if (filterMission) result = result.filter(t => t.missionId === filterMission);
|
|
120
|
+
if (filterAgent) result = result.filter(t => t.assignTo === filterAgent);
|
|
121
|
+
if (search) {
|
|
122
|
+
const q = search.toLowerCase();
|
|
123
|
+
result = result.filter(t =>
|
|
124
|
+
(t.title ?? "").toLowerCase().includes(q) ||
|
|
125
|
+
(t.assignTo ?? "").toLowerCase().includes(q) ||
|
|
126
|
+
t.id.toLowerCase().includes(q)
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
}, [tasks, filterStatus, filterMission, filterAgent, search]);
|
|
131
|
+
|
|
132
|
+
const hasFilters = filterStatus !== null || filterMission !== null || filterAgent !== null;
|
|
133
|
+
const paged = filtered.slice(page * PER_PAGE, (page + 1) * PER_PAGE);
|
|
134
|
+
const totalPages = Math.ceil(filtered.length / PER_PAGE);
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div>
|
|
138
|
+
{/* Header */}
|
|
139
|
+
<div>
|
|
140
|
+
<h2 className="text-lg font-semibold tracking-tight">Tasks</h2>
|
|
141
|
+
<p className="mt-1 text-xs text-muted-foreground">{tasks.length} total</p>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{/* Filters + Search */}
|
|
145
|
+
{tasks.length > 0 && (
|
|
146
|
+
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
|
147
|
+
{/* Search */}
|
|
148
|
+
<div className="relative">
|
|
149
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
150
|
+
<input
|
|
151
|
+
type="text"
|
|
152
|
+
value={search}
|
|
153
|
+
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
|
154
|
+
placeholder="Search..."
|
|
155
|
+
className="w-48 border border-border bg-transparent pl-9 pr-3 py-1.5 text-xs placeholder:text-muted-foreground/40 focus:border-foreground/30 focus:outline-none transition-colors"
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{/* Status filter */}
|
|
160
|
+
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
|
161
|
+
<PopoverTrigger
|
|
162
|
+
render={
|
|
163
|
+
<Button variant="outline" size="sm" data-testid="filter-status">
|
|
164
|
+
<ListFilter className="h-3 w-3" />
|
|
165
|
+
{filterStatus ? (
|
|
166
|
+
<>
|
|
167
|
+
Status: <Badge variant="secondary" className="ml-1 font-normal">{filterStatus.replace(/_/g, " ")}</Badge>
|
|
168
|
+
</>
|
|
169
|
+
) : (
|
|
170
|
+
"Status"
|
|
171
|
+
)}
|
|
172
|
+
</Button>
|
|
173
|
+
}
|
|
174
|
+
/>
|
|
175
|
+
<PopoverContent className="w-52 p-0" align="start">
|
|
176
|
+
<Command>
|
|
177
|
+
<CommandInput placeholder="Search status..." />
|
|
178
|
+
<CommandList>
|
|
179
|
+
<CommandEmpty>No status found.</CommandEmpty>
|
|
180
|
+
<CommandGroup>
|
|
181
|
+
{activeStatuses.map(s => (
|
|
182
|
+
<CommandItem
|
|
183
|
+
key={s}
|
|
184
|
+
value={s}
|
|
185
|
+
data-checked={filterStatus === s || undefined}
|
|
186
|
+
onSelect={() => {
|
|
187
|
+
setFilterStatus(filterStatus === s ? null : s);
|
|
188
|
+
setStatusOpen(false);
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
<span className={`h-2 w-2 rounded-full shrink-0 ${statusColor[s]}`} />
|
|
192
|
+
{s.replace(/_/g, " ")}
|
|
193
|
+
</CommandItem>
|
|
194
|
+
))}
|
|
195
|
+
</CommandGroup>
|
|
196
|
+
</CommandList>
|
|
197
|
+
</Command>
|
|
198
|
+
</PopoverContent>
|
|
199
|
+
</Popover>
|
|
200
|
+
|
|
201
|
+
{/* Mission filter */}
|
|
202
|
+
{taskMissionIds.length > 0 && (
|
|
203
|
+
<Popover open={missionOpen} onOpenChange={setMissionOpen}>
|
|
204
|
+
<PopoverTrigger
|
|
205
|
+
render={
|
|
206
|
+
<Button variant="outline" size="sm" data-testid="filter-mission">
|
|
207
|
+
<ListFilter className="h-3 w-3" />
|
|
208
|
+
{filterMission ? (
|
|
209
|
+
<>
|
|
210
|
+
Mission: <Badge variant="secondary" className="ml-1 font-normal">
|
|
211
|
+
{filterMission === "__none__" ? "No mission" : (missionMap.get(filterMission) ?? filterMission.slice(0, 8))}
|
|
212
|
+
</Badge>
|
|
213
|
+
</>
|
|
214
|
+
) : (
|
|
215
|
+
"Mission"
|
|
216
|
+
)}
|
|
217
|
+
</Button>
|
|
218
|
+
}
|
|
219
|
+
/>
|
|
220
|
+
<PopoverContent className="w-56 p-0" align="start">
|
|
221
|
+
<Command>
|
|
222
|
+
<CommandInput placeholder="Search mission..." />
|
|
223
|
+
<CommandList>
|
|
224
|
+
<CommandEmpty>No mission found.</CommandEmpty>
|
|
225
|
+
<CommandGroup>
|
|
226
|
+
<CommandItem
|
|
227
|
+
value="__none__"
|
|
228
|
+
data-checked={filterMission === "__none__" || undefined}
|
|
229
|
+
onSelect={() => {
|
|
230
|
+
setFilterMission(filterMission === "__none__" ? null : "__none__");
|
|
231
|
+
setMissionOpen(false);
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
<span className="text-muted-foreground">No mission</span>
|
|
235
|
+
</CommandItem>
|
|
236
|
+
{taskMissionIds.map(mid => (
|
|
237
|
+
<CommandItem
|
|
238
|
+
key={mid}
|
|
239
|
+
value={missionMap.get(mid) ?? mid}
|
|
240
|
+
data-checked={filterMission === mid || undefined}
|
|
241
|
+
onSelect={() => {
|
|
242
|
+
setFilterMission(filterMission === mid ? null : mid);
|
|
243
|
+
setMissionOpen(false);
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
{missionMap.get(mid) ?? mid.slice(0, 8)}
|
|
247
|
+
</CommandItem>
|
|
248
|
+
))}
|
|
249
|
+
</CommandGroup>
|
|
250
|
+
</CommandList>
|
|
251
|
+
</Command>
|
|
252
|
+
</PopoverContent>
|
|
253
|
+
</Popover>
|
|
254
|
+
)}
|
|
255
|
+
|
|
256
|
+
{/* Agent filter */}
|
|
257
|
+
{activeAgents.length > 1 && (
|
|
258
|
+
<Popover open={agentOpen} onOpenChange={setAgentOpen}>
|
|
259
|
+
<PopoverTrigger
|
|
260
|
+
render={
|
|
261
|
+
<Button variant="outline" size="sm" data-testid="filter-agent">
|
|
262
|
+
<ListFilter className="h-3 w-3" />
|
|
263
|
+
{filterAgent ? (
|
|
264
|
+
<>
|
|
265
|
+
Agent: <Badge variant="secondary" className="ml-1 font-normal">{filterAgent}</Badge>
|
|
266
|
+
</>
|
|
267
|
+
) : (
|
|
268
|
+
"Agent"
|
|
269
|
+
)}
|
|
270
|
+
</Button>
|
|
271
|
+
}
|
|
272
|
+
/>
|
|
273
|
+
<PopoverContent className="w-52 p-0" align="start">
|
|
274
|
+
<Command>
|
|
275
|
+
<CommandInput placeholder="Search agent..." />
|
|
276
|
+
<CommandList>
|
|
277
|
+
<CommandEmpty>No agent found.</CommandEmpty>
|
|
278
|
+
<CommandGroup>
|
|
279
|
+
{activeAgents.map(a => (
|
|
280
|
+
<CommandItem
|
|
281
|
+
key={a}
|
|
282
|
+
value={a}
|
|
283
|
+
data-checked={filterAgent === a || undefined}
|
|
284
|
+
onSelect={() => {
|
|
285
|
+
setFilterAgent(filterAgent === a ? null : a);
|
|
286
|
+
setAgentOpen(false);
|
|
287
|
+
setPage(0);
|
|
288
|
+
}}
|
|
289
|
+
>
|
|
290
|
+
{a}
|
|
291
|
+
</CommandItem>
|
|
292
|
+
))}
|
|
293
|
+
</CommandGroup>
|
|
294
|
+
</CommandList>
|
|
295
|
+
</Command>
|
|
296
|
+
</PopoverContent>
|
|
297
|
+
</Popover>
|
|
298
|
+
)}
|
|
299
|
+
|
|
300
|
+
{/* Active filter badges + clear */}
|
|
301
|
+
{hasFilters && (
|
|
302
|
+
<>
|
|
303
|
+
<span className="text-xs text-muted-foreground">
|
|
304
|
+
{filtered.length} of {tasks.length}
|
|
305
|
+
</span>
|
|
306
|
+
<Button
|
|
307
|
+
variant="ghost"
|
|
308
|
+
size="xs"
|
|
309
|
+
onClick={() => { setFilterStatus(null); setFilterMission(null); setFilterAgent(null); setSearch(""); setPage(0); }}
|
|
310
|
+
data-testid="clear-filters"
|
|
311
|
+
>
|
|
312
|
+
<X className="h-3 w-3" />
|
|
313
|
+
Clear
|
|
314
|
+
</Button>
|
|
315
|
+
</>
|
|
316
|
+
)}
|
|
317
|
+
|
|
318
|
+
{/* Refresh */}
|
|
319
|
+
<button
|
|
320
|
+
onClick={() => refetch()}
|
|
321
|
+
disabled={isFetching}
|
|
322
|
+
data-testid="refresh-btn"
|
|
323
|
+
className="ml-auto inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
|
324
|
+
>
|
|
325
|
+
<RefreshCw className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`} />
|
|
326
|
+
Refresh
|
|
327
|
+
</button>
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
|
|
331
|
+
{/* Table */}
|
|
332
|
+
{filtered.length > 0 ? (
|
|
333
|
+
<div data-testid="tasks-table" className="mt-4 border border-border overflow-hidden overflow-x-auto min-w-[600px]">
|
|
334
|
+
<div className="flex items-center border-b border-border bg-secondary/50 text-left text-xs font-medium text-muted-foreground">
|
|
335
|
+
<span className="px-4 py-2.5 w-28">Status</span>
|
|
336
|
+
<span className="px-4 py-2.5 flex-1">Task</span>
|
|
337
|
+
<span className="px-4 py-2.5 w-24">Agent</span>
|
|
338
|
+
<span className="px-4 py-2.5 w-20">Duration</span>
|
|
339
|
+
<span className="px-4 py-2.5 w-16">Retries</span>
|
|
340
|
+
<span className="px-4 py-2.5 w-20 text-right">Updated</span>
|
|
341
|
+
<span className="w-8" />
|
|
342
|
+
</div>
|
|
343
|
+
{paged.map((task) => (
|
|
344
|
+
<Link
|
|
345
|
+
key={task.id}
|
|
346
|
+
href={`/projects/${id}/tasks/${task.id}`}
|
|
347
|
+
data-testid={`task-row-${task.id}`}
|
|
348
|
+
className="flex items-center border-b border-border last:border-0 hover:bg-secondary/30 transition-colors group"
|
|
349
|
+
>
|
|
350
|
+
<span className="px-4 py-3 w-28">
|
|
351
|
+
<span className="inline-flex items-center gap-1.5">
|
|
352
|
+
<span className={`h-2 w-2 rounded-full ${statusColor[task.status ?? "pending"]}`} />
|
|
353
|
+
<span className="text-xs text-muted-foreground">{task.status}</span>
|
|
354
|
+
</span>
|
|
355
|
+
</span>
|
|
356
|
+
<span className="px-4 py-3 flex-1">
|
|
357
|
+
<span className="text-sm font-medium block">{task.title}</span>
|
|
358
|
+
{task.missionId && (
|
|
359
|
+
<span className="block mt-0.5 text-[11px] text-muted-foreground/50">
|
|
360
|
+
mission: {missionMap.get(task.missionId) ?? task.missionId.slice(0, 8)}
|
|
361
|
+
</span>
|
|
362
|
+
)}
|
|
363
|
+
{task.dependsOn && task.dependsOn.length > 0 && (
|
|
364
|
+
<span className="block mt-0.5 text-[11px] text-muted-foreground/50">
|
|
365
|
+
depends on: {task.dependsOn.join(", ")}
|
|
366
|
+
</span>
|
|
367
|
+
)}
|
|
368
|
+
</span>
|
|
369
|
+
<span className="px-4 py-3 w-24 font-mono text-xs text-muted-foreground">
|
|
370
|
+
{task.assignTo ?? "\u2014"}
|
|
371
|
+
</span>
|
|
372
|
+
<span className="px-4 py-3 w-20 font-mono text-xs text-muted-foreground">
|
|
373
|
+
{duration(task.result?.duration)}
|
|
374
|
+
</span>
|
|
375
|
+
<span className="px-4 py-3 w-16 text-xs text-muted-foreground">
|
|
376
|
+
{task.retries ?? 0}/{task.maxRetries ?? 3}
|
|
377
|
+
</span>
|
|
378
|
+
<span className="px-4 py-3 w-20 text-right text-xs text-muted-foreground">
|
|
379
|
+
{timeAgo(task.updatedAt)}
|
|
380
|
+
</span>
|
|
381
|
+
<span className="px-2 py-3 w-8">
|
|
382
|
+
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/30 group-hover:text-muted-foreground transition-colors" />
|
|
383
|
+
</span>
|
|
384
|
+
</Link>
|
|
385
|
+
))}
|
|
386
|
+
{totalPages > 1 && (
|
|
387
|
+
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border">
|
|
388
|
+
<span className="text-xs text-muted-foreground">
|
|
389
|
+
{page * PER_PAGE + 1}–{Math.min((page + 1) * PER_PAGE, filtered.length)} of {filtered.length}
|
|
390
|
+
</span>
|
|
391
|
+
<div className="flex items-center gap-1">
|
|
392
|
+
<button
|
|
393
|
+
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
394
|
+
disabled={page === 0}
|
|
395
|
+
className="px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground border border-border transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
|
396
|
+
>
|
|
397
|
+
Prev
|
|
398
|
+
</button>
|
|
399
|
+
<button
|
|
400
|
+
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
|
401
|
+
disabled={page >= totalPages - 1}
|
|
402
|
+
className="px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground border border-border transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
|
403
|
+
>
|
|
404
|
+
Next
|
|
405
|
+
</button>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
)}
|
|
409
|
+
</div>
|
|
410
|
+
) : tasks.length > 0 ? (
|
|
411
|
+
<div className="mt-4 border border-border p-8 text-center text-sm text-muted-foreground">
|
|
412
|
+
{search ? "No tasks matching your search." : "No tasks match the current filters."}
|
|
413
|
+
</div>
|
|
414
|
+
) : (
|
|
415
|
+
<div data-testid="tasks-empty" className="mt-4 border border-border p-8 text-center text-sm text-muted-foreground">
|
|
416
|
+
No tasks yet.
|
|
417
|
+
</div>
|
|
418
|
+
)}
|
|
419
|
+
</div>
|
|
420
|
+
);
|
|
421
|
+
}
|