@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.
- package/LICENSE +13 -0
- package/README.md +44 -0
- package/app/(dashboard)/error.tsx +47 -0
- package/app/(dashboard)/layout.tsx +41 -0
- package/app/(dashboard)/loading.tsx +30 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/memory/view.tsx +36 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/models-view.tsx +30 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/page.tsx +51 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/prompt/view.tsx +211 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/skills/view.tsx +84 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/tools/view.tsx +525 -0
- package/app/(dashboard)/projects/[id]/agents/[name]/vault/view.tsx +61 -0
- package/app/(dashboard)/projects/[id]/agents/page.tsx +39 -0
- package/app/(dashboard)/projects/[id]/agents/view.tsx +283 -0
- package/app/(dashboard)/projects/[id]/layout.tsx +11 -0
- package/app/(dashboard)/projects/[id]/logs/page.tsx +27 -0
- package/app/(dashboard)/projects/[id]/logs/view.tsx +184 -0
- package/app/(dashboard)/projects/[id]/memory/page.tsx +40 -0
- package/app/(dashboard)/projects/[id]/memory/view.tsx +17 -0
- package/app/(dashboard)/projects/[id]/missions/[missionId]/page.tsx +88 -0
- package/app/(dashboard)/projects/[id]/missions/[missionId]/view.tsx +569 -0
- package/app/(dashboard)/projects/[id]/missions/page.tsx +32 -0
- package/app/(dashboard)/projects/[id]/missions/view.tsx +282 -0
- package/app/(dashboard)/projects/[id]/onboarding-checklist.tsx +359 -0
- package/app/(dashboard)/projects/[id]/page.tsx +68 -0
- package/app/(dashboard)/projects/[id]/playbooks/[name]/page.tsx +5 -0
- package/app/(dashboard)/projects/[id]/playbooks/[name]/view.tsx +433 -0
- package/app/(dashboard)/projects/[id]/playbooks/page.tsx +5 -0
- package/app/(dashboard)/projects/[id]/playbooks/view.tsx +222 -0
- package/app/(dashboard)/projects/[id]/schedules/page.tsx +44 -0
- package/app/(dashboard)/projects/[id]/schedules/view.tsx +385 -0
- package/app/(dashboard)/projects/[id]/sessions/[sessionId]/page.tsx +55 -0
- package/app/(dashboard)/projects/[id]/sessions/[sessionId]/raw-view.tsx +373 -0
- package/app/(dashboard)/projects/[id]/sessions/[sessionId]/view.tsx +124 -0
- package/app/(dashboard)/projects/[id]/sessions/page.tsx +31 -0
- package/app/(dashboard)/projects/[id]/sessions/view.tsx +242 -0
- package/app/(dashboard)/projects/[id]/settings/page.tsx +52 -0
- package/app/(dashboard)/projects/[id]/skills/[name]/page.tsx +65 -0
- package/app/(dashboard)/projects/[id]/skills/[name]/view.tsx +227 -0
- package/app/(dashboard)/projects/[id]/skills/page.tsx +31 -0
- package/app/(dashboard)/projects/[id]/skills/view.tsx +243 -0
- package/app/(dashboard)/projects/[id]/storage/page.tsx +5 -0
- package/app/(dashboard)/projects/[id]/storage/view.tsx +385 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/activity/view.tsx +336 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/assessment/view.tsx +113 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/blueprint.ts +72 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/output/view.tsx +90 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/page.tsx +63 -0
- package/app/(dashboard)/projects/[id]/tasks/[taskId]/view.tsx +137 -0
- package/app/(dashboard)/projects/[id]/tasks/page.tsx +40 -0
- package/app/(dashboard)/projects/[id]/tasks/view.tsx +421 -0
- package/app/(dashboard)/projects/[id]/view.tsx +421 -0
- package/app/(dashboard)/projects/[id]/welcome-banner.tsx +156 -0
- package/app/(dashboard)/projects/projects-list.tsx +126 -0
- package/app/(playground)/error.tsx +42 -0
- package/app/(playground)/layout.tsx +27 -0
- package/app/(playground)/projects/[id]/playground/page.tsx +57 -0
- package/app/(playground)/projects/[id]/playground/view.tsx +167 -0
- package/app/(playground)/projects/[id]/playground-legacy/page.tsx +47 -0
- package/app/(playground)/projects/[id]/playground-legacy/skeleton.tsx +63 -0
- package/app/(playground)/projects/[id]/playground-legacy/view.tsx +237 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +385 -0
- package/app/icon.svg +6 -0
- package/app/layout.tsx +39 -0
- package/app/page.tsx +11 -0
- package/components/ai-elements/code-block.tsx +562 -0
- package/components/dashboard/agent-capabilities.tsx +326 -0
- package/components/dashboard/agent-identity-header.tsx +50 -0
- package/components/dashboard/agent-model-picker.tsx +195 -0
- package/components/dashboard/agent-model-selector.tsx +121 -0
- package/components/dashboard/agent-picker-card.tsx +46 -0
- package/components/dashboard/agent-studio.tsx +242 -0
- package/components/dashboard/agents-table.tsx +48 -0
- package/components/dashboard/breadcrumb.tsx +70 -0
- package/components/dashboard/builder-chat.tsx +418 -0
- package/components/dashboard/chat-shell.tsx +40 -0
- package/components/dashboard/client-picker.tsx +63 -0
- package/components/dashboard/command-snippet.tsx +77 -0
- package/components/dashboard/connect-dialog.tsx +680 -0
- package/components/dashboard/copy-button.tsx +45 -0
- package/components/dashboard/copy-card.tsx +48 -0
- package/components/dashboard/file-browser.tsx +161 -0
- package/components/dashboard/hint.tsx +22 -0
- package/components/dashboard/inference-mode.tsx +15 -0
- package/components/dashboard/manual-refresh-button.tsx +52 -0
- package/components/dashboard/markdown.tsx +31 -0
- package/components/dashboard/mcp-install-panel.tsx +196 -0
- package/components/dashboard/mission-graph.tsx +262 -0
- package/components/dashboard/nav-tabs.tsx +86 -0
- package/components/dashboard/polpo-chat.tsx +468 -0
- package/components/dashboard/project-copilot.tsx +253 -0
- package/components/dashboard/sdk-snippet-panel.tsx +194 -0
- package/components/dashboard/section-header.tsx +37 -0
- package/components/dashboard/settings-form.tsx +2477 -0
- package/components/dashboard/sidebar.tsx +385 -0
- package/components/dashboard/skeletons.tsx +1054 -0
- package/components/dashboard/skills-install-wizard.tsx +273 -0
- package/components/dashboard/swarm-runs.tsx +316 -0
- package/components/dashboard/tab-toggle.tsx +35 -0
- package/components/dashboard/task-studio.tsx +224 -0
- package/components/dashboard/top-header.tsx +203 -0
- package/components/dashboard/webhook-deliveries.tsx +234 -0
- package/components/icons/coding-agents.tsx +151 -0
- package/components/json-ld.tsx +8 -0
- package/components/providers.tsx +25 -0
- package/components/ui/badge.tsx +52 -0
- package/components/ui/button.tsx +60 -0
- package/components/ui/card.tsx +103 -0
- package/components/ui/chart.tsx +356 -0
- package/components/ui/command.tsx +196 -0
- package/components/ui/dialog.tsx +157 -0
- package/components/ui/input-group.tsx +158 -0
- package/components/ui/input.tsx +20 -0
- package/components/ui/multi-select.tsx +161 -0
- package/components/ui/popover.tsx +90 -0
- package/components/ui/select.tsx +201 -0
- package/components/ui/separator.tsx +25 -0
- package/components/ui/sheet.tsx +135 -0
- package/components/ui/skeleton.tsx +13 -0
- package/components/ui/tabs.tsx +56 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/tooltip.tsx +66 -0
- package/hooks/use-desktop-sidebar.tsx +50 -0
- package/hooks/use-mobile-sidebar.tsx +37 -0
- package/index.ts +14 -0
- package/lib/api.ts +194 -0
- package/lib/builder-context.ts +60 -0
- package/lib/data-client.ts +68 -0
- package/lib/get-query-client.ts +25 -0
- package/lib/polpo-client.tsx +49 -0
- package/lib/tool-catalog.ts +234 -0
- package/lib/use-event-catalog.ts +28 -0
- package/lib/utils.ts +6 -0
- 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
|
+
}
|