@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,242 @@
1
+ "use client";
2
+
3
+ import { useState, useMemo } from "react";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import { useParams } from "next/navigation";
6
+ import Link from "next/link";
7
+ import { ChevronRight, RefreshCw, Search, ListFilter, X } from "lucide-react";
8
+ import { usePolpoClient } from "#/lib/polpo-client";
9
+ import { Button } from "#/components/ui/button";
10
+ import { Badge } from "#/components/ui/badge";
11
+ import { Popover, PopoverContent, PopoverTrigger } from "#/components/ui/popover";
12
+ import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "#/components/ui/command";
13
+
14
+ export interface Session {
15
+ id: string;
16
+ title?: string;
17
+ agent?: string;
18
+ messageCount: number;
19
+ createdAt: string;
20
+ updatedAt: string;
21
+ }
22
+
23
+ function timeAgo(date: string) {
24
+ const diff = Date.now() - new Date(date).getTime();
25
+ const min = Math.floor(diff / 60000);
26
+ if (min < 60) return `${min}m ago`;
27
+ const hours = Math.floor(min / 60);
28
+ if (hours < 24) return `${hours}h ago`;
29
+ return `${Math.floor(hours / 24)}d ago`;
30
+ }
31
+
32
+ const PER_PAGE = 15;
33
+
34
+ export default function SessionsView({
35
+ initialSessions,
36
+ }: {
37
+ initialSessions: Session[];
38
+ }) {
39
+ const { id } = useParams<{ id: string }>();
40
+ const [page, setPage] = useState(0);
41
+ const [search, setSearch] = useState("");
42
+ const [filterAgent, setFilterAgent] = useState<string | null>(null);
43
+ const [agentOpen, setAgentOpen] = useState(false);
44
+
45
+ const polpo = usePolpoClient(id);
46
+ const { data: sessions = [], isFetching, refetch } = useQuery({
47
+ queryKey: ["all-sessions", id],
48
+ queryFn: async () => {
49
+ const r = await polpo.getSessions();
50
+ return (r.sessions ?? []) as unknown as Session[];
51
+ },
52
+ initialData: initialSessions,
53
+ });
54
+
55
+ const activeAgents = useMemo(
56
+ () => [...new Set(sessions.map((s) => s.agent).filter(Boolean))].sort() as string[],
57
+ [sessions]
58
+ );
59
+
60
+ const filtered = useMemo(() => {
61
+ let result = sessions;
62
+ if (filterAgent) result = result.filter((s) => s.agent === filterAgent);
63
+ if (search) {
64
+ const q = search.toLowerCase();
65
+ result = result.filter((s) =>
66
+ (s.title ?? "").toLowerCase().includes(q) ||
67
+ (s.agent ?? "").toLowerCase().includes(q) ||
68
+ s.id.toLowerCase().includes(q)
69
+ );
70
+ }
71
+ return result;
72
+ }, [sessions, filterAgent, search]);
73
+
74
+ const hasFilters = filterAgent !== null;
75
+ const paged = filtered.slice(page * PER_PAGE, (page + 1) * PER_PAGE);
76
+ const totalPages = Math.ceil(filtered.length / PER_PAGE);
77
+
78
+ return (
79
+ <div>
80
+ {/* Header */}
81
+ <div>
82
+ <h2 className="text-lg font-semibold tracking-tight">Sessions</h2>
83
+ <p className="mt-1 text-xs text-muted-foreground">
84
+ {sessions.length} chat sessions across all agents.
85
+ </p>
86
+ </div>
87
+
88
+ {/* Filters + Search + Refresh */}
89
+ {sessions.length > 0 && (
90
+ <div className="mt-3 flex items-center gap-2 flex-wrap">
91
+ {/* Search */}
92
+ <div className="relative">
93
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
94
+ <input
95
+ type="text"
96
+ value={search}
97
+ onChange={(e) => { setSearch(e.target.value); setPage(0); }}
98
+ placeholder="Search..."
99
+ 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"
100
+ />
101
+ </div>
102
+
103
+ {/* Agent filter */}
104
+ {activeAgents.length > 1 && (
105
+ <Popover open={agentOpen} onOpenChange={setAgentOpen}>
106
+ <PopoverTrigger
107
+ render={
108
+ <Button variant="outline" size="sm" data-testid="filter-agent">
109
+ <ListFilter className="h-3 w-3" />
110
+ {filterAgent ? (
111
+ <>
112
+ Agent: <Badge variant="secondary" className="ml-1 font-normal">{filterAgent}</Badge>
113
+ </>
114
+ ) : (
115
+ "Agent"
116
+ )}
117
+ </Button>
118
+ }
119
+ />
120
+ <PopoverContent className="w-52 p-0" align="start">
121
+ <Command>
122
+ <CommandInput placeholder="Search agent..." />
123
+ <CommandList>
124
+ <CommandEmpty>No agent found.</CommandEmpty>
125
+ <CommandGroup>
126
+ {activeAgents.map((a) => (
127
+ <CommandItem
128
+ key={a}
129
+ value={a}
130
+ data-checked={filterAgent === a || undefined}
131
+ onSelect={() => {
132
+ setFilterAgent(filterAgent === a ? null : a);
133
+ setAgentOpen(false);
134
+ setPage(0);
135
+ }}
136
+ >
137
+ {a}
138
+ </CommandItem>
139
+ ))}
140
+ </CommandGroup>
141
+ </CommandList>
142
+ </Command>
143
+ </PopoverContent>
144
+ </Popover>
145
+ )}
146
+
147
+ {/* Active filter + clear */}
148
+ {hasFilters && (
149
+ <>
150
+ <span className="text-xs text-muted-foreground">
151
+ {filtered.length} of {sessions.length}
152
+ </span>
153
+ <Button
154
+ variant="ghost"
155
+ size="xs"
156
+ onClick={() => { setFilterAgent(null); setSearch(""); setPage(0); }}
157
+ data-testid="clear-filters"
158
+ >
159
+ <X className="h-3 w-3" />
160
+ Clear
161
+ </Button>
162
+ </>
163
+ )}
164
+
165
+ {/* Refresh */}
166
+ <button
167
+ onClick={() => refetch()}
168
+ disabled={isFetching}
169
+ data-testid="refresh-btn"
170
+ className="ml-auto inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
171
+ >
172
+ <RefreshCw className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`} />
173
+ Refresh
174
+ </button>
175
+ </div>
176
+ )}
177
+
178
+ {/* List */}
179
+ {filtered.length > 0 ? (
180
+ <div data-testid="sessions-list" className="mt-4 border border-border overflow-hidden">
181
+ {paged.map((session) => (
182
+ <Link
183
+ key={session.id}
184
+ href={`/projects/${id}/sessions/${session.id}`}
185
+ className="flex items-center border-b border-border last:border-0 hover:bg-secondary/30 transition-colors group"
186
+ >
187
+ <div className="flex-1 px-4 py-3 min-w-0">
188
+ <p className="text-sm font-medium truncate">
189
+ {session.title || "Untitled session"}
190
+ </p>
191
+ <div className="mt-0.5 flex items-center gap-2">
192
+ {session.agent && (
193
+ <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
194
+ {session.agent}
195
+ </span>
196
+ )}
197
+ <span className="font-mono text-[10px] text-muted-foreground/50">
198
+ {session.id}
199
+ </span>
200
+ </div>
201
+ </div>
202
+ <div className="flex items-center gap-4 px-4 py-3 text-xs text-muted-foreground font-mono shrink-0">
203
+ <span>{session.messageCount} msgs</span>
204
+ <span className="text-muted-foreground/40">{timeAgo(session.updatedAt)}</span>
205
+ </div>
206
+ <div className="px-3">
207
+ <ChevronRight className="h-3.5 w-3.5 text-muted-foreground/30 group-hover:text-muted-foreground transition-colors" />
208
+ </div>
209
+ </Link>
210
+ ))}
211
+ {totalPages > 1 && (
212
+ <div className="flex items-center justify-between px-4 py-2.5 border-t border-border">
213
+ <span className="text-xs text-muted-foreground">
214
+ {page * PER_PAGE + 1}–{Math.min((page + 1) * PER_PAGE, filtered.length)} of {filtered.length}
215
+ </span>
216
+ <div className="flex items-center gap-1">
217
+ <button
218
+ onClick={() => setPage((p) => Math.max(0, p - 1))}
219
+ disabled={page === 0}
220
+ className="px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground border border-border transition-colors disabled:opacity-30 disabled:pointer-events-none"
221
+ >
222
+ Prev
223
+ </button>
224
+ <button
225
+ onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
226
+ disabled={page >= totalPages - 1}
227
+ className="px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground border border-border transition-colors disabled:opacity-30 disabled:pointer-events-none"
228
+ >
229
+ Next
230
+ </button>
231
+ </div>
232
+ </div>
233
+ )}
234
+ </div>
235
+ ) : (
236
+ <div data-testid="sessions-empty" className="mt-4 border border-border p-8 text-center text-sm text-muted-foreground">
237
+ {search || hasFilters ? "No sessions matching your filters." : "No sessions yet. Start a conversation via the completions API."}
238
+ </div>
239
+ )}
240
+ </div>
241
+ );
242
+ }
@@ -0,0 +1,52 @@
1
+ import { api } from "#/lib/api";
2
+ import type { Project } from "#/lib/api";
3
+ import { SettingsForm } from "#/components/dashboard/settings-form";
4
+ import type { AutumnStatusResponse } from "#/components/dashboard/settings-form";
5
+
6
+ /**
7
+ * Tenant data-plane URL. Mirrors the helper in connect-dialog.tsx so what
8
+ * the user sees on the Settings tab matches what they wire into their CLI
9
+ * / SDK / `curl` calls. Local dev gets the apex localhost so the dashboard
10
+ * stays usable without DNS.
11
+ */
12
+ function tenantApiUrl(slug?: string): string {
13
+ if (!slug) return "—";
14
+ if (process.env.NEXT_PUBLIC_API_URL?.includes("localhost")) {
15
+ return `http://${slug}.polpo.localhost`;
16
+ }
17
+ return `https://${slug}.polpo.cloud`;
18
+ }
19
+
20
+ export default async function ProjectSettingsPage({ params }: { params: Promise<{ id: string }> }) {
21
+ const { id } = await params;
22
+
23
+ // Fetch project + Autumn integration status in parallel server-side. The
24
+ // integration status is hydrated into the dashboard's TanStack Query cache
25
+ // so the Integrations tab renders the connection state on first paint
26
+ // without flashing a "Loading…" spinner.
27
+ let project: Project | null = null;
28
+ let autumnStatus: AutumnStatusResponse | null = null;
29
+ try {
30
+ const [projectRes, autumnRes] = await Promise.all([
31
+ api<Project>(`/v1/projects/${id}`).catch(() => null),
32
+ api<{ ok: boolean; data: AutumnStatusResponse | null }>(
33
+ `/v1/integrations/${id}/autumn`,
34
+ ).catch(() => null),
35
+ ]);
36
+ project = projectRes;
37
+ autumnStatus = autumnRes?.data ?? null;
38
+ } catch {}
39
+
40
+ const apiEndpoint = tenantApiUrl(project?.slug);
41
+
42
+ return (
43
+ <SettingsForm
44
+ projectId={id}
45
+ projectName={project?.name ?? ""}
46
+ projectSlug={project?.slug ?? ""}
47
+ apiEndpoint={apiEndpoint}
48
+ initialSettings={project?.settings}
49
+ initialAutumnStatus={autumnStatus}
50
+ />
51
+ );
52
+ }
@@ -0,0 +1,65 @@
1
+ import { Suspense } from "react";
2
+ import { dataApi } from "#/lib/api";
3
+ import { SkillDetailSkeleton } from "#/components/dashboard/skeletons";
4
+ import SkillDetailView, { type LoadedSkill, type FileEntry } from "./view";
5
+
6
+ /** Slim shape for the list-entry merge. Keep in sync with `SkillsListEntry`
7
+ * in view.tsx; only the fields the detail view needs. */
8
+ type SkillsListEntry = {
9
+ name: string;
10
+ assignedTo?: string[];
11
+ tags?: string[];
12
+ category?: string;
13
+ };
14
+
15
+ async function SkillData({ id, name }: { id: string; name: string }) {
16
+ let skill: LoadedSkill | null = null;
17
+ let files: FileEntry[] = [];
18
+ let skillsList: SkillsListEntry[] = [];
19
+
20
+ try {
21
+ // Three fetches in parallel — all three land in one round trip to
22
+ // the data plane. The skills list carries `assignedTo` / `tags` /
23
+ // `category` (via the SkillStore fast-path) that the /:name/content
24
+ // endpoint currently omits; we merge client-side.
25
+ const [skillRes, filesRes, skillsListRes] = await Promise.all([
26
+ dataApi<{ ok: boolean; data: LoadedSkill }>(
27
+ id,
28
+ `/v1/skills/${encodeURIComponent(name)}/content`,
29
+ ).catch(() => ({ ok: false, data: null as LoadedSkill | null })),
30
+ dataApi<{ ok: boolean; data: { entries: FileEntry[] } }>(
31
+ id,
32
+ `/v1/files/list?path=${encodeURIComponent(`.polpo/skills/${name}`)}`,
33
+ ).catch(() => ({ ok: false, data: { entries: [] as FileEntry[] } })),
34
+ dataApi<{ ok: boolean; data: SkillsListEntry[] }>(
35
+ id,
36
+ `/v1/skills`,
37
+ ).catch(() => ({ ok: false, data: [] as SkillsListEntry[] })),
38
+ ]);
39
+ skill = skillRes.data ?? null;
40
+ files = filesRes.data?.entries ?? [];
41
+ skillsList = skillsListRes.data ?? [];
42
+ } catch {}
43
+
44
+ return (
45
+ <SkillDetailView
46
+ initialSkill={skill}
47
+ initialFiles={files}
48
+ initialSkillsList={skillsList}
49
+ />
50
+ );
51
+ }
52
+
53
+ export default async function SkillDetailPage({
54
+ params,
55
+ }: {
56
+ params: Promise<{ id: string; name: string }>;
57
+ }) {
58
+ const { id, name } = await params;
59
+
60
+ return (
61
+ <Suspense fallback={<SkillDetailSkeleton />}>
62
+ <SkillData id={id} name={name} />
63
+ </Suspense>
64
+ );
65
+ }
@@ -0,0 +1,227 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import { useParams } from "next/navigation";
6
+ import Link from "next/link";
7
+ import { ArrowLeft, Users, Tag } from "lucide-react";
8
+ import { usePolpoClient } from "#/lib/polpo-client";
9
+ import { Markdown } from "#/components/dashboard/markdown";
10
+ import { ManualRefreshButton } from "#/components/dashboard/manual-refresh-button";
11
+ import { FileBrowser } from "#/components/dashboard/file-browser";
12
+
13
+ export interface LoadedSkill {
14
+ name: string;
15
+ description: string;
16
+ content: string;
17
+ source: string;
18
+ path: string;
19
+ tags?: string[];
20
+ category?: string;
21
+ assignedTo?: string[];
22
+ allowedTools?: string[];
23
+ }
24
+
25
+ export interface FileEntry {
26
+ name: string;
27
+ type: "file" | "directory";
28
+ size?: number;
29
+ mimeType?: string;
30
+ modifiedAt?: string;
31
+ }
32
+
33
+ export interface SkillsListEntry {
34
+ name: string;
35
+ assignedTo?: string[];
36
+ tags?: string[];
37
+ category?: string;
38
+ }
39
+
40
+ export default function SkillDetailView({
41
+ initialSkill,
42
+ initialFiles,
43
+ initialSkillsList,
44
+ }: {
45
+ initialSkill: LoadedSkill | null;
46
+ initialFiles: FileEntry[];
47
+ initialSkillsList: SkillsListEntry[];
48
+ }) {
49
+ const { id, name } = useParams<{ id: string; name: string }>();
50
+
51
+ const [viewingFile, setViewingFile] = useState<string | null>(null);
52
+ const [viewingFileName, setViewingFileName] = useState<string | null>(null);
53
+
54
+ const polpo = usePolpoClient(id);
55
+ const { data: baseSkill, isFetching: skillFetching, refetch: refetchSkill } = useQuery({
56
+ queryKey: ["skill-detail", id, name],
57
+ queryFn: () => polpo.getSkillContent(name) as unknown as Promise<LoadedSkill>,
58
+ initialData: initialSkill ?? undefined,
59
+ // SKILL.md content changes rarely (manual edit of the file in
60
+ // the project). 5-minute stale window cuts refetch chatter
61
+ // during a debugging/reading session; tab focus still triggers
62
+ // a background revalidate once data is stale.
63
+ staleTime: 5 * 60 * 1000,
64
+ });
65
+
66
+ // The /content endpoint doesn't join agent assignments — pull them
67
+ // from the list endpoint (which does) and merge. Remove when
68
+ // @polpo-ai/server includes assignments in GET /:name/content.
69
+ const { data: skillsList, isFetching: skillsListFetching, refetch: refetchSkillsList } = useQuery({
70
+ queryKey: ["skills-with-assignments", id],
71
+ queryFn: () => polpo.getSkills() as unknown as Promise<SkillsListEntry[]>,
72
+ initialData: initialSkillsList,
73
+ // Assignments change when the user assigns/unassigns skills to
74
+ // agents — a more frequent operation than editing SKILL.md, so
75
+ // a tighter window (30s) keeps the pills reasonably fresh.
76
+ staleTime: 30 * 1000,
77
+ });
78
+ const listEntry = skillsList?.find((s) => s.name === name);
79
+ const skill: LoadedSkill | undefined = baseSkill
80
+ ? {
81
+ ...baseSkill,
82
+ assignedTo: listEntry?.assignedTo ?? baseSkill.assignedTo,
83
+ tags: listEntry?.tags ?? baseSkill.tags,
84
+ category: listEntry?.category ?? baseSkill.category,
85
+ }
86
+ : baseSkill;
87
+
88
+ // Load file content when a file is selected from the browser
89
+ const { data: filePreview, isFetching: filePreviewFetching, refetch: refetchFilePreview } = useQuery({
90
+ queryKey: ["file-preview", id, viewingFile],
91
+ queryFn: () => polpo.previewFile(viewingFile!),
92
+ enabled: !!viewingFile,
93
+ });
94
+
95
+ if (!skill) {
96
+ return (
97
+ <div className="mt-4 border border-border p-8 text-center text-sm text-muted-foreground">
98
+ Skill not found.
99
+ </div>
100
+ );
101
+ }
102
+
103
+ return (
104
+ <div>
105
+ {/* Back link */}
106
+ <Link
107
+ href={`/projects/${id}/skills`}
108
+ className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
109
+ >
110
+ <ArrowLeft className="h-3 w-3" />
111
+ Skills
112
+ </Link>
113
+
114
+ {/* Header */}
115
+ <div className="mt-4 flex items-start justify-between gap-4">
116
+ <div>
117
+ <h2 className="text-lg font-bold">{skill.name}</h2>
118
+ <p className="mt-1 text-sm text-muted-foreground">{skill.description}</p>
119
+ </div>
120
+ <ManualRefreshButton
121
+ onRefresh={() => Promise.all([
122
+ refetchSkill(),
123
+ refetchSkillsList(),
124
+ viewingFile ? refetchFilePreview() : Promise.resolve(),
125
+ ])}
126
+ isRefreshing={skillFetching || skillsListFetching || filePreviewFetching}
127
+ className="mt-1 shrink-0"
128
+ />
129
+ </div>
130
+
131
+ {/* Metadata pills — user-meaningful info only (tags, assignments,
132
+ category, allowed tools). `source = "project"` is always true
133
+ on cloud so we drop it. */}
134
+ <div className="mt-4 flex flex-wrap gap-2">
135
+ {skill.assignedTo && skill.assignedTo.length > 0 && (
136
+ <span className="inline-flex items-center gap-1.5 rounded-md bg-secondary px-2.5 py-1 text-xs text-muted-foreground">
137
+ <Users className="h-3 w-3" />
138
+ {skill.assignedTo.join(", ")}
139
+ </span>
140
+ )}
141
+ {skill.category && (
142
+ <span className="inline-flex items-center gap-1.5 rounded-md bg-blue-500/10 text-blue-500 px-2.5 py-1 text-xs font-medium">
143
+ <Tag className="h-3 w-3" />
144
+ {skill.category}
145
+ </span>
146
+ )}
147
+ {skill.allowedTools && skill.allowedTools.length > 0 && (
148
+ <span className="rounded-md bg-secondary px-2.5 py-1 text-xs text-muted-foreground font-mono">
149
+ {skill.allowedTools.join(", ")}
150
+ </span>
151
+ )}
152
+ </div>
153
+
154
+ {/* Tags */}
155
+ {skill.tags && skill.tags.length > 0 && (
156
+ <div className="mt-3 flex flex-wrap gap-1.5">
157
+ {skill.tags.map((tag) => (
158
+ <span key={tag} className="rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
159
+ {tag}
160
+ </span>
161
+ ))}
162
+ </div>
163
+ )}
164
+
165
+ {/* Two-column layout: Content (left) + Files (right) */}
166
+ <div className="mt-6 grid gap-6 lg:grid-cols-[1fr_380px]">
167
+ {/* Left: Content viewer */}
168
+ <div>
169
+ <div className="flex items-center justify-between mb-3">
170
+ <h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
171
+ {viewingFileName ?? "SKILL.md"}
172
+ </h3>
173
+ {viewingFile && (
174
+ <button
175
+ onClick={() => { setViewingFile(null); setViewingFileName(null); }}
176
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors"
177
+ >
178
+ Back to SKILL.md
179
+ </button>
180
+ )}
181
+ </div>
182
+ <div className="border border-border bg-card p-6 max-h-[calc(100vh-16rem)] overflow-auto scrollbar-thin">
183
+ {viewingFile && filePreview ? (
184
+ filePreview.type === "text" && filePreview.content ? (
185
+ filePreview.name.endsWith(".md") ? (
186
+ <div className="prose prose-sm dark:prose-invert max-w-none">
187
+ <Markdown content={filePreview.content} />
188
+ </div>
189
+ ) : (
190
+ <pre className="text-xs font-mono whitespace-pre-wrap break-words">{filePreview.content}</pre>
191
+ )
192
+ ) : (
193
+ <p className="text-sm text-muted-foreground">Preview not available for this file type.</p>
194
+ )
195
+ ) : (
196
+ <div className="prose prose-sm dark:prose-invert max-w-none">
197
+ <Markdown content={skill.content} />
198
+ </div>
199
+ )}
200
+ </div>
201
+ </div>
202
+
203
+ {/* Right: File browser (sticky) */}
204
+ <div className="lg:sticky lg:top-8 lg:self-start">
205
+ <h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-3">Files</h3>
206
+ <FileBrowser
207
+ projectId={id}
208
+ rootPath={`.polpo/skills/${name}`}
209
+ compact
210
+ initialEntries={initialFiles}
211
+ onFileSelect={(filePath, fileName) => {
212
+ // SKILL.md is the default view — clicking it in the browser
213
+ // just resets to the base content, no separate preview.
214
+ if (fileName === "SKILL.md") {
215
+ setViewingFile(null);
216
+ setViewingFileName(null);
217
+ return;
218
+ }
219
+ setViewingFile(filePath);
220
+ setViewingFileName(fileName);
221
+ }}
222
+ />
223
+ </div>
224
+ </div>
225
+ </div>
226
+ );
227
+ }
@@ -0,0 +1,31 @@
1
+ import { Suspense } from "react";
2
+ import { dataApi } from "#/lib/api";
3
+ import { SkillsListSkeleton } from "#/components/dashboard/skeletons";
4
+ import SkillsView, { type SkillInfo } from "./view";
5
+
6
+ async function SkillsData({ id }: { id: string }) {
7
+ let skills: SkillInfo[] = [];
8
+ try {
9
+ const res = await dataApi<{ ok: boolean; data: SkillInfo[] }>(
10
+ id,
11
+ "/v1/skills",
12
+ );
13
+ skills = res.data ?? [];
14
+ } catch {}
15
+
16
+ return <SkillsView initialSkills={skills} />;
17
+ }
18
+
19
+ export default async function SkillsPage({
20
+ params,
21
+ }: {
22
+ params: Promise<{ id: string }>;
23
+ }) {
24
+ const { id } = await params;
25
+
26
+ return (
27
+ <Suspense fallback={<SkillsListSkeleton />}>
28
+ <SkillsData id={id} />
29
+ </Suspense>
30
+ );
31
+ }