@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,45 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Check, Copy } from "lucide-react";
5
+
6
+ /**
7
+ * Icon-only copy-to-clipboard affordance. Swaps to a check for 2s after
8
+ * copying. Used for quick-copy of instructions, memory, etc.
9
+ */
10
+ export function CopyButton({
11
+ value,
12
+ label = "Copy",
13
+ className = "",
14
+ }: {
15
+ value: string;
16
+ label?: string;
17
+ className?: string;
18
+ }) {
19
+ const [copied, setCopied] = useState(false);
20
+
21
+ async function handleCopy() {
22
+ try {
23
+ await navigator.clipboard.writeText(value);
24
+ setCopied(true);
25
+ setTimeout(() => setCopied(false), 2000);
26
+ } catch {}
27
+ }
28
+
29
+ return (
30
+ <button
31
+ type="button"
32
+ onClick={handleCopy}
33
+ aria-label={label}
34
+ title={copied ? "Copied" : label}
35
+ className={`inline-flex items-center gap-1.5 border border-border bg-card px-2 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground ${className}`}
36
+ >
37
+ {copied ? (
38
+ <Check className="h-3.5 w-3.5 text-emerald-500" strokeWidth={1.5} />
39
+ ) : (
40
+ <Copy className="h-3.5 w-3.5" strokeWidth={1.5} />
41
+ )}
42
+ {copied ? "Copied" : label}
43
+ </button>
44
+ );
45
+ }
@@ -0,0 +1,48 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Check, Copy } from "lucide-react";
5
+ import { highlight } from "sugar-high";
6
+
7
+ /**
8
+ * Card with a small pill label at top-left, copy icon top-right,
9
+ * monospace value beneath. Shared across ConnectDialog, WelcomeBanner,
10
+ * and OnboardingChecklist.
11
+ */
12
+ export function CopyCard({ label, value, lang }: { label: string; value: string; lang?: "ts" | "curl" }) {
13
+ const [copied, setCopied] = useState(false);
14
+
15
+ async function handleCopy() {
16
+ try {
17
+ await navigator.clipboard.writeText(value);
18
+ setCopied(true);
19
+ setTimeout(() => setCopied(false), 2000);
20
+ } catch {}
21
+ }
22
+
23
+ return (
24
+ <div className="flex flex-col gap-2 border border-border bg-background p-3">
25
+ <div className="flex items-center justify-between">
26
+ <span className="inline-flex h-5 items-center rounded bg-secondary px-2 text-[11px] font-medium text-muted-foreground">
27
+ {label}
28
+ </span>
29
+ <button
30
+ type="button"
31
+ onClick={handleCopy}
32
+ className="text-muted-foreground hover:text-foreground transition-colors"
33
+ aria-label={`Copy ${label}`}
34
+ >
35
+ {copied ? <Check className="h-4 w-4 text-brand" /> : <Copy className="h-4 w-4" />}
36
+ </button>
37
+ </div>
38
+ {lang ? (
39
+ <pre
40
+ className="font-mono text-sm leading-6 whitespace-pre-wrap break-all overflow-x-auto sh-code"
41
+ dangerouslySetInnerHTML={{ __html: highlight(value) }}
42
+ />
43
+ ) : (
44
+ <pre className="font-mono text-sm leading-6 text-foreground whitespace-pre-wrap break-all overflow-x-auto">{value}</pre>
45
+ )}
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,161 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback } from "react";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import { usePolpoClient } from "#/lib/polpo-client";
6
+ import {
7
+ Folder, FileText, File, Image, Music, Video, Archive,
8
+ ChevronRight, ChevronLeft, Download,
9
+ } from "lucide-react";
10
+
11
+ interface FileEntry {
12
+ name: string;
13
+ type: "file" | "directory";
14
+ size?: number;
15
+ mimeType?: string;
16
+ modifiedAt?: string;
17
+ }
18
+
19
+ function formatSize(bytes?: number) {
20
+ if (!bytes) return "—";
21
+ if (bytes < 1024) return `${bytes} B`;
22
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
23
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
24
+ }
25
+
26
+ function fileIcon(entry: FileEntry) {
27
+ if (entry.type === "directory") return <Folder className="h-4 w-4 text-blue-500" />;
28
+ const m = entry.mimeType ?? "";
29
+ if (m.startsWith("image/")) return <Image className="h-4 w-4 text-amber-500" />;
30
+ if (m.startsWith("audio/")) return <Music className="h-4 w-4 text-purple-500" />;
31
+ if (m.startsWith("video/")) return <Video className="h-4 w-4 text-red-500" />;
32
+ if (m === "application/pdf") return <FileText className="h-4 w-4 text-red-500" />;
33
+ if (m.startsWith("text/") || m === "application/json") return <FileText className="h-4 w-4 text-muted-foreground" />;
34
+ if (m.includes("zip") || m.includes("tar")) return <Archive className="h-4 w-4 text-muted-foreground" />;
35
+ return <File className="h-4 w-4 text-muted-foreground" />;
36
+ }
37
+
38
+ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
39
+
40
+ /**
41
+ * Reusable file browser component.
42
+ * Shows a navigable file listing for a given root path.
43
+ */
44
+ export function FileBrowser({
45
+ projectId,
46
+ rootPath,
47
+ compact = false,
48
+ onFileSelect,
49
+ initialEntries,
50
+ }: {
51
+ projectId: string;
52
+ rootPath: string;
53
+ /** Compact mode: smaller text, no modified column */
54
+ compact?: boolean;
55
+ /** Called when a file is clicked. If provided, opens in-app instead of downloading. */
56
+ onFileSelect?: (filePath: string, fileName: string) => void;
57
+ /** Pre-fetched root-path entries (e.g. SSR'd alongside the page). Seeds
58
+ * the initial useQuery cache so the first render doesn't trigger a
59
+ * client-side refetch for content the parent already has. Navigating
60
+ * into a subdirectory refetches as usual. */
61
+ initialEntries?: FileEntry[];
62
+ }) {
63
+ const [currentPath, setCurrentPath] = useState(rootPath);
64
+ const [pathStack, setPathStack] = useState<string[]>([]);
65
+
66
+ const polpo = usePolpoClient(projectId);
67
+ const { data: listing, isLoading } = useQuery({
68
+ queryKey: ["files-list", projectId, currentPath],
69
+ queryFn: () => polpo.listFiles(currentPath) as unknown as Promise<{ path: string; entries: FileEntry[] }>,
70
+ // Only seed for the root path (where initialEntries was captured);
71
+ // subdirs fetch normally.
72
+ initialData:
73
+ initialEntries && currentPath === rootPath
74
+ ? { path: rootPath, entries: initialEntries }
75
+ : undefined,
76
+ // File listings change when install/remove/upload happens.
77
+ // 60s window keeps chatter down during navigation without
78
+ // making the browser feel stale after the user just uploaded.
79
+ staleTime: 60 * 1000,
80
+ });
81
+
82
+ const entries = listing?.entries ?? [];
83
+
84
+ const navigateTo = useCallback((dirName: string) => {
85
+ const newPath = `${currentPath}/${dirName}`;
86
+ setPathStack((prev) => [...prev, currentPath]);
87
+ setCurrentPath(newPath);
88
+ }, [currentPath]);
89
+
90
+ const navigateBack = useCallback(() => {
91
+ const prev = pathStack[pathStack.length - 1] ?? rootPath;
92
+ setPathStack((ps) => ps.slice(0, -1));
93
+ setCurrentPath(prev);
94
+ }, [pathStack, rootPath]);
95
+
96
+ const getDownloadUrl = (filePath: string) =>
97
+ `${API_URL}/v1/projects/${projectId}/data/v1/files/read?path=${encodeURIComponent(filePath)}&download=1`;
98
+
99
+ const handleClick = (entry: FileEntry) => {
100
+ if (entry.type === "directory") {
101
+ navigateTo(entry.name);
102
+ } else {
103
+ const filePath = `${currentPath}/${entry.name}`;
104
+ if (onFileSelect) {
105
+ onFileSelect(filePath, entry.name);
106
+ } else {
107
+ window.open(getDownloadUrl(filePath), "_blank");
108
+ }
109
+ }
110
+ };
111
+
112
+ const isAtRoot = pathStack.length === 0;
113
+ const displayPath = currentPath.replace(rootPath, "").replace(/^\//, "") || ".";
114
+
115
+ if (isLoading) {
116
+ return <div className="py-4 text-center text-xs text-muted-foreground">Loading...</div>;
117
+ }
118
+
119
+ if (entries.length === 0) {
120
+ return <div className="py-4 text-center text-xs text-muted-foreground">Empty directory</div>;
121
+ }
122
+
123
+ const textSize = compact ? "text-xs" : "text-sm";
124
+
125
+ return (
126
+ <div>
127
+ {/* Breadcrumb */}
128
+ {!isAtRoot && (
129
+ <button
130
+ onClick={navigateBack}
131
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground mb-2 transition-colors"
132
+ >
133
+ <ChevronLeft className="h-3 w-3" />
134
+ {displayPath || "Back"}
135
+ </button>
136
+ )}
137
+
138
+ {/* File list */}
139
+ <div className="rounded-lg border border-border overflow-hidden">
140
+ {entries.map((entry) => (
141
+ <div
142
+ key={entry.name}
143
+ onClick={() => handleClick(entry)}
144
+ className={`flex items-center gap-2.5 px-3 py-2 border-b border-border last:border-0 hover:bg-muted/30 cursor-pointer transition-colors ${textSize}`}
145
+ >
146
+ {fileIcon(entry)}
147
+ <span className="font-medium flex-1 truncate font-mono">{entry.name}</span>
148
+ {entry.type === "file" && entry.size != null && (
149
+ <span className="text-muted-foreground/50 text-[11px] shrink-0">{formatSize(entry.size)}</span>
150
+ )}
151
+ {entry.type === "directory" ? (
152
+ <ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
153
+ ) : (
154
+ <Download className="h-3.5 w-3.5 text-muted-foreground/40 shrink-0" />
155
+ )}
156
+ </div>
157
+ ))}
158
+ </div>
159
+ </div>
160
+ );
161
+ }
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from "react";
2
+ import { Info } from "lucide-react";
3
+
4
+ /**
5
+ * Info banner for tab descriptions — a bordered, padded callout with an
6
+ * info icon and a left accent rule. Replaces the faint muted one-liners
7
+ * that were easy to miss, so the context for each tab actually reads.
8
+ *
9
+ * Neo-brutalist: sharp corners, solid left accent, no gradient. An optional
10
+ * `action` (e.g. a Copy / Preview button) sits on the right, aligned to top.
11
+ */
12
+ export function Hint({ children, action }: { children: ReactNode; action?: ReactNode }) {
13
+ return (
14
+ <div className="flex items-start justify-between gap-4 border border-border border-l-2 border-l-muted-foreground/40 bg-secondary/30 px-4 py-3">
15
+ <div className="flex items-start gap-2.5">
16
+ <Info className="mt-px h-4 w-4 shrink-0 text-muted-foreground" strokeWidth={1.75} />
17
+ <p className="text-[13px] leading-relaxed text-foreground">{children}</p>
18
+ </div>
19
+ {action && <div className="shrink-0">{action}</div>}
20
+ </div>
21
+ );
22
+ }
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * Single-tenant stub. The inference-mode selector (BYOK vs managed gateway
5
+ * credits) is a cloud-billing concept with no meaning in self-host, where all
6
+ * inference is BYOK. Kept as a pass-through wrapper so the settings form
7
+ * compiles; the managed-credits UI ships only in the cloud build.
8
+ */
9
+ export type InferenceMode = string;
10
+
11
+ export function InferenceModeRadio({
12
+ children,
13
+ }: { children?: ReactNode } & Record<string, unknown>) {
14
+ return <>{children}</>;
15
+ }
@@ -0,0 +1,52 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useState, useTransition } from "react";
5
+ import { RefreshCw } from "lucide-react";
6
+
7
+ interface ManualRefreshButtonProps {
8
+ onRefresh?: () => void | Promise<unknown>;
9
+ isRefreshing?: boolean;
10
+ label?: string;
11
+ className?: string;
12
+ }
13
+
14
+ export function ManualRefreshButton({
15
+ onRefresh,
16
+ isRefreshing = false,
17
+ label = "Refresh",
18
+ className = "",
19
+ }: ManualRefreshButtonProps) {
20
+ const router = useRouter();
21
+ const [isPending, startTransition] = useTransition();
22
+ const [isManualRefreshing, setIsManualRefreshing] = useState(false);
23
+ const refreshing = isRefreshing || isPending || isManualRefreshing;
24
+
25
+ async function handleClick() {
26
+ if (onRefresh) {
27
+ setIsManualRefreshing(true);
28
+ try {
29
+ await onRefresh();
30
+ } finally {
31
+ setIsManualRefreshing(false);
32
+ }
33
+ return;
34
+ }
35
+
36
+ startTransition(() => {
37
+ router.refresh();
38
+ });
39
+ }
40
+
41
+ return (
42
+ <button
43
+ type="button"
44
+ onClick={handleClick}
45
+ disabled={refreshing}
46
+ className={`inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50 ${className}`}
47
+ >
48
+ <RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />
49
+ {label}
50
+ </button>
51
+ );
52
+ }
@@ -0,0 +1,31 @@
1
+ "use client";
2
+
3
+ import dynamic from "next/dynamic";
4
+ import { Skeleton } from "#/components/ui/skeleton";
5
+
6
+ const ReactMarkdown = dynamic(() => import("react-markdown"), {
7
+ loading: () => (
8
+ <div className="space-y-2">
9
+ <Skeleton className="h-4 w-3/4" />
10
+ <Skeleton className="h-3.5 w-full" />
11
+ <Skeleton className="h-3.5 w-5/6" />
12
+ </div>
13
+ ),
14
+ });
15
+
16
+ export function Markdown({ content }: { content: string }) {
17
+ return (
18
+ <div className="prose prose-sm prose-invert max-w-none
19
+ prose-headings:font-semibold prose-headings:tracking-tight prose-headings:text-foreground
20
+ prose-h1:text-lg prose-h2:text-sm prose-h3:text-xs
21
+ prose-p:text-muted-foreground prose-p:leading-relaxed
22
+ prose-li:text-muted-foreground prose-li:marker:text-muted-foreground/40
23
+ prose-strong:text-foreground prose-strong:font-medium
24
+ prose-code:text-xs prose-code:font-mono prose-code:bg-secondary prose-code:px-1 prose-code:py-0.5 prose-code:rounded
25
+ prose-pre:bg-card prose-pre:border prose-pre:border-border prose-pre:rounded-lg
26
+ prose-a:text-brand prose-a:no-underline hover:prose-a:underline
27
+ ">
28
+ <ReactMarkdown>{content}</ReactMarkdown>
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,196 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { ExternalLink } from "lucide-react";
5
+ import { CopyCard } from "#/components/dashboard/copy-card";
6
+ import {
7
+ ClaudeCodeIcon,
8
+ CursorIcon,
9
+ WindsurfIcon,
10
+ } from "#/components/icons/coding-agents";
11
+
12
+ /**
13
+ * MCP install panel — rendered inside the Connect dialog's "Coding Agent"
14
+ * tab when the user picks the "MCP (remote tools)" sub-option.
15
+ *
16
+ * Conceptually distinct from the skills install flow: MCP adds a single
17
+ * OAuth-authenticated endpoint that a coding client (Cursor, Claude Desktop,
18
+ * etc.) calls at runtime — no files land in the user's repo, no skills are
19
+ * scaffolded. Intended for "I want to query my Polpo state from my editor"
20
+ * use cases, not "I want to develop agents against Polpo locally".
21
+ *
22
+ * Each client has its own config surface, so we render a per-client
23
+ * snippet instead of one generic `polpo install` command. Cursor additionally
24
+ * gets a 1-click deep link (`cursor://anysphere.cursor-deeplink/mcp/install`);
25
+ * other clients require pasting JSON into a config file.
26
+ */
27
+
28
+ // The MCP server lives on the apex host — it's scoped to the user by
29
+ // Better Auth session, not by subdomain. Using `api.polpo.sh` keeps OAuth
30
+ // discovery on the same origin (avoids cross-origin .well-known quirks that
31
+ // some MCP clients are sensitive to).
32
+ const MCP_URL = "https://api.polpo.sh/v1/mcp";
33
+
34
+ type ClientId = "cursor" | "claude-desktop" | "claude-code" | "windsurf" | "generic";
35
+
36
+ interface MCPClient {
37
+ id: ClientId;
38
+ label: string;
39
+ Icon: (props: { className?: string }) => React.ReactElement;
40
+ /** Short line of context shown under the client name. */
41
+ hint: string;
42
+ /** The snippet body the user copies. */
43
+ snippet: string;
44
+ /** Label shown on the CopyCard (e.g. "mcp.json", "terminal"). */
45
+ snippetLabel: string;
46
+ /** Path-to-edit hint shown above the snippet, if any. */
47
+ pathHint?: string;
48
+ /** Optional 1-click deep link (only Cursor at the moment). */
49
+ deepLink?: { href: string; label: string };
50
+ }
51
+
52
+ function buildCursorDeepLink(): string {
53
+ // Cursor's deep-link spec: a base64-encoded JSON config blob.
54
+ // Ref: https://docs.cursor.com/features/mcp (Share an MCP server)
55
+ const payload = JSON.stringify({ url: MCP_URL });
56
+ // btoa is fine here — we're running in the browser.
57
+ const b64 = typeof window !== "undefined" ? window.btoa(payload) : "";
58
+ return `cursor://anysphere.cursor-deeplink/mcp/install?name=polpo&config=${b64}`;
59
+ }
60
+
61
+ function useMcpClients(): MCPClient[] {
62
+ return useMemo(() => {
63
+ const jsonConfig = JSON.stringify(
64
+ { mcpServers: { polpo: { url: MCP_URL } } },
65
+ null,
66
+ 2,
67
+ );
68
+ return [
69
+ {
70
+ id: "cursor",
71
+ label: "Cursor",
72
+ Icon: CursorIcon,
73
+ hint: "1-click install or paste into ~/.cursor/mcp.json",
74
+ snippet: jsonConfig,
75
+ snippetLabel: "~/.cursor/mcp.json",
76
+ pathHint: "Global: ~/.cursor/mcp.json · Project: .cursor/mcp.json",
77
+ deepLink: {
78
+ href: buildCursorDeepLink(),
79
+ label: "Install in Cursor",
80
+ },
81
+ },
82
+ {
83
+ id: "claude-desktop",
84
+ label: "Claude Desktop",
85
+ Icon: ClaudeCodeIcon,
86
+ hint: "Paste into claude_desktop_config.json, then restart.",
87
+ snippet: jsonConfig,
88
+ snippetLabel: "claude_desktop_config.json",
89
+ pathHint:
90
+ "macOS: ~/Library/Application Support/Claude · Linux: ~/.config/Claude",
91
+ },
92
+ {
93
+ id: "claude-code",
94
+ label: "Claude Code",
95
+ Icon: ClaudeCodeIcon,
96
+ hint: "Run once — registers Polpo as a remote MCP server.",
97
+ snippet: `claude mcp add --transport http polpo ${MCP_URL}`,
98
+ snippetLabel: "terminal",
99
+ },
100
+ {
101
+ id: "windsurf",
102
+ label: "Windsurf",
103
+ Icon: WindsurfIcon,
104
+ hint: "Paste into Windsurf's MCP config file.",
105
+ snippet: JSON.stringify(
106
+ { mcpServers: { polpo: { serverUrl: MCP_URL } } },
107
+ null,
108
+ 2,
109
+ ),
110
+ snippetLabel: "~/.codeium/windsurf/mcp_config.json",
111
+ },
112
+ {
113
+ id: "generic",
114
+ label: "Other",
115
+ // Icon reuse: Claude Code icon as a neutral stand-in.
116
+ Icon: ClaudeCodeIcon,
117
+ hint: "Any MCP client that supports Streamable HTTP + OAuth.",
118
+ snippet: jsonConfig,
119
+ snippetLabel: "mcp config",
120
+ },
121
+ ];
122
+ }, []);
123
+ }
124
+
125
+ const VERIFY_PROMPT =
126
+ "List the Polpo projects in my organization and summarise how many agents each one has.";
127
+
128
+ export function McpInstallPanel() {
129
+ const clients = useMcpClients();
130
+ const [selected, setSelected] = useState<ClientId>("cursor");
131
+ const client = clients.find((c) => c.id === selected)!;
132
+
133
+ return (
134
+ <div className="flex flex-col gap-5">
135
+ {/* Client picker — compact pill row. Single-select: each client has
136
+ a different config shape, so showing one at a time is clearer
137
+ than a multi-select JSON dump. */}
138
+ <div className="flex flex-wrap items-center gap-2">
139
+ {clients.map((c) => {
140
+ const active = c.id === selected;
141
+ return (
142
+ <button
143
+ key={c.id}
144
+ type="button"
145
+ onClick={() => setSelected(c.id)}
146
+ className={[
147
+ "inline-flex items-center gap-1.5 border px-2.5 py-1.5 text-xs font-medium transition-colors",
148
+ active
149
+ ? "border-foreground/40 bg-foreground text-background"
150
+ : "border-border bg-background text-muted-foreground hover:text-foreground",
151
+ ].join(" ")}
152
+ aria-pressed={active}
153
+ >
154
+ <c.Icon className="h-3.5 w-3.5" />
155
+ {c.label}
156
+ </button>
157
+ );
158
+ })}
159
+ </div>
160
+
161
+ {/* Hint + optional 1-click install */}
162
+ <div className="flex flex-col gap-2">
163
+ <p className="text-sm leading-6 text-muted-foreground">{client.hint}</p>
164
+ {client.pathHint && (
165
+ <p className="text-[11px] leading-5 text-muted-foreground font-mono">
166
+ {client.pathHint}
167
+ </p>
168
+ )}
169
+ {client.deepLink && (
170
+ <a
171
+ href={client.deepLink.href}
172
+ className="inline-flex w-fit items-center gap-1.5 rounded border border-foreground/20 bg-foreground/5 px-3 py-1.5 text-sm font-medium text-foreground hover:bg-foreground hover:text-background transition-colors"
173
+ >
174
+ <client.Icon className="h-4 w-4" />
175
+ {client.deepLink.label}
176
+ <ExternalLink className="h-3.5 w-3.5" />
177
+ </a>
178
+ )}
179
+ </div>
180
+
181
+ {/* Snippet */}
182
+ <CopyCard label={client.snippetLabel} value={client.snippet} />
183
+
184
+ {/* Verify — shared across clients */}
185
+ <div className="flex flex-col gap-2 rounded border border-border bg-background/60 p-3">
186
+ <div className="flex items-baseline justify-between">
187
+ <p className="text-xs font-medium text-foreground">Verify</p>
188
+ <p className="text-[11px] text-muted-foreground">
189
+ First call opens a browser tab for OAuth login
190
+ </p>
191
+ </div>
192
+ <CopyCard label="verify prompt" value={VERIFY_PROMPT} />
193
+ </div>
194
+ </div>
195
+ );
196
+ }