@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,222 @@
1
+ "use client";
2
+
3
+ import { useState, useMemo } from "react";
4
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
5
+ import { useParams, useRouter } from "next/navigation";
6
+ import Link from "next/link";
7
+ import { RefreshCw, Search, Play, Loader2, Trash2, Workflow, AlertTriangle } from "lucide-react";
8
+ import { usePolpoClient } from "#/lib/polpo-client";
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ DialogDescription,
15
+ DialogFooter,
16
+ DialogClose,
17
+ } from "#/components/ui/dialog";
18
+
19
+ interface PlaybookParam {
20
+ name: string;
21
+ type?: string;
22
+ description?: string;
23
+ required?: boolean;
24
+ default?: unknown;
25
+ enum?: unknown[];
26
+ }
27
+
28
+ interface Playbook {
29
+ name: string;
30
+ description: string;
31
+ mission: {
32
+ prompt?: string;
33
+ tasks: { title: string; assignTo: string; description?: string; dependsOn?: string[] }[];
34
+ };
35
+ parameters?: PlaybookParam[];
36
+ version?: string;
37
+ author?: string;
38
+ tags?: string[];
39
+ }
40
+
41
+ export default function PlaybooksView() {
42
+ const { id } = useParams<{ id: string }>();
43
+ const router = useRouter();
44
+ const queryClient = useQueryClient();
45
+ const [search, setSearch] = useState("");
46
+ const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
47
+
48
+ const polpo = usePolpoClient(id);
49
+ const { data: playbooks = [], isLoading, isFetching, refetch } = useQuery({
50
+ queryKey: ["playbooks", id],
51
+ queryFn: () => polpo.getPlaybooks() as unknown as Promise<Playbook[]>,
52
+ });
53
+
54
+ const deleteMutation = useMutation({
55
+ mutationFn: (name: string) => polpo.deletePlaybook(name),
56
+ onSuccess: () => {
57
+ queryClient.invalidateQueries({ queryKey: ["playbooks", id] });
58
+ setDeleteTarget(null);
59
+ },
60
+ });
61
+
62
+ const filtered = useMemo(() => {
63
+ if (!search) return playbooks;
64
+ const q = search.toLowerCase();
65
+ return playbooks.filter(p =>
66
+ p.name.toLowerCase().includes(q) ||
67
+ p.description?.toLowerCase().includes(q)
68
+ );
69
+ }, [playbooks, search]);
70
+
71
+ if (isLoading) {
72
+ return (
73
+ <div className="space-y-3">
74
+ {[1, 2, 3].map(i => (
75
+ <div key={i} className="h-20 rounded-lg bg-secondary/30 animate-pulse" />
76
+ ))}
77
+ </div>
78
+ );
79
+ }
80
+
81
+ return (
82
+ <div>
83
+ {/* Header */}
84
+ <div className="flex items-center justify-between">
85
+ <div>
86
+ <h2 className="text-lg font-semibold tracking-tight">Playbooks</h2>
87
+ <p className="mt-1 text-xs text-muted-foreground">{playbooks.length} total</p>
88
+ </div>
89
+ <button
90
+ onClick={() => refetch()}
91
+ disabled={isFetching}
92
+ className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
93
+ >
94
+ <RefreshCw className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`} />
95
+ Refresh
96
+ </button>
97
+ </div>
98
+
99
+ {/* Search */}
100
+ {playbooks.length > 0 && (
101
+ <div className="mt-3 relative">
102
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
103
+ <input
104
+ type="text"
105
+ value={search}
106
+ onChange={(e) => setSearch(e.target.value)}
107
+ placeholder="Search playbooks..."
108
+ className="h-8 w-full max-w-xs rounded-md border border-border bg-transparent pl-8 pr-3 text-xs focus:border-foreground/30 focus:outline-none transition-colors"
109
+ />
110
+ </div>
111
+ )}
112
+
113
+ {/* List */}
114
+ {filtered.length > 0 ? (
115
+ <div className="mt-4 space-y-2">
116
+ {filtered.map((playbook) => {
117
+ const params = playbook.parameters ?? [];
118
+ const taskCount = playbook.mission?.tasks?.length ?? 0;
119
+
120
+ return (
121
+ <div
122
+ key={playbook.name}
123
+ className="border border-border bg-card px-4 py-3 hover:border-foreground/10 transition-colors"
124
+ >
125
+ <div className="flex items-start justify-between gap-3">
126
+ <div className="flex-1 min-w-0">
127
+ {/* Name + badges */}
128
+ <div className="flex items-center gap-2 flex-wrap">
129
+ <Workflow className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
130
+ <Link href={`/projects/${id}/playbooks/${encodeURIComponent(playbook.name)}`} className="font-mono text-sm font-semibold hover:underline underline-offset-2">{playbook.name}</Link>
131
+ <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
132
+ {taskCount} task{taskCount !== 1 ? "s" : ""}
133
+ </span>
134
+ {params.length > 0 && (
135
+ <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
136
+ {params.length} param{params.length !== 1 ? "s" : ""}
137
+ </span>
138
+ )}
139
+ {playbook.version && (
140
+ <span className="text-[10px] text-muted-foreground/50">v{playbook.version}</span>
141
+ )}
142
+ </div>
143
+ {/* Description */}
144
+ <p className="mt-1 text-xs text-muted-foreground line-clamp-2">
145
+ {playbook.description}
146
+ </p>
147
+ {/* Param tags */}
148
+ {params.length > 0 && (
149
+ <div className="mt-2 flex items-center gap-1.5 flex-wrap">
150
+ {params.map(p => (
151
+ <span
152
+ key={p.name}
153
+ className="rounded border border-border px-1.5 py-0.5 text-[9px] font-mono text-muted-foreground"
154
+ >
155
+ {p.required && <span className="text-red-400 mr-0.5">*</span>}
156
+ {p.name}
157
+ {p.default !== undefined && (
158
+ <span className="text-muted-foreground/40">={String(p.default)}</span>
159
+ )}
160
+ </span>
161
+ ))}
162
+ </div>
163
+ )}
164
+ {/* Tags */}
165
+ {playbook.tags && playbook.tags.length > 0 && (
166
+ <div className="mt-1.5 flex gap-1">
167
+ {playbook.tags.map(t => (
168
+ <span key={t} className="rounded bg-foreground/5 px-1.5 py-0.5 text-[9px] text-muted-foreground">
169
+ {t}
170
+ </span>
171
+ ))}
172
+ </div>
173
+ )}
174
+ </div>
175
+
176
+ {/* Actions */}
177
+ </div>
178
+ </div>
179
+ );
180
+ })}
181
+ </div>
182
+ ) : playbooks.length > 0 ? (
183
+ <div className="mt-4 border border-border p-8 text-center text-sm text-muted-foreground">
184
+ No playbooks match your search.
185
+ </div>
186
+ ) : (
187
+ <div className="mt-4 border border-border p-8 text-center text-sm text-muted-foreground">
188
+ <Workflow className="h-8 w-8 mx-auto mb-3 text-muted-foreground/30" />
189
+ No playbooks yet.
190
+ </div>
191
+ )}
192
+ {/* Delete dialog */}
193
+ <Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
194
+ <DialogContent showCloseButton={false}>
195
+ <DialogHeader>
196
+ <div className="flex items-center gap-2">
197
+ <AlertTriangle className="h-4 w-4 text-destructive" />
198
+ <DialogTitle>Delete playbook</DialogTitle>
199
+ </div>
200
+ <DialogDescription>
201
+ Are you sure you want to delete <span className="font-mono font-medium text-foreground">{deleteTarget}</span>? This cannot be undone.
202
+ </DialogDescription>
203
+ </DialogHeader>
204
+ <DialogFooter>
205
+ <DialogClose
206
+ render={<button className="px-4 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors" />}
207
+ >
208
+ Cancel
209
+ </DialogClose>
210
+ <button
211
+ onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
212
+ disabled={deleteMutation.isPending}
213
+ className="inline-flex items-center gap-2 bg-destructive text-destructive-foreground px-4 py-1.5 text-xs font-medium transition-all hover:opacity-90 disabled:opacity-50 rounded-md"
214
+ >
215
+ {deleteMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : "Delete"}
216
+ </button>
217
+ </DialogFooter>
218
+ </DialogContent>
219
+ </Dialog>
220
+ </div>
221
+ );
222
+ }
@@ -0,0 +1,44 @@
1
+ import { Suspense } from "react";
2
+ import { dataApi } from "#/lib/api";
3
+ import type { Mission, ScheduleEntry } from "@polpo-ai/core";
4
+ import { SchedulesListSkeleton } from "#/components/dashboard/skeletons";
5
+ import SchedulesView from "./view";
6
+
7
+ async function SchedulesData({ id }: { id: string }) {
8
+ // Fetch schedules + missions in parallel so the join in the view is
9
+ // hydrated on first paint. Both endpoints are cheap (no per-row joins
10
+ // in the data plane), so the parallel cost is the slowest of the two.
11
+ let schedules: ScheduleEntry[] = [];
12
+ let missions: Mission[] = [];
13
+
14
+ try {
15
+ const [schedRes, missionRes] = await Promise.all([
16
+ dataApi<{ ok: boolean; data: ScheduleEntry[] }>(id, "/v1/schedules").catch(
17
+ () => ({ ok: false, data: [] as ScheduleEntry[] }),
18
+ ),
19
+ dataApi<{ ok: boolean; data: Mission[] }>(id, "/v1/missions").catch(
20
+ () => ({ ok: false, data: [] as Mission[] }),
21
+ ),
22
+ ]);
23
+ schedules = schedRes.data ?? [];
24
+ missions = missionRes.data ?? [];
25
+ } catch {
26
+ // Fail open — render empty state rather than 500.
27
+ }
28
+
29
+ return <SchedulesView initialSchedules={schedules} initialMissions={missions} />;
30
+ }
31
+
32
+ export default async function SchedulesPage({
33
+ params,
34
+ }: {
35
+ params: Promise<{ id: string }>;
36
+ }) {
37
+ const { id } = await params;
38
+
39
+ return (
40
+ <Suspense fallback={<SchedulesListSkeleton />}>
41
+ <SchedulesData id={id} />
42
+ </Suspense>
43
+ );
44
+ }
@@ -0,0 +1,385 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
5
+ import { useParams } from "next/navigation";
6
+ import Link from "next/link";
7
+ import {
8
+ RefreshCw,
9
+ Clock,
10
+ Loader2,
11
+ Pause,
12
+ Play,
13
+ Pencil,
14
+ Trash2,
15
+ AlertTriangle,
16
+ } from "lucide-react";
17
+ import type { Mission, ScheduleEntry, UpdateScheduleRequest } from "@polpo-ai/sdk";
18
+ import { usePolpoClient } from "#/lib/polpo-client";
19
+ import {
20
+ Dialog,
21
+ DialogContent,
22
+ DialogHeader,
23
+ DialogTitle,
24
+ DialogDescription,
25
+ } from "#/components/ui/dialog";
26
+
27
+ /**
28
+ * Schedules — client + pure-dogfood list of the project's active schedules.
29
+ *
30
+ * Schedules are mission-bound: an entry exists for every mission with a
31
+ * `schedule` set. Reads + inline actions all go through the published SDK
32
+ * (`usePolpoClient`): `getSchedules`/`getMissions` for the list, and
33
+ * `updateSchedule`/`deleteSchedule` (keyed by mission id) for pause/resume,
34
+ * edit-cron and delete. No raw `dataApi`/`api`. A schedule has no standalone
35
+ * detail (it's a cron on a mission), so rows link to the mission's page.
36
+ */
37
+
38
+ const typeBadge: Record<string, { label: string; className: string }> = {
39
+ recurring: { label: "Recurring", className: "bg-blue-500/10 text-blue-400" },
40
+ "one-shot": { label: "One-shot", className: "bg-amber-500/10 text-amber-400" },
41
+ };
42
+
43
+ type ScheduleRow = ScheduleEntry & {
44
+ missionName?: string;
45
+ executionCount?: number;
46
+ };
47
+
48
+ function relativeTime(date?: string, mode: "past" | "future" = "past"): string {
49
+ if (!date) return "—";
50
+ const now = Date.now();
51
+ const target = new Date(date).getTime();
52
+ const diff = mode === "future" ? target - now : now - target;
53
+ if (diff < 0) return mode === "future" ? "due" : "—";
54
+ const min = Math.floor(diff / 60_000);
55
+ const prefix = mode === "future" ? "in " : "";
56
+ const suffix = mode === "past" ? " ago" : "";
57
+ if (min < 1) return mode === "future" ? "<1m" : "just now";
58
+ if (min < 60) return `${prefix}${min}m${suffix}`;
59
+ const hours = Math.floor(min / 60);
60
+ if (hours < 24) return `${prefix}${hours}h${suffix}`;
61
+ const days = Math.floor(hours / 24);
62
+ if (days < 30) return `${prefix}${days}d${suffix}`;
63
+ return new Date(date).toLocaleDateString();
64
+ }
65
+
66
+ function isoTooltip(date?: string): string {
67
+ return date ? new Date(date).toISOString() : "";
68
+ }
69
+
70
+ export default function SchedulesView({
71
+ initialSchedules,
72
+ initialMissions,
73
+ }: {
74
+ initialSchedules: ScheduleEntry[];
75
+ initialMissions: Mission[];
76
+ }) {
77
+ const { id } = useParams<{ id: string }>();
78
+ const queryClient = useQueryClient();
79
+ const polpo = usePolpoClient(id);
80
+
81
+ const { data: schedules = [], isFetching, refetch } = useQuery({
82
+ queryKey: ["schedules", id],
83
+ queryFn: () => polpo.getSchedules(),
84
+ initialData: initialSchedules,
85
+ });
86
+
87
+ const { data: missions = [] } = useQuery({
88
+ queryKey: ["missions", id],
89
+ queryFn: () => polpo.getMissions(),
90
+ initialData: initialMissions,
91
+ });
92
+
93
+ const missionById = useMemo(
94
+ () => new Map(missions.map((m) => [m.id, m])),
95
+ [missions],
96
+ );
97
+
98
+ const rows: ScheduleRow[] = useMemo(
99
+ () =>
100
+ schedules.map((s) => {
101
+ const mission = missionById.get(s.missionId);
102
+ return { ...s, missionName: mission?.name, executionCount: mission?.executionCount };
103
+ }),
104
+ [schedules, missionById],
105
+ );
106
+
107
+ // ── Mutations (dogfood SDK) — keyed by mission id, per the SDK signature.
108
+ const invalidate = () => queryClient.invalidateQueries({ queryKey: ["schedules", id] });
109
+
110
+ const updateMutation = useMutation({
111
+ mutationFn: ({ missionId, patch }: { missionId: string; patch: UpdateScheduleRequest }) =>
112
+ polpo.updateSchedule(missionId, patch),
113
+ onSuccess: invalidate,
114
+ });
115
+ const deleteMutation = useMutation({
116
+ mutationFn: (missionId: string) => polpo.deleteSchedule(missionId),
117
+ onSuccess: () => {
118
+ invalidate();
119
+ setDeleteTarget(null);
120
+ },
121
+ });
122
+
123
+ const [editTarget, setEditTarget] = useState<ScheduleRow | null>(null);
124
+ const [editExpression, setEditExpression] = useState("");
125
+ const [editRecurring, setEditRecurring] = useState(true);
126
+ const [deleteTarget, setDeleteTarget] = useState<ScheduleRow | null>(null);
127
+ const [togglingId, setTogglingId] = useState<string | null>(null);
128
+
129
+ function openEdit(row: ScheduleRow) {
130
+ setEditTarget(row);
131
+ setEditExpression(row.expression);
132
+ setEditRecurring(row.recurring);
133
+ }
134
+ function saveEdit() {
135
+ if (!editTarget) return;
136
+ updateMutation.mutate(
137
+ { missionId: editTarget.missionId, patch: { expression: editExpression.trim(), recurring: editRecurring } },
138
+ { onSuccess: () => setEditTarget(null) },
139
+ );
140
+ }
141
+ function toggleEnabled(row: ScheduleRow) {
142
+ setTogglingId(row.id);
143
+ updateMutation.mutate(
144
+ { missionId: row.missionId, patch: { enabled: !row.enabled } },
145
+ { onSettled: () => setTogglingId(null) },
146
+ );
147
+ }
148
+
149
+ return (
150
+ <div>
151
+ <div className="flex items-center justify-between">
152
+ <div>
153
+ <h2 className="text-lg font-semibold tracking-tight">Schedules</h2>
154
+ <p className="mt-1 text-xs text-muted-foreground">
155
+ {schedules.length} active {schedules.length === 1 ? "schedule" : "schedules"}
156
+ </p>
157
+ </div>
158
+ <button
159
+ onClick={() => refetch()}
160
+ disabled={isFetching}
161
+ className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
162
+ >
163
+ <RefreshCw className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`} />
164
+ Refresh
165
+ </button>
166
+ </div>
167
+
168
+ {rows.length > 0 ? (
169
+ <div className="mt-4 border border-border overflow-hidden overflow-x-auto">
170
+ <table className="w-full text-sm min-w-[860px]">
171
+ <thead>
172
+ <tr className="border-b border-border bg-secondary/50">
173
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground w-20">Status</th>
174
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground">Mission</th>
175
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground w-24">Type</th>
176
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground w-32">Expression</th>
177
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground w-28">Next run</th>
178
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground w-16">Runs</th>
179
+ <th className="px-4 py-2.5 text-right text-xs font-medium text-muted-foreground w-28">Actions</th>
180
+ </tr>
181
+ </thead>
182
+ <tbody>
183
+ {rows.map((row) => {
184
+ const type = row.recurring ? "recurring" : "one-shot";
185
+ const badge = typeBadge[type];
186
+ const isOrphan = !row.missionName;
187
+ const isToggling = togglingId === row.id;
188
+ return (
189
+ <tr
190
+ key={row.id}
191
+ className="border-b border-border last:border-0 hover:bg-secondary/30 transition-colors"
192
+ >
193
+ <td className="px-4 py-3 w-20">
194
+ <span className="inline-flex items-center gap-1.5">
195
+ <span
196
+ className={`h-2 w-2 rounded-full ${row.enabled ? "bg-brand" : "bg-muted-foreground/30"}`}
197
+ />
198
+ <span className="text-xs text-muted-foreground">
199
+ {row.enabled ? "Active" : "Paused"}
200
+ </span>
201
+ </span>
202
+ </td>
203
+ <td className="px-4 py-3">
204
+ {isOrphan ? (
205
+ <span className="font-mono text-xs italic text-muted-foreground">
206
+ mission {row.missionId.slice(0, 8)}… (deleted)
207
+ </span>
208
+ ) : (
209
+ <Link
210
+ href={`/projects/${id}/missions/${row.missionId}`}
211
+ className="font-mono text-xs font-medium hover:underline underline-offset-2"
212
+ >
213
+ {row.missionName}
214
+ </Link>
215
+ )}
216
+ </td>
217
+ <td className="px-4 py-3 w-24">
218
+ <span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${badge.className}`}>
219
+ {badge.label}
220
+ </span>
221
+ </td>
222
+ <td className="px-4 py-3 w-32 font-mono text-xs text-muted-foreground">{row.expression}</td>
223
+ <td
224
+ className="px-4 py-3 w-28 text-xs text-muted-foreground"
225
+ title={isoTooltip(row.nextRunAt)}
226
+ >
227
+ {relativeTime(row.nextRunAt, "future")}
228
+ </td>
229
+ <td className="px-4 py-3 w-16 text-xs text-muted-foreground">{row.executionCount ?? 0}</td>
230
+ <td className="px-4 py-3 w-28">
231
+ <div className="flex items-center justify-end gap-1">
232
+ <button
233
+ onClick={() => toggleEnabled(row)}
234
+ disabled={isToggling}
235
+ title={row.enabled ? "Pause schedule" : "Resume schedule"}
236
+ className="inline-flex h-7 w-7 items-center justify-center border border-border text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground disabled:opacity-50"
237
+ >
238
+ {isToggling ? (
239
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
240
+ ) : row.enabled ? (
241
+ <Pause className="h-3.5 w-3.5" />
242
+ ) : (
243
+ <Play className="h-3.5 w-3.5" />
244
+ )}
245
+ </button>
246
+ <button
247
+ onClick={() => openEdit(row)}
248
+ title="Edit cron expression"
249
+ className="inline-flex h-7 w-7 items-center justify-center border border-border text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground"
250
+ >
251
+ <Pencil className="h-3.5 w-3.5" />
252
+ </button>
253
+ <button
254
+ onClick={() => setDeleteTarget(row)}
255
+ title="Delete schedule"
256
+ className="inline-flex h-7 w-7 items-center justify-center border border-border text-muted-foreground transition-colors hover:border-destructive/40 hover:text-destructive"
257
+ >
258
+ <Trash2 className="h-3.5 w-3.5" />
259
+ </button>
260
+ </div>
261
+ </td>
262
+ </tr>
263
+ );
264
+ })}
265
+ </tbody>
266
+ </table>
267
+ </div>
268
+ ) : (
269
+ <EmptyState />
270
+ )}
271
+
272
+ {/* Edit-cron dialog */}
273
+ <Dialog open={!!editTarget} onOpenChange={(open) => !open && setEditTarget(null)}>
274
+ <DialogContent className="sm:max-w-md">
275
+ <DialogHeader>
276
+ <DialogTitle>Edit schedule</DialogTitle>
277
+ <DialogDescription>
278
+ {editTarget?.missionName
279
+ ? `Cron + recurrence for "${editTarget.missionName}".`
280
+ : "Update the cron expression and recurrence for this schedule."}
281
+ </DialogDescription>
282
+ </DialogHeader>
283
+ <div className="space-y-3 pt-1">
284
+ <div>
285
+ <label className="mb-1 block font-mono text-[10px] font-bold uppercase tracking-[0.14em] text-muted-foreground/60">
286
+ Cron expression
287
+ </label>
288
+ <input
289
+ value={editExpression}
290
+ onChange={(e) => setEditExpression(e.target.value)}
291
+ placeholder="0 9 * * *"
292
+ className="h-8 w-full border border-border bg-transparent px-3 font-mono text-xs placeholder:text-muted-foreground/30 focus:border-foreground/30 focus:outline-none"
293
+ />
294
+ </div>
295
+ <label className="flex items-center gap-2 text-xs text-muted-foreground">
296
+ <input
297
+ type="checkbox"
298
+ checked={editRecurring}
299
+ onChange={(e) => setEditRecurring(e.target.checked)}
300
+ className="h-3.5 w-3.5 accent-foreground"
301
+ />
302
+ Recurring (re-run on every cron tick)
303
+ </label>
304
+ <div className="flex items-center justify-end gap-2 pt-1">
305
+ {updateMutation.isError && (
306
+ <span className="mr-auto text-[10px] text-destructive">Failed — check the expression.</span>
307
+ )}
308
+ <button
309
+ onClick={() => setEditTarget(null)}
310
+ className="px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
311
+ >
312
+ Cancel
313
+ </button>
314
+ <button
315
+ onClick={saveEdit}
316
+ disabled={!editExpression.trim() || updateMutation.isPending}
317
+ className="inline-flex items-center gap-1.5 border border-foreground bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-colors hover:bg-foreground/90 disabled:opacity-40"
318
+ >
319
+ {updateMutation.isPending && <Loader2 className="h-3 w-3 animate-spin" />}
320
+ Save
321
+ </button>
322
+ </div>
323
+ </div>
324
+ </DialogContent>
325
+ </Dialog>
326
+
327
+ {/* Delete-confirm dialog */}
328
+ <Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
329
+ <DialogContent className="sm:max-w-md">
330
+ <DialogHeader>
331
+ <DialogTitle className="flex items-center gap-2">
332
+ <AlertTriangle className="h-4 w-4 text-destructive" strokeWidth={1.5} />
333
+ Delete schedule
334
+ </DialogTitle>
335
+ <DialogDescription>
336
+ Remove the schedule for{" "}
337
+ <span className="font-mono font-medium text-foreground">
338
+ {deleteTarget?.missionName ?? `mission ${deleteTarget?.missionId.slice(0, 8)}…`}
339
+ </span>
340
+ ? The mission itself is kept — only the cron is removed.
341
+ </DialogDescription>
342
+ </DialogHeader>
343
+ <div className="flex items-center justify-end gap-2 pt-1">
344
+ {deleteMutation.isError && (
345
+ <span className="mr-auto text-[10px] text-destructive">Failed to delete.</span>
346
+ )}
347
+ <button
348
+ onClick={() => setDeleteTarget(null)}
349
+ className="px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
350
+ >
351
+ Cancel
352
+ </button>
353
+ <button
354
+ onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.missionId)}
355
+ disabled={deleteMutation.isPending}
356
+ className="inline-flex items-center gap-1.5 border border-destructive bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
357
+ >
358
+ {deleteMutation.isPending && <Loader2 className="h-3 w-3 animate-spin" />}
359
+ Delete
360
+ </button>
361
+ </div>
362
+ </DialogContent>
363
+ </Dialog>
364
+ </div>
365
+ );
366
+ }
367
+
368
+ function EmptyState() {
369
+ return (
370
+ <div className="mt-4 border border-border p-10 text-center">
371
+ <div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-full bg-secondary/50">
372
+ <Clock className="h-5 w-5 text-muted-foreground" strokeWidth={1.5} />
373
+ </div>
374
+ <h3 className="text-sm font-medium">No schedules yet</h3>
375
+ <p className="mx-auto mt-2 max-w-md text-xs leading-relaxed text-muted-foreground">
376
+ Schedule a mission to run automatically on a cron expression or a one-shot date.
377
+ Currently supported: <span className="font-medium text-foreground">missions</span>.
378
+ Coming soon: scheduled completions and channel delivery.
379
+ </p>
380
+ <p className="mt-3 text-[11px] text-muted-foreground/60">
381
+ Create via <code className="rounded bg-secondary/50 px-1 py-0.5 font-mono">POST /v1/schedules</code>
382
+ </p>
383
+ </div>
384
+ );
385
+ }