@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,224 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Task Studio — the task detail surface as a single fast client component.
5
+ *
6
+ * Mirrors AgentStudio: a server shell fetches the initial task (and activity)
7
+ * once, hands it to this ONE client component, which renders client-switched
8
+ * tabs (Overview / Activity / Output / Assessment) via `useState` + a URL
9
+ * `?tab=` param (instant, deep-linkable, no per-tab navigation).
10
+ *
11
+ * Pure dogfood: task data is fetched through the published SDK via
12
+ * `usePolpoClient` (getTask + getTaskActivityFull), hydrated from the
13
+ * server shell's `initialData`. No raw `dataApi`/`api` in the client.
14
+ */
15
+
16
+ import { useEffect, useState } from "react";
17
+ import Link from "next/link";
18
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
19
+ import {
20
+ ListChecks,
21
+ FileText,
22
+ Activity,
23
+ Terminal,
24
+ ClipboardCheck,
25
+ type LucideIcon,
26
+ } from "lucide-react";
27
+ import { useQuery } from "@tanstack/react-query";
28
+ import type { Task } from "@polpo-ai/core";
29
+ import { usePolpoClient } from "#/lib/polpo-client";
30
+ import { Breadcrumb } from "#/components/dashboard/breadcrumb";
31
+ import type { BlueprintContext } from "#/app/(dashboard)/projects/[id]/tasks/[taskId]/blueprint";
32
+ import TaskOverviewView from "#/app/(dashboard)/projects/[id]/tasks/[taskId]/view";
33
+ import TaskOutputView from "#/app/(dashboard)/projects/[id]/tasks/[taskId]/output/view";
34
+ import TaskAssessmentView from "#/app/(dashboard)/projects/[id]/tasks/[taskId]/assessment/view";
35
+ import TaskActivityView, {
36
+ type TaskActivityPayload,
37
+ } from "#/app/(dashboard)/projects/[id]/tasks/[taskId]/activity/view";
38
+
39
+ const statusColor: Record<string, string> = {
40
+ done: "bg-brand",
41
+ in_progress: "bg-foreground animate-pulse",
42
+ pending: "bg-muted-foreground/30",
43
+ failed: "bg-destructive",
44
+ review: "bg-yellow-500",
45
+ assigned: "bg-muted-foreground/50",
46
+ draft: "bg-muted-foreground/20",
47
+ awaiting_approval: "bg-yellow-500",
48
+ };
49
+
50
+ const ACTIVE_TASK_STATUSES = new Set([
51
+ "pending",
52
+ "assigned",
53
+ "in_progress",
54
+ "review",
55
+ "awaiting_approval",
56
+ ]);
57
+
58
+ type StudioTab = "overview" | "activity" | "output" | "assessment";
59
+
60
+ const TABS: { key: StudioTab; label: string; icon: LucideIcon }[] = [
61
+ { key: "overview", label: "Overview", icon: FileText },
62
+ { key: "activity", label: "Activity", icon: Activity },
63
+ { key: "output", label: "Output", icon: Terminal },
64
+ { key: "assessment", label: "Assessment", icon: ClipboardCheck },
65
+ ];
66
+
67
+ export interface TaskStudioInitialData {
68
+ task: Task | null;
69
+ activity: TaskActivityPayload;
70
+ blueprint: BlueprintContext | null;
71
+ }
72
+
73
+ export function TaskStudio({
74
+ projectId,
75
+ taskId,
76
+ initialData,
77
+ }: {
78
+ projectId: string;
79
+ taskId: string;
80
+ initialData: TaskStudioInitialData;
81
+ }) {
82
+ const router = useRouter();
83
+ const pathname = usePathname();
84
+ const searchParams = useSearchParams();
85
+ const polpo = usePolpoClient(projectId);
86
+
87
+ // Tab is URL-driven (?tab=) so deep links work and the copilot can point
88
+ // straight at a tab. Falls back to "overview".
89
+ const urlTab = searchParams.get("tab") as StudioTab | null;
90
+ const validUrlTab = urlTab && TABS.some((t) => t.key === urlTab) ? urlTab : null;
91
+ const [tab, setTab] = useState<StudioTab>(validUrlTab ?? "overview");
92
+
93
+ // React to external URL changes (back/forward, copilot navigation).
94
+ useEffect(() => {
95
+ if (validUrlTab && validUrlTab !== tab) setTab(validUrlTab);
96
+ // eslint-disable-next-line react-hooks/exhaustive-deps
97
+ }, [validUrlTab]);
98
+
99
+ function selectTab(key: StudioTab) {
100
+ setTab(key);
101
+ const params = new URLSearchParams(searchParams.toString());
102
+ if (key === "overview") params.delete("tab");
103
+ else params.set("tab", key);
104
+ const qs = params.toString();
105
+ router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
106
+ }
107
+
108
+ // Single source of truth for the task — dogfood via SDK getTask, hydrated
109
+ // from the server shell. When the task is a blueprint (no real instance yet)
110
+ // there's nothing to fetch, so we keep the synthetic initialData.
111
+ const isBlueprint = Boolean(initialData.blueprint) && !initialData.activity.task;
112
+ const { data: task = initialData.task } = useQuery<Task | null>({
113
+ queryKey: ["task", projectId, taskId],
114
+ // SDK getTask returns its own re-exported Task shape; the views consume
115
+ // the core Task. They're structurally equivalent — cast through unknown
116
+ // to bridge the SDK/core type-export skew (see missions/view.tsx note).
117
+ queryFn: () => polpo.getTask(taskId) as unknown as Promise<Task | null>,
118
+ initialData: initialData.task,
119
+ enabled: !isBlueprint,
120
+ refetchInterval: (query) => {
121
+ const t = query.state.data;
122
+ return t && ACTIVE_TASK_STATUSES.has(t.status) ? 3000 : false;
123
+ },
124
+ });
125
+
126
+ const blueprint = initialData.blueprint;
127
+
128
+ if (!task) {
129
+ return (
130
+ <div className="flex h-[calc(100vh-7rem)] min-h-[520px] flex-col overflow-hidden">
131
+ <div className="flex shrink-0 items-center gap-2 pb-3">
132
+ <Breadcrumb
133
+ items={[
134
+ { label: "Tasks", href: `/projects/${projectId}/tasks`, icon: ListChecks },
135
+ { label: taskId },
136
+ ]}
137
+ />
138
+ </div>
139
+ <div className="border border-border p-10 text-center">
140
+ <FileText
141
+ className="mx-auto h-5 w-5 text-muted-foreground/40"
142
+ strokeWidth={1.5}
143
+ />
144
+ <p className="mt-2 text-sm text-muted-foreground">Task not found.</p>
145
+ <Link
146
+ href={`/projects/${projectId}/tasks`}
147
+ className="mt-3 inline-block text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
148
+ >
149
+ Back to tasks
150
+ </Link>
151
+ </div>
152
+ </div>
153
+ );
154
+ }
155
+
156
+ return (
157
+ // Fills the viewport (no page scroll); only the inner pane scrolls.
158
+ <div className="flex h-[calc(100vh-7rem)] min-h-[520px] flex-col overflow-hidden">
159
+ {/* Top bar — breadcrumb + status. Fixed (does not scroll). */}
160
+ <div className="flex shrink-0 items-center gap-2 pb-3">
161
+ <Breadcrumb
162
+ items={[
163
+ { label: "Tasks", href: `/projects/${projectId}/tasks`, icon: ListChecks },
164
+ { label: task.title || taskId },
165
+ ]}
166
+ />
167
+ <div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
168
+ <span
169
+ className={`h-2 w-2 rounded-full ${statusColor[task.status ?? "pending"]}`}
170
+ />
171
+ <span className="font-medium text-foreground">{task.status}</span>
172
+ </div>
173
+ </div>
174
+
175
+ {/* Sticky tab nav (does not scroll) */}
176
+ <div className="shrink-0 pr-4">
177
+ <div className="flex flex-wrap gap-1 border-b border-border">
178
+ {TABS.map((t) => {
179
+ const Icon = t.icon;
180
+ const active = t.key === tab;
181
+ return (
182
+ <button
183
+ key={t.key}
184
+ type="button"
185
+ onClick={() => selectTab(t.key)}
186
+ className={`relative flex items-center gap-1.5 px-3 py-2 text-sm transition-colors ${
187
+ active
188
+ ? "font-medium text-foreground"
189
+ : "text-muted-foreground hover:text-foreground"
190
+ }`}
191
+ >
192
+ <Icon className="h-3.5 w-3.5" strokeWidth={1.5} />
193
+ {t.label}
194
+ {active && (
195
+ <span className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground" />
196
+ )}
197
+ </button>
198
+ );
199
+ })}
200
+ </div>
201
+ </div>
202
+
203
+ {/* Only the tab content scrolls */}
204
+ <div className="min-h-0 flex-1 overflow-auto py-4 pr-4">
205
+ {tab === "overview" && (
206
+ <TaskOverviewView task={task} blueprint={blueprint ?? undefined} />
207
+ )}
208
+ {tab === "activity" && (
209
+ <TaskActivityView
210
+ projectId={projectId}
211
+ taskId={taskId}
212
+ initialActivity={initialData.activity}
213
+ />
214
+ )}
215
+ {tab === "output" && (
216
+ <TaskOutputView task={task} blueprint={blueprint} />
217
+ )}
218
+ {tab === "assessment" && (
219
+ <TaskAssessmentView task={task} blueprint={blueprint} />
220
+ )}
221
+ </div>
222
+ </div>
223
+ );
224
+ }
@@ -0,0 +1,203 @@
1
+ "use client";
2
+
3
+ import Image from "next/image";
4
+ import Link from "next/link";
5
+ import { useState } from "react";
6
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
7
+ import {
8
+ ArrowLeft,
9
+ BookOpen,
10
+ ChevronDown,
11
+ Check,
12
+ Loader2,
13
+ SquarePen,
14
+ Settings,
15
+ } from "lucide-react";
16
+ import { ConnectButton } from "#/components/dashboard/connect-dialog";
17
+ import { Popover, PopoverContent, PopoverTrigger } from "#/components/ui/popover";
18
+
19
+ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
20
+
21
+ /**
22
+ * Single-tenant top header: brandmark + Connect + Docs. No org breadcrumb,
23
+ * plan badge, credits or user menu — those are cloud-only surfaces.
24
+ */
25
+ export function TopHeader() {
26
+ return (
27
+ <header className="flex h-12 items-center justify-between border-b border-border bg-card px-4 md:px-5 shrink-0">
28
+ <div className="flex items-center gap-3 min-w-0">
29
+ <Link href="/projects/local" data-testid="header-logo" className="flex items-center shrink-0">
30
+ <Image
31
+ src="/polpo-brandmark.svg"
32
+ alt="Polpo"
33
+ width={24}
34
+ height={24}
35
+ priority
36
+ className="h-[18px] w-[18px]"
37
+ />
38
+ </Link>
39
+ </div>
40
+
41
+ <div className="flex items-center gap-2 shrink-0">
42
+ <ConnectButton />
43
+ <a
44
+ href="https://docs.polpo.sh"
45
+ target="_blank"
46
+ rel="noopener noreferrer"
47
+ data-testid="header-docs"
48
+ className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors"
49
+ >
50
+ <BookOpen className="h-3.5 w-3.5" strokeWidth={1.5} />
51
+ <span className="hidden sm:inline">Docs</span>
52
+ </a>
53
+ </div>
54
+ </header>
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Project name → lightweight rename popover. Same data-plane endpoint as
60
+ * settings-form. `orgId` is accepted (optional, unused) for call-site parity
61
+ * with the cloud sidebar.
62
+ */
63
+ type Mode = "menu" | "rename";
64
+
65
+ export function ProjectSwitcher({
66
+ projectId,
67
+ projectName,
68
+ variant = "header",
69
+ }: {
70
+ projectId: string;
71
+ projectName: string;
72
+ orgId?: string;
73
+ variant?: "header" | "sidebar";
74
+ }) {
75
+ const queryClient = useQueryClient();
76
+ const [open, setOpen] = useState(false);
77
+ const [mode, setMode] = useState<Mode>("menu");
78
+ const [name, setName] = useState(projectName);
79
+ const [saved, setSaved] = useState(false);
80
+ const [error, setError] = useState<string | null>(null);
81
+
82
+ const renameMutation = useMutation({
83
+ mutationFn: async (newName: string) => {
84
+ const res = await fetch(`${API_URL}/v1/projects/${projectId}`, {
85
+ method: "PATCH",
86
+ credentials: "include",
87
+ headers: { "Content-Type": "application/json" },
88
+ body: JSON.stringify({ name: newName }),
89
+ });
90
+ if (!res.ok) {
91
+ const d = await res.json().catch(() => ({}));
92
+ throw new Error(d?.error ?? `Failed (${res.status})`);
93
+ }
94
+ return res.json();
95
+ },
96
+ onSuccess: () => {
97
+ setError(null);
98
+ setSaved(true);
99
+ setTimeout(() => {
100
+ setSaved(false);
101
+ setOpen(false);
102
+ }, 1200);
103
+ queryClient.invalidateQueries({ queryKey: ["project", projectId] });
104
+ },
105
+ onError: (err: Error) => setError(err.message),
106
+ });
107
+
108
+ const changed = name.trim() !== projectName && name.trim().length > 0;
109
+
110
+ return (
111
+ <Popover
112
+ open={open}
113
+ onOpenChange={(o) => {
114
+ setOpen(o);
115
+ if (o) {
116
+ setMode("menu");
117
+ setName(projectName);
118
+ setError(null);
119
+ setSaved(false);
120
+ }
121
+ }}
122
+ >
123
+ <PopoverTrigger
124
+ data-testid={variant === "sidebar" ? "sidebar-project-switcher" : "header-project-switcher"}
125
+ className={
126
+ variant === "sidebar"
127
+ ? "group flex w-full items-center justify-between gap-1.5 border border-border bg-card px-2.5 py-1.5 text-sm font-medium truncate text-foreground hover:border-foreground/30 data-[popup-open]:border-foreground/40 data-[popup-open]:bg-secondary/50 transition-colors"
128
+ : "group inline-flex items-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium truncate text-foreground hover:border-border hover:bg-secondary/70 data-[popup-open]:border-border data-[popup-open]:bg-secondary transition-colors"
129
+ }
130
+ >
131
+ <span className="truncate">{projectName}</span>
132
+ <ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
133
+ </PopoverTrigger>
134
+ <PopoverContent align="start" sideOffset={6} className="w-72 p-2">
135
+ {mode === "menu" ? (
136
+ <div className="flex flex-col">
137
+ <button
138
+ type="button"
139
+ onClick={() => setMode("rename")}
140
+ className="flex items-center gap-2 rounded-md px-3 py-2 text-sm leading-5 text-muted-foreground hover:bg-secondary/50 hover:text-foreground transition-colors"
141
+ >
142
+ <SquarePen className="h-3.5 w-3.5 shrink-0" />
143
+ Rename project
144
+ </button>
145
+ <Link
146
+ href={`/projects/${projectId}/settings`}
147
+ onClick={() => setOpen(false)}
148
+ className="flex items-center gap-2 rounded-md px-3 py-2 text-sm leading-5 text-muted-foreground hover:bg-secondary/50 hover:text-foreground transition-colors"
149
+ >
150
+ <Settings className="h-3.5 w-3.5 shrink-0" />
151
+ Project settings
152
+ </Link>
153
+ </div>
154
+ ) : (
155
+ <form
156
+ onSubmit={(e) => {
157
+ e.preventDefault();
158
+ if (changed) renameMutation.mutate(name.trim());
159
+ }}
160
+ className="flex flex-col gap-2 p-1"
161
+ >
162
+ <div className="flex items-center justify-between">
163
+ <button
164
+ type="button"
165
+ onClick={() => setMode("menu")}
166
+ className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
167
+ >
168
+ <ArrowLeft className="h-3 w-3" />
169
+ Back
170
+ </button>
171
+ <span className="text-[10px] font-mono uppercase tracking-wider text-muted-foreground">
172
+ Rename
173
+ </span>
174
+ </div>
175
+ <div className="flex items-stretch gap-2">
176
+ <input
177
+ type="text"
178
+ value={name}
179
+ autoFocus
180
+ onChange={(e) => setName(e.target.value)}
181
+ className="flex-1 min-w-0 border border-border bg-transparent px-2.5 py-1.5 text-sm focus:border-foreground/30 focus:outline-none transition-colors"
182
+ />
183
+ <button
184
+ type="submit"
185
+ disabled={!changed || renameMutation.isPending}
186
+ className="rounded border border-border bg-foreground/5 px-3 py-1.5 text-xs font-medium text-foreground hover:bg-foreground hover:text-background transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
187
+ >
188
+ {renameMutation.isPending ? (
189
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
190
+ ) : saved ? (
191
+ <Check className="h-3.5 w-3.5" />
192
+ ) : (
193
+ "Save"
194
+ )}
195
+ </button>
196
+ </div>
197
+ {error && <p className="text-xs text-destructive">{error}</p>}
198
+ </form>
199
+ )}
200
+ </PopoverContent>
201
+ </Popover>
202
+ );
203
+ }
@@ -0,0 +1,234 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Webhook delivery inspector.
5
+ *
6
+ * Renders a paginated, auto-refreshing table of webhook delivery attempts
7
+ * for a single webhook. Each row can be expanded to show the request body
8
+ * and (truncated) response body, and re-fired with the "Redeliver" button.
9
+ *
10
+ * Auto-refresh is intentionally chunky (5s). Webhooks are not chat — the
11
+ * user opens this panel to debug, not to watch a live stream. SSE would be
12
+ * overkill and 5s polling is cheap on the data plane.
13
+ */
14
+
15
+ import { useState } from "react";
16
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
17
+ import { ChevronDown, ChevronRight, Loader2, RefreshCw } from "lucide-react";
18
+ import { fetchDataPlane, mutateDataPlane } from "#/lib/data-client";
19
+
20
+ interface Webhook {
21
+ id: string;
22
+ url: string;
23
+ events: string[];
24
+ }
25
+
26
+ interface Delivery {
27
+ id: string;
28
+ webhook_id: string;
29
+ event: string;
30
+ attempt: number;
31
+ status: "pending" | "success" | "failed" | "retrying";
32
+ status_code: number | null;
33
+ response_body: string | null;
34
+ error: string | null;
35
+ duration_ms: number | null;
36
+ payload: unknown;
37
+ next_retry_at: string | null;
38
+ created_at: string;
39
+ completed_at: string | null;
40
+ }
41
+
42
+ interface DeliveriesResponse {
43
+ ok: boolean;
44
+ data: Delivery[];
45
+ next_cursor: string | null;
46
+ }
47
+
48
+ const STATUS_STYLES: Record<Delivery["status"], string> = {
49
+ success: "bg-emerald-500/10 text-emerald-600 ring-1 ring-emerald-500/20",
50
+ failed: "bg-red-500/10 text-red-600 ring-1 ring-red-500/20",
51
+ retrying: "bg-amber-500/10 text-amber-600 ring-1 ring-amber-500/20",
52
+ pending: "bg-secondary text-muted-foreground ring-1 ring-border",
53
+ };
54
+
55
+ export function WebhookDeliveries({
56
+ projectId,
57
+ webhook,
58
+ }: {
59
+ projectId: string;
60
+ webhook: Webhook;
61
+ }) {
62
+ const queryClient = useQueryClient();
63
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
64
+
65
+ const { data, isLoading, isFetching, refetch } = useQuery({
66
+ queryKey: ["webhook-deliveries", projectId, webhook.id],
67
+ queryFn: () =>
68
+ fetchDataPlane<DeliveriesResponse>(
69
+ projectId,
70
+ `/v1/webhooks/${webhook.id}/deliveries?limit=50`,
71
+ ),
72
+ // 5s auto-refresh while the panel is mounted. React Query pauses
73
+ // polling when the tab loses focus, which is what we want.
74
+ refetchInterval: 5000,
75
+ });
76
+
77
+ const redeliver = useMutation({
78
+ mutationFn: (deliveryId: string) =>
79
+ mutateDataPlane(
80
+ projectId,
81
+ `/v1/webhooks/${webhook.id}/deliveries/${deliveryId}/redeliver`,
82
+ { method: "POST" },
83
+ ),
84
+ onSuccess: () => {
85
+ queryClient.invalidateQueries({
86
+ queryKey: ["webhook-deliveries", projectId, webhook.id],
87
+ });
88
+ },
89
+ });
90
+
91
+ const deliveries = data?.data ?? [];
92
+
93
+ function toggle(id: string) {
94
+ setExpanded((prev) => {
95
+ const next = new Set(prev);
96
+ if (next.has(id)) next.delete(id);
97
+ else next.add(id);
98
+ return next;
99
+ });
100
+ }
101
+
102
+ return (
103
+ <div className="space-y-3">
104
+ <header className="flex items-start justify-between gap-4">
105
+ <div className="min-w-0">
106
+ <p className="text-xs uppercase tracking-wide text-muted-foreground">
107
+ Endpoint
108
+ </p>
109
+ <p className="font-mono text-sm break-all">{webhook.url}</p>
110
+ </div>
111
+ <button
112
+ onClick={() => refetch()}
113
+ className="inline-flex items-center gap-1.5 rounded border border-border px-2.5 py-1 text-xs hover:border-foreground/30 transition-colors"
114
+ disabled={isFetching}
115
+ title="Refresh"
116
+ >
117
+ <RefreshCw
118
+ className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`}
119
+ />
120
+ Refresh
121
+ </button>
122
+ </header>
123
+
124
+ {isLoading ? (
125
+ <div className="rounded border border-border p-6 text-center text-sm text-muted-foreground">
126
+ <Loader2 className="mx-auto h-4 w-4 animate-spin" />
127
+ </div>
128
+ ) : deliveries.length === 0 ? (
129
+ <div className="rounded border border-border p-8 text-center text-sm text-muted-foreground">
130
+ No deliveries yet. Trigger an event to see attempts here.
131
+ </div>
132
+ ) : (
133
+ <div className="rounded border border-border overflow-hidden">
134
+ <div className="grid grid-cols-[1.5rem_minmax(0,1fr)_auto_auto_auto_auto] items-center gap-3 border-b border-border bg-secondary/40 px-3 py-2 text-[11px] uppercase tracking-wide text-muted-foreground">
135
+ <span />
136
+ <span>Event</span>
137
+ <span>Status</span>
138
+ <span>Code</span>
139
+ <span>Latency</span>
140
+ <span />
141
+ </div>
142
+ {deliveries.map((d) => {
143
+ const isOpen = expanded.has(d.id);
144
+ return (
145
+ <div
146
+ key={d.id}
147
+ className="border-b border-border last:border-0"
148
+ >
149
+ <button
150
+ onClick={() => toggle(d.id)}
151
+ className="grid w-full grid-cols-[1.5rem_minmax(0,1fr)_auto_auto_auto_auto] items-center gap-3 px-3 py-2 text-left text-sm hover:bg-secondary/30 transition-colors"
152
+ >
153
+ {isOpen ? (
154
+ <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
155
+ ) : (
156
+ <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
157
+ )}
158
+ <div className="min-w-0">
159
+ <p className="font-mono text-xs truncate">{d.event}</p>
160
+ <p className="text-[11px] text-muted-foreground">
161
+ {new Date(d.created_at).toLocaleString()} · attempt{" "}
162
+ {d.attempt}
163
+ </p>
164
+ </div>
165
+ <span
166
+ className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${STATUS_STYLES[d.status]}`}
167
+ >
168
+ {d.status}
169
+ </span>
170
+ <span className="font-mono text-xs text-muted-foreground tabular-nums">
171
+ {d.status_code ?? "—"}
172
+ </span>
173
+ <span className="font-mono text-xs text-muted-foreground tabular-nums">
174
+ {d.duration_ms != null ? `${d.duration_ms}ms` : "—"}
175
+ </span>
176
+ <span
177
+ role="button"
178
+ tabIndex={0}
179
+ onClick={(e) => {
180
+ e.stopPropagation();
181
+ redeliver.mutate(d.id);
182
+ }}
183
+ onKeyDown={(e) => {
184
+ if (e.key === "Enter" || e.key === " ") {
185
+ e.preventDefault();
186
+ e.stopPropagation();
187
+ redeliver.mutate(d.id);
188
+ }
189
+ }}
190
+ aria-disabled={redeliver.isPending}
191
+ className={`inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] cursor-pointer hover:border-foreground/30 transition-colors ${
192
+ redeliver.isPending ? "opacity-50 pointer-events-none" : ""
193
+ }`}
194
+ >
195
+ Redeliver
196
+ </span>
197
+ </button>
198
+
199
+ {isOpen && (
200
+ <div className="grid gap-3 border-t border-border bg-secondary/20 px-6 py-3 md:grid-cols-2">
201
+ <div>
202
+ <p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
203
+ Request payload
204
+ </p>
205
+ <pre className="max-h-72 overflow-auto rounded border border-border bg-background p-2 font-mono text-[11px]">
206
+ {JSON.stringify(d.payload, null, 2)}
207
+ </pre>
208
+ </div>
209
+ <div>
210
+ <p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
211
+ Response
212
+ </p>
213
+ <pre className="max-h-72 overflow-auto rounded border border-border bg-background p-2 font-mono text-[11px] whitespace-pre-wrap break-all">
214
+ {d.response_body ??
215
+ d.error ??
216
+ (d.status === "pending" ? "pending…" : "(empty)")}
217
+ </pre>
218
+ {d.next_retry_at && (
219
+ <p className="mt-1 text-[11px] text-muted-foreground">
220
+ Next retry:{" "}
221
+ {new Date(d.next_retry_at).toLocaleString()}
222
+ </p>
223
+ )}
224
+ </div>
225
+ </div>
226
+ )}
227
+ </div>
228
+ );
229
+ })}
230
+ </div>
231
+ )}
232
+ </div>
233
+ );
234
+ }