@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,283 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import { useParams, useRouter } from "next/navigation";
6
+ import Link from "next/link";
7
+ // SDK-bundled types — consistent with what `polpo.X()` returns.
8
+ import type { AgentConfig } from "@polpo-ai/sdk";
9
+ import { RefreshCw, ChevronRight, Users, User, Search, Plus, Loader2 } from "lucide-react";
10
+ import { usePolpoClient } from "#/lib/polpo-client";
11
+ import {
12
+ Dialog,
13
+ DialogContent,
14
+ DialogDescription,
15
+ DialogHeader,
16
+ DialogTitle,
17
+ DialogTrigger,
18
+ } from "#/components/ui/dialog";
19
+
20
+ export interface Team {
21
+ name: string;
22
+ description?: string;
23
+ createdAt?: string;
24
+ }
25
+
26
+ export default function AgentsView({
27
+ initialAgents,
28
+ initialTeams,
29
+ }: {
30
+ initialAgents: AgentConfig[];
31
+ initialTeams: Team[];
32
+ }) {
33
+ const { id } = useParams<{ id: string }>();
34
+ const router = useRouter();
35
+ const [tab, setTab] = useState<"agents" | "teams">("agents");
36
+ const [search, setSearch] = useState("");
37
+
38
+ const polpo = usePolpoClient(id);
39
+ const { data: agents = [], isFetching: fa, refetch: ra } = useQuery({
40
+ queryKey: ["agents", id],
41
+ queryFn: () => polpo.getAgents(),
42
+ initialData: initialAgents,
43
+ });
44
+
45
+ const { data: teams = [] } = useQuery({
46
+ queryKey: ["teams", id],
47
+ queryFn: () => polpo.getTeams() as unknown as Promise<Team[]>,
48
+ initialData: initialTeams,
49
+ });
50
+
51
+ return (
52
+ <div>
53
+ {/* Header */}
54
+ <div className="flex items-start justify-between gap-4">
55
+ <div>
56
+ <h2 className="text-lg font-semibold tracking-tight">Agents</h2>
57
+ <p className="mt-1 text-xs text-muted-foreground">
58
+ {agents.length} agents across {teams.length} teams.
59
+ </p>
60
+ </div>
61
+ <NewAgentButton projectId={id} />
62
+ </div>
63
+
64
+ {/* Search + Tabs + Refresh */}
65
+ <div className="mt-3 flex items-center gap-2 flex-wrap">
66
+ <div className="relative">
67
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
68
+ <input
69
+ type="text"
70
+ value={search}
71
+ onChange={(e) => setSearch(e.target.value)}
72
+ placeholder="Search..."
73
+ 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"
74
+ />
75
+ </div>
76
+
77
+ <div className="flex items-center gap-1 rounded-lg bg-muted/50 p-1">
78
+ <button
79
+ onClick={() => setTab("agents")}
80
+ className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
81
+ tab === "agents" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
82
+ }`}
83
+ >
84
+ <User className="h-3.5 w-3.5" />
85
+ Agents ({agents.length})
86
+ </button>
87
+ <button
88
+ onClick={() => setTab("teams")}
89
+ className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
90
+ tab === "teams" ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
91
+ }`}
92
+ >
93
+ <Users className="h-3.5 w-3.5" />
94
+ Teams ({teams.length})
95
+ </button>
96
+ </div>
97
+
98
+ <button
99
+ onClick={() => ra()}
100
+ disabled={fa}
101
+ className="ml-auto inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
102
+ >
103
+ <RefreshCw className={`h-3 w-3 ${fa ? "animate-spin" : ""}`} />
104
+ Refresh
105
+ </button>
106
+ </div>
107
+
108
+ {tab === "agents" ? (() => {
109
+ const q = search.toLowerCase();
110
+ const filteredAgents = q
111
+ ? agents.filter((a) => a.name.toLowerCase().includes(q) || (a.role ?? "").toLowerCase().includes(q) || (a as any).teamName?.toLowerCase().includes(q))
112
+ : agents;
113
+ return filteredAgents.length > 0 ? (
114
+ <div className="mt-4 border border-border overflow-hidden overflow-x-auto">
115
+ <table className="w-full text-sm min-w-[600px]">
116
+ <thead>
117
+ <tr className="border-b border-border bg-secondary/50">
118
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground">Agent</th>
119
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground">Team</th>
120
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground">Model</th>
121
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground">Tools</th>
122
+ <th className="w-8" />
123
+ </tr>
124
+ </thead>
125
+ <tbody>
126
+ {filteredAgents.map(agent => {
127
+ const href = `/projects/${id}/agents/${encodeURIComponent(agent.name)}`;
128
+ return (
129
+ <tr
130
+ key={agent.name}
131
+ onClick={() => router.push(href)}
132
+ className="border-b border-border last:border-0 hover:bg-secondary/30 transition-colors group cursor-pointer"
133
+ >
134
+ <td className="px-4 py-3">
135
+ <Link
136
+ href={href}
137
+ onClick={(e) => e.stopPropagation()}
138
+ className="font-mono text-xs font-semibold hover:underline"
139
+ >
140
+ {agent.name}
141
+ </Link>
142
+ {agent.role && (
143
+ <span className="block mt-0.5 text-[11px] text-muted-foreground/60 truncate max-w-[200px]">{agent.role}</span>
144
+ )}
145
+ </td>
146
+ <td className="px-4 py-3">
147
+ {(agent as any).teamName ? (
148
+ <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
149
+ {(agent as any).teamName}
150
+ </span>
151
+ ) : (
152
+ <span className="text-[10px] text-muted-foreground/30">&mdash;</span>
153
+ )}
154
+ </td>
155
+ <td className="px-4 py-3 font-mono text-xs text-muted-foreground">{agent.model ?? "\u2014"}</td>
156
+ <td className="px-4 py-3 text-xs text-muted-foreground">
157
+ {agent.allowedTools?.length ?? 0}
158
+ </td>
159
+ <td className="px-2 py-3">
160
+ <ChevronRight className="h-3.5 w-3.5 text-muted-foreground/30 group-hover:text-muted-foreground transition-colors" />
161
+ </td>
162
+ </tr>
163
+ );
164
+ })}
165
+ </tbody>
166
+ </table>
167
+ </div>
168
+ ) : (
169
+ <div className="mt-4 border border-border p-8 text-center text-sm text-muted-foreground">
170
+ {search ? "No agents matching your search." : "No agents deployed. Use polpo deploy to deploy agents."}
171
+ </div>
172
+ );
173
+ })() : (
174
+ teams.length > 0 ? (
175
+ <div className="mt-4 border border-border overflow-hidden">
176
+ <table className="w-full text-sm">
177
+ <thead>
178
+ <tr className="border-b border-border bg-secondary/50">
179
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground">Team</th>
180
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground">Description</th>
181
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground w-20">Agents</th>
182
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground w-28">Created</th>
183
+ </tr>
184
+ </thead>
185
+ <tbody>
186
+ {teams.map(team => {
187
+ const teamAgents = agents.filter(a => (a as any).teamName === team.name);
188
+ return (
189
+ <tr key={team.name} className="border-b border-border last:border-0">
190
+ <td className="px-4 py-3 font-mono text-xs font-semibold">{team.name}</td>
191
+ <td className="px-4 py-3 text-xs text-muted-foreground">{team.description ?? "\u2014"}</td>
192
+ <td className="px-4 py-3 text-xs text-muted-foreground">{teamAgents.length}</td>
193
+ <td className="px-4 py-3 text-xs text-muted-foreground">
194
+ {team.createdAt
195
+ ? new Date(team.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })
196
+ : "\u2014"
197
+ }
198
+ </td>
199
+ </tr>
200
+ );
201
+ })}
202
+ </tbody>
203
+ </table>
204
+ </div>
205
+ ) : (
206
+ <div className="mt-4 border border-border p-8 text-center text-sm text-muted-foreground">
207
+ No teams configured.
208
+ </div>
209
+ )
210
+ )}
211
+ </div>
212
+ );
213
+ }
214
+
215
+ /**
216
+ * New agent — asks for a name, creates the agent via the SDK (dogfood),
217
+ * then drops the user straight into the Agent Builder to configure it.
218
+ */
219
+ function NewAgentButton({ projectId }: { projectId: string }) {
220
+ const polpo = usePolpoClient(projectId);
221
+ const router = useRouter();
222
+ const [open, setOpen] = useState(false);
223
+ const [name, setName] = useState("");
224
+ const [saving, setSaving] = useState(false);
225
+ const [error, setError] = useState<string | null>(null);
226
+
227
+ async function create() {
228
+ const trimmed = name.trim();
229
+ if (!trimmed) return;
230
+ setSaving(true);
231
+ setError(null);
232
+ try {
233
+ await polpo.addAgent({ name: trimmed });
234
+ // Hand off to the agent detail (Studio) to configure the fresh agent.
235
+ router.push(
236
+ `/projects/${projectId}/agents/${encodeURIComponent(trimmed)}`,
237
+ );
238
+ } catch (err) {
239
+ setError((err as Error).message ?? "Failed to create agent");
240
+ setSaving(false);
241
+ }
242
+ }
243
+
244
+ return (
245
+ <Dialog open={open} onOpenChange={setOpen}>
246
+ <DialogTrigger className="inline-flex shrink-0 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">
247
+ <Plus className="h-3.5 w-3.5" strokeWidth={2} />
248
+ New agent
249
+ </DialogTrigger>
250
+ <DialogContent className="sm:max-w-md">
251
+ <DialogHeader>
252
+ <DialogTitle>New agent</DialogTitle>
253
+ <DialogDescription>
254
+ Give it a name — you&apos;ll configure the rest in the builder.
255
+ </DialogDescription>
256
+ </DialogHeader>
257
+
258
+ <div className="flex flex-col gap-3 pt-1">
259
+ <input
260
+ autoFocus
261
+ value={name}
262
+ onChange={(e) => setName(e.target.value)}
263
+ onKeyDown={(e) => {
264
+ if (e.key === "Enter") void create();
265
+ }}
266
+ placeholder="agent-name"
267
+ className="border border-border bg-background px-3 py-2 font-mono text-sm text-foreground outline-none focus:border-foreground/40"
268
+ />
269
+ {error && <p className="text-xs text-destructive">{error}</p>}
270
+ <button
271
+ type="button"
272
+ onClick={() => void create()}
273
+ disabled={saving || !name.trim()}
274
+ className="inline-flex items-center justify-center gap-1.5 border border-foreground bg-foreground px-3 py-2 text-sm font-medium text-background transition-colors hover:bg-foreground/90 disabled:opacity-50"
275
+ >
276
+ {saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
277
+ Create &amp; edit
278
+ </button>
279
+ </div>
280
+ </DialogContent>
281
+ </Dialog>
282
+ );
283
+ }
@@ -0,0 +1,11 @@
1
+ export default function ProjectLayout({
2
+ children,
3
+ }: {
4
+ children: React.ReactNode;
5
+ }) {
6
+ return (
7
+ <div data-testid="project-detail" className="px-4 py-6 md:px-8 md:py-8">
8
+ {children}
9
+ </div>
10
+ );
11
+ }
@@ -0,0 +1,27 @@
1
+ import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
2
+ import getQueryClient from "#/lib/get-query-client";
3
+ import { dataApi } from "#/lib/api";
4
+ import LogsView from "./view";
5
+
6
+ export default async function LogsPage({
7
+ params,
8
+ }: {
9
+ params: Promise<{ id: string }>;
10
+ }) {
11
+ const { id } = await params;
12
+ const queryClient = getQueryClient();
13
+
14
+ await queryClient.prefetchQuery({
15
+ queryKey: ["log-sessions", id],
16
+ queryFn: () =>
17
+ dataApi<{ ok: boolean; data: unknown[] }>(id, "/v1/logs").then(
18
+ (r) => r.data ?? []
19
+ ),
20
+ });
21
+
22
+ return (
23
+ <HydrationBoundary state={dehydrate(queryClient)}>
24
+ <LogsView />
25
+ </HydrationBoundary>
26
+ );
27
+ }
@@ -0,0 +1,184 @@
1
+ "use client";
2
+
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { useParams } from "next/navigation";
5
+ import { useState } from "react";
6
+ import { usePolpoClient } from "#/lib/polpo-client";
7
+ import { LogsViewSkeleton, LogsEntriesSkeleton } from "#/components/dashboard/skeletons";
8
+ import { ManualRefreshButton } from "#/components/dashboard/manual-refresh-button";
9
+
10
+ interface SessionInfo {
11
+ sessionId: string;
12
+ startedAt: string;
13
+ entries: number;
14
+ }
15
+
16
+ interface LogEntry {
17
+ ts: string;
18
+ event: string;
19
+ data: unknown;
20
+ }
21
+
22
+ const eventColor: Record<string, string> = {
23
+ "task:created": "text-blue-500",
24
+ "task:transition": "text-foreground",
25
+ "task:updated": "text-muted-foreground",
26
+ "agent:spawned": "text-green-500",
27
+ "agent:finished": "text-green-600",
28
+ "agent:activity": "text-muted-foreground",
29
+ "assessment:started": "text-yellow-500",
30
+ "assessment:complete": "text-yellow-600",
31
+ "mission:started": "text-blue-500",
32
+ "mission:completed": "text-green-500",
33
+ "mission:failed": "text-destructive",
34
+ "log": "text-muted-foreground",
35
+ };
36
+
37
+ function timeAgo(date: string) {
38
+ const diff = Date.now() - new Date(date).getTime();
39
+ const min = Math.floor(diff / 60000);
40
+ if (min < 60) return `${min}m ago`;
41
+ const hours = Math.floor(min / 60);
42
+ if (hours < 24) return `${hours}h ago`;
43
+ return `${Math.floor(hours / 24)}d ago`;
44
+ }
45
+
46
+ function formatTime(ts: string) {
47
+ return new Date(ts).toLocaleTimeString("en-US", {
48
+ hour: "2-digit",
49
+ minute: "2-digit",
50
+ second: "2-digit",
51
+ hour12: false,
52
+ });
53
+ }
54
+
55
+ function summarizeData(data: unknown): string {
56
+ if (!data || typeof data !== "object") return "";
57
+ const d = data as Record<string, unknown>;
58
+ const parts: string[] = [];
59
+ if (d.id) parts.push(`id:${String(d.id).slice(0, 8)}`);
60
+ if (d.title) parts.push(String(d.title));
61
+ if (d.name) parts.push(String(d.name));
62
+ if (d.from && d.to) parts.push(`${d.from} → ${d.to}`);
63
+ if (d.status) parts.push(String(d.status));
64
+ if (d.agent) parts.push(String(d.agent));
65
+ return parts.join(" · ");
66
+ }
67
+
68
+ export default function LogsView() {
69
+ const { id } = useParams<{ id: string }>();
70
+ const [selectedSession, setSelectedSession] = useState<string | null>(null);
71
+
72
+ const polpo = usePolpoClient(id);
73
+ const { data: sessions = [], isLoading, isFetching: sessionsFetching, refetch: refetchSessions } = useQuery({
74
+ queryKey: ["log-sessions", id],
75
+ queryFn: () => polpo.getLogs() as unknown as Promise<SessionInfo[]>,
76
+ });
77
+
78
+ const { data: entries = [], isLoading: loadingEntries, isFetching: entriesFetching, refetch: refetchEntries } = useQuery({
79
+ queryKey: ["log-entries", id, selectedSession],
80
+ queryFn: () => polpo.getLogEntries(selectedSession!) as unknown as Promise<LogEntry[]>,
81
+ enabled: !!selectedSession,
82
+ });
83
+
84
+ if (isLoading) {
85
+ return <div data-testid="logs-loading"><LogsViewSkeleton /></div>;
86
+ }
87
+
88
+ return (
89
+ <div>
90
+ <div className="flex items-start justify-between gap-4">
91
+ <div>
92
+ <h2 className="text-lg font-semibold tracking-tight">Logs</h2>
93
+ <p className="mt-1 text-xs text-muted-foreground">
94
+ Orchestrator session logs. Each session captures events from a single
95
+ orchestrator run.
96
+ </p>
97
+ </div>
98
+ <ManualRefreshButton
99
+ onRefresh={() => Promise.all([refetchSessions(), selectedSession ? refetchEntries() : Promise.resolve()])}
100
+ isRefreshing={sessionsFetching || entriesFetching}
101
+ className="mt-1 shrink-0"
102
+ />
103
+ </div>
104
+
105
+ <div className="mt-6 flex gap-6">
106
+ {/* Session list */}
107
+ <div data-testid="logs-sessions" className="w-64 shrink-0">
108
+ <p className="text-xs font-medium text-muted-foreground mb-3">
109
+ {sessions.length} sessions
110
+ </p>
111
+ {sessions.length > 0 ? (
112
+ <div className="border border-border overflow-hidden">
113
+ {sessions.map((s) => (
114
+ <button
115
+ key={s.sessionId}
116
+ onClick={() => setSelectedSession(s.sessionId)}
117
+ className={`w-full text-left px-3 py-2.5 border-b border-border last:border-0 transition-colors ${
118
+ selectedSession === s.sessionId
119
+ ? "bg-secondary"
120
+ : "hover:bg-secondary/30"
121
+ }`}
122
+ >
123
+ <p className="font-mono text-xs font-medium truncate">
124
+ {s.sessionId.slice(0, 12)}...
125
+ </p>
126
+ <div className="mt-1 flex items-center justify-between text-[11px] text-muted-foreground">
127
+ <span>{timeAgo(s.startedAt)}</span>
128
+ <span>{s.entries} events</span>
129
+ </div>
130
+ </button>
131
+ ))}
132
+ </div>
133
+ ) : (
134
+ <div data-testid="logs-sessions-empty" className="border border-border p-6 text-center text-xs text-muted-foreground">
135
+ No sessions yet.
136
+ </div>
137
+ )}
138
+ </div>
139
+
140
+ {/* Log entries */}
141
+ <div className="flex-1 min-w-0">
142
+ {selectedSession ? (
143
+ loadingEntries ? (
144
+ <div data-testid="logs-entries-loading">
145
+ <LogsEntriesSkeleton />
146
+ </div>
147
+ ) : entries.length > 0 ? (
148
+ <div data-testid="logs-entries" className="border border-border overflow-hidden">
149
+ {entries.map((entry, i) => (
150
+ <div
151
+ key={i}
152
+ className="flex items-start gap-3 border-b border-border last:border-0 px-3 py-2 font-mono text-xs"
153
+ >
154
+ <span className="text-muted-foreground/40 w-16 shrink-0 pt-0.5">
155
+ {formatTime(entry.ts)}
156
+ </span>
157
+ <span
158
+ className={`shrink-0 pt-0.5 w-40 truncate font-medium ${
159
+ eventColor[entry.event] ?? "text-muted-foreground"
160
+ }`}
161
+ >
162
+ {entry.event}
163
+ </span>
164
+ <span className="text-muted-foreground truncate">
165
+ {summarizeData(entry.data)}
166
+ </span>
167
+ </div>
168
+ ))}
169
+ </div>
170
+ ) : (
171
+ <div data-testid="logs-entries-empty" className="border border-border p-8 text-center text-sm text-muted-foreground">
172
+ No entries in this session.
173
+ </div>
174
+ )
175
+ ) : (
176
+ <div className="border border-border p-8 text-center text-sm text-muted-foreground">
177
+ Select a session to view logs.
178
+ </div>
179
+ )}
180
+ </div>
181
+ </div>
182
+ </div>
183
+ );
184
+ }
@@ -0,0 +1,40 @@
1
+ import { Suspense } from "react";
2
+ import { dataApi } from "#/lib/api";
3
+ import { ProjectMemorySkeleton } from "#/components/dashboard/skeletons";
4
+ import MemoryView from "./view";
5
+
6
+ async function MemoryData({ id }: { id: string }) {
7
+ let memoryContent = "";
8
+ try {
9
+ const res = await dataApi<{ ok: boolean; data: { content: string } }>(
10
+ id,
11
+ "/v1/memory",
12
+ );
13
+ memoryContent = res.data?.content ?? "";
14
+ } catch {}
15
+
16
+ return <MemoryView memoryContent={memoryContent} />;
17
+ }
18
+
19
+ export default async function MemoryPage({
20
+ params,
21
+ }: {
22
+ params: Promise<{ id: string }>;
23
+ }) {
24
+ const { id } = await params;
25
+
26
+ return (
27
+ <div>
28
+ <h2 className="text-lg font-semibold tracking-tight">Project memory</h2>
29
+ <p className="mt-1 text-xs text-muted-foreground leading-relaxed max-w-xl">
30
+ Memory is built and maintained automatically as agents work. Project memory is
31
+ shared across all agents — every agent in this project inherits this context
32
+ at the start of each session.
33
+ </p>
34
+
35
+ <Suspense fallback={<ProjectMemorySkeleton />}>
36
+ <MemoryData id={id} />
37
+ </Suspense>
38
+ </div>
39
+ );
40
+ }
@@ -0,0 +1,17 @@
1
+ import { Markdown } from "#/components/dashboard/markdown";
2
+
3
+ export default function MemoryView({ memoryContent }: { memoryContent: string }) {
4
+ if (memoryContent) {
5
+ return (
6
+ <div data-testid="project-memory-content" className="mt-6 border border-border bg-card p-6">
7
+ <Markdown content={memoryContent} />
8
+ </div>
9
+ );
10
+ }
11
+
12
+ return (
13
+ <div className="mt-6 border border-border p-8 text-center text-sm text-muted-foreground">
14
+ No project memory yet. Memory is created automatically when agents work.
15
+ </div>
16
+ );
17
+ }
@@ -0,0 +1,88 @@
1
+ import { Suspense } from "react";
2
+ import Link from "next/link";
3
+ import { ArrowLeft } from "lucide-react";
4
+ import { dataApi } from "#/lib/api";
5
+ import type { Mission, Task } from "@polpo-ai/core";
6
+ import { MissionGraphSkeleton, MissionHeaderSkeleton } from "#/components/dashboard/skeletons";
7
+ import { MissionHeader, MissionTasksPanel } from "./view";
8
+
9
+ async function MissionHeaderData({
10
+ id,
11
+ missionId,
12
+ }: {
13
+ id: string;
14
+ missionId: string;
15
+ }) {
16
+ let mission: Mission | null = null;
17
+ try {
18
+ const res = await dataApi<{ ok: boolean; data: Mission }>(
19
+ id,
20
+ `/v1/missions/${missionId}`,
21
+ );
22
+ mission = res.data ?? null;
23
+ } catch {}
24
+
25
+ return (
26
+ <MissionHeader projectId={id} missionId={missionId} initialMission={mission} />
27
+ );
28
+ }
29
+
30
+ async function MissionTasksData({
31
+ id,
32
+ missionId,
33
+ }: {
34
+ id: string;
35
+ missionId: string;
36
+ }) {
37
+ // Fetch both the spawned tasks (live) and the mission record (for the
38
+ // draft-mode preview that parses `mission.data` into the same shape).
39
+ let tasks: Task[] = [];
40
+ let mission: Mission | null = null;
41
+ try {
42
+ const [tasksRes, missionRes] = await Promise.all([
43
+ dataApi<{ ok: boolean; data: Task[] }>(id, "/v1/tasks"),
44
+ dataApi<{ ok: boolean; data: Mission }>(id, `/v1/missions/${missionId}`),
45
+ ]);
46
+ tasks = (tasksRes.data ?? []).filter((t) => t.missionId === missionId);
47
+ mission = missionRes.data ?? null;
48
+ } catch {}
49
+
50
+ return (
51
+ <MissionTasksPanel
52
+ projectId={id}
53
+ missionId={missionId}
54
+ initialTasks={tasks}
55
+ initialMission={mission}
56
+ />
57
+ );
58
+ }
59
+
60
+ export default async function MissionDetailPage({
61
+ params,
62
+ }: {
63
+ params: Promise<{ id: string; missionId: string }>;
64
+ }) {
65
+ const { id, missionId } = await params;
66
+
67
+ return (
68
+ <div>
69
+ {/* Back — static, renders immediately */}
70
+ <Link
71
+ href={`/projects/${id}/missions`}
72
+ className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
73
+ >
74
+ <ArrowLeft className="h-3 w-3" />
75
+ Missions
76
+ </Link>
77
+
78
+ {/* Two parallel Suspense boundaries — mission metadata and tasks stream independently */}
79
+ <Suspense fallback={<MissionHeaderSkeleton />}>
80
+ <MissionHeaderData id={id} missionId={missionId} />
81
+ </Suspense>
82
+
83
+ <Suspense fallback={<MissionGraphSkeleton />}>
84
+ <MissionTasksData id={id} missionId={missionId} />
85
+ </Suspense>
86
+ </div>
87
+ );
88
+ }