@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,525 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import {
6
+ Lock,
7
+ Loader2,
8
+ Terminal,
9
+ Globe,
10
+ Image as ImageIcon,
11
+ Video,
12
+ Volume2,
13
+ Search,
14
+ Mail,
15
+ Sheet,
16
+ FileText,
17
+ FileType,
18
+ Brain,
19
+ // per-tool icons
20
+ FilePlus,
21
+ FilePen,
22
+ SquareTerminal,
23
+ FileSearch,
24
+ TextSearch,
25
+ FolderTree,
26
+ Download,
27
+ Flag,
28
+ KeyRound,
29
+ Compass,
30
+ ScanText,
31
+ MousePointerClick,
32
+ Keyboard,
33
+ Camera,
34
+ Eye,
35
+ ListChecks,
36
+ MousePointer2,
37
+ MousePointer,
38
+ Clock,
39
+ Code,
40
+ X,
41
+ ArrowLeft,
42
+ ArrowRight,
43
+ RotateCw,
44
+ Columns3,
45
+ AudioLines,
46
+ Send,
47
+ Inbox,
48
+ MailOpen,
49
+ Paperclip,
50
+ ShieldCheck,
51
+ Hash,
52
+ Table,
53
+ Info,
54
+ Merge,
55
+ Save,
56
+ Plus,
57
+ Pencil,
58
+ type LucideIcon,
59
+ } from "lucide-react";
60
+ import type { AgentConfig } from "@polpo-ai/core";
61
+ import { SectionHeader } from "#/components/dashboard/section-header";
62
+ import {
63
+ TOOL_CATALOG,
64
+ CATALOG_TOOL_NAMES,
65
+ isToolEnabled,
66
+ hasGroupWildcard,
67
+ } from "#/lib/tool-catalog";
68
+
69
+ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
70
+
71
+ const ICONS: Record<string, LucideIcon> = {
72
+ Terminal,
73
+ Globe,
74
+ Image: ImageIcon,
75
+ Video,
76
+ Volume2,
77
+ Search,
78
+ Mail,
79
+ Sheet,
80
+ FileText,
81
+ FileType,
82
+ Brain,
83
+ ShieldCheck,
84
+ };
85
+
86
+ /**
87
+ * Tools the runtime keeps ON only while `allowedTools` is unset — the moment
88
+ * an agent sets any `allowedTools`, the core filters these out unless they're
89
+ * explicitly listed. So any edit must first "materialise" this baseline (pin
90
+ * the names) to avoid silently stripping the agent's file/shell/HTTP tools.
91
+ */
92
+ const BASELINE_TOOLS = [
93
+ "read", "write", "edit", "bash", "glob", "grep", "ls", "http_fetch", "http_download",
94
+ ];
95
+
96
+ /** Empty allowedTools ⇒ runtime gives all baseline tools. Pin them before
97
+ * any edit so they survive once the list becomes non-empty. */
98
+ const materialize = (allowed: string[]): string[] =>
99
+ allowed.length === 0 ? [...BASELINE_TOOLS] : allowed;
100
+
101
+ /** Reverse tidy: if the list ends up as exactly the full baseline (nothing
102
+ * extra, nothing missing), collapse back to [] — the clean "defaults" state. */
103
+ function tidyBaseline(allowed: string[]): string[] {
104
+ const onlyBaseline = allowed.length > 0 && allowed.every((t) => BASELINE_TOOLS.includes(t));
105
+ const allOn = BASELINE_TOOLS.every((t) => allowed.includes(t));
106
+ return onlyBaseline && allOn ? [] : allowed;
107
+ }
108
+
109
+ /** Explicit (no-wildcard) single-tool toggle for baseline groups. */
110
+ function toggleToolExplicit(base: string[], name: string, enable: boolean): string[] {
111
+ if (enable) return base.includes(name) ? base : [...base, name];
112
+ return base.filter((t) => t !== name);
113
+ }
114
+
115
+ /** Explicit (no-wildcard) whole-group toggle for baseline groups. */
116
+ function toggleCategoryExplicit(base: string[], names: string[], enable: boolean): string[] {
117
+ const without = base.filter((t) => !names.includes(t));
118
+ return enable ? [...without, ...names] : without;
119
+ }
120
+
121
+ /** An appropriate icon per individual tool. Falls back to the category
122
+ * icon when a tool isn't mapped. */
123
+ const TOOL_ICONS: Record<string, LucideIcon> = {
124
+ // core
125
+ read: FileText, write: FilePlus, edit: FilePen, bash: SquareTerminal,
126
+ glob: FileSearch, grep: TextSearch, ls: FolderTree, http_fetch: Globe,
127
+ http_download: Download, register_outcome: Flag, vault_get: KeyRound, vault_list: KeyRound,
128
+ // browser
129
+ browser_navigate: Compass, browser_snapshot: ScanText, browser_click: MousePointerClick,
130
+ browser_fill: FilePen, browser_type: Keyboard, browser_press: Keyboard,
131
+ browser_screenshot: Camera, browser_get: Eye, browser_select: ListChecks,
132
+ browser_hover: MousePointer2, browser_scroll: MousePointer, browser_wait: Clock,
133
+ browser_eval: Code, browser_close: X, browser_back: ArrowLeft, browser_forward: ArrowRight,
134
+ browser_reload: RotateCw, browser_tabs: Columns3,
135
+ // image / video
136
+ image_generate: ImageIcon, image_analyze: Eye, video_generate: Video,
137
+ // audio
138
+ audio_transcribe: AudioLines, audio_speak: Volume2,
139
+ // search
140
+ search_web: Search, search_find_similar: Search,
141
+ // email
142
+ email_send: Send, email_draft: FilePen, email_list: Inbox, email_read: MailOpen,
143
+ email_search: Search, email_download_attachment: Paperclip, email_verify: ShieldCheck,
144
+ email_count: Hash,
145
+ // excel
146
+ excel_read: Table, excel_write: Sheet, excel_query: Search, excel_info: Info,
147
+ // pdf / docx
148
+ pdf_read: FileText, pdf_create: FilePlus, pdf_merge: Merge, pdf_info: Info,
149
+ docx_read: FileText, docx_create: FilePlus,
150
+ // memory
151
+ memory_get: Brain, memory_save: Save, memory_append: Plus, memory_update: Pencil,
152
+ };
153
+
154
+ const iconForTool = (name: string, fallback: LucideIcon): LucideIcon =>
155
+ TOOL_ICONS[name] ?? fallback;
156
+
157
+ /* ── allowedTools edit helpers ──────────────────────────────────── */
158
+
159
+ const wildcardOf = (groupKey: string) => `${groupKey}*`;
160
+
161
+ /** Expand a group wildcard into its explicit tool names (so a single
162
+ * tool can then be toggled off without disabling the whole group). */
163
+ function expandWildcard(allowed: string[], groupKey: string, groupTools: string[]) {
164
+ const wc = wildcardOf(groupKey);
165
+ if (!allowed.includes(wc)) return allowed;
166
+ const next = allowed.filter((t) => t !== wc);
167
+ for (const n of groupTools) if (!next.includes(n)) next.push(n);
168
+ return next;
169
+ }
170
+
171
+ function toggleTool(
172
+ allowed: string[],
173
+ groupKey: string,
174
+ groupTools: string[],
175
+ name: string,
176
+ enable: boolean,
177
+ ): string[] {
178
+ let next = expandWildcard(allowed, groupKey, groupTools);
179
+ if (enable) {
180
+ if (!next.includes(name)) next = [...next, name];
181
+ } else {
182
+ next = next.filter((t) => t !== name);
183
+ }
184
+ // Tidy: all group tools on → collapse back to the wildcard.
185
+ if (groupTools.length > 1 && groupTools.every((n) => next.includes(n))) {
186
+ next = next.filter((t) => !groupTools.includes(t));
187
+ next.push(wildcardOf(groupKey));
188
+ }
189
+ return next;
190
+ }
191
+
192
+ function toggleCategory(
193
+ allowed: string[],
194
+ groupKey: string,
195
+ groupTools: string[],
196
+ enable: boolean,
197
+ ): string[] {
198
+ const next = allowed.filter(
199
+ (t) => t !== wildcardOf(groupKey) && !groupTools.includes(t),
200
+ );
201
+ if (enable) next.push(wildcardOf(groupKey));
202
+ return next;
203
+ }
204
+
205
+ /* ── Sharp toggle switch ────────────────────────────────────────── */
206
+
207
+ function Switch({
208
+ checked,
209
+ onChange,
210
+ disabled,
211
+ }: {
212
+ checked: boolean;
213
+ onChange: (v: boolean) => void;
214
+ disabled?: boolean;
215
+ }) {
216
+ return (
217
+ <button
218
+ type="button"
219
+ role="switch"
220
+ aria-checked={checked}
221
+ disabled={disabled}
222
+ onClick={() => onChange(!checked)}
223
+ className={`relative inline-flex h-4 w-7 shrink-0 items-center border transition-colors ${
224
+ checked ? "border-emerald-500 bg-emerald-500/20" : "border-border bg-secondary"
225
+ } ${disabled ? "cursor-not-allowed opacity-40" : "cursor-pointer"}`}
226
+ >
227
+ <span
228
+ aria-hidden
229
+ className={`block h-3 w-3 transition-transform ${
230
+ checked ? "translate-x-[13px] bg-emerald-500" : "translate-x-[1px] bg-muted-foreground"
231
+ }`}
232
+ />
233
+ </button>
234
+ );
235
+ }
236
+
237
+ interface CategoryTab {
238
+ key: string;
239
+ label: string;
240
+ icon: LucideIcon;
241
+ core?: boolean;
242
+ baseline?: boolean;
243
+ requiresKey?: boolean;
244
+ note?: string;
245
+ /** Catalog tool names of this group (for toggle math). */
246
+ groupTools: string[];
247
+ wildcard: boolean;
248
+ enabled: number;
249
+ total: number;
250
+ tools: { name: string; description: string; enabled: boolean }[];
251
+ }
252
+
253
+ export default function AgentToolsView({
254
+ agent,
255
+ projectId,
256
+ }: {
257
+ agent: AgentConfig | null;
258
+ projectId: string;
259
+ }) {
260
+ const router = useRouter();
261
+ const [allowed, setAllowed] = useState<string[]>(agent?.allowedTools ?? []);
262
+ const [saving, setSaving] = useState(false);
263
+
264
+ const customTools = allowed.filter(
265
+ (t) => !t.endsWith("*") && !CATALOG_TOOL_NAMES.has(t),
266
+ );
267
+
268
+ // Baseline groups are all-on while allowedTools is unset (mirrors runtime).
269
+ const baselineDefault = allowed.length === 0;
270
+
271
+ const tabs: CategoryTab[] = TOOL_CATALOG.map((g) => {
272
+ const groupTools = g.tools.map((t) => t.name);
273
+ const tools = g.tools.map((t) => ({
274
+ name: t.name,
275
+ description: t.description,
276
+ enabled: g.core || (g.baseline && baselineDefault) || isToolEnabled(t.name, allowed),
277
+ }));
278
+ return {
279
+ key: g.key,
280
+ label: g.label,
281
+ icon: ICONS[g.icon] ?? Terminal,
282
+ core: g.core,
283
+ baseline: g.baseline,
284
+ requiresKey: g.requiresKey,
285
+ note: g.note,
286
+ groupTools,
287
+ // Baseline groups never collapse to a wildcard (their names have no
288
+ // shared prefix the runtime understands) — always explicit.
289
+ wildcard: !g.core && !g.baseline && hasGroupWildcard(g.key, allowed),
290
+ enabled: tools.filter((t) => t.enabled).length,
291
+ total: tools.length,
292
+ tools,
293
+ };
294
+ });
295
+
296
+ if (customTools.length > 0) {
297
+ tabs.push({
298
+ key: "__custom",
299
+ label: "Custom",
300
+ icon: Terminal,
301
+ groupTools: customTools,
302
+ wildcard: false,
303
+ enabled: customTools.length,
304
+ total: customTools.length,
305
+ note: "Tools enabled for this agent that aren't in the standard catalog.",
306
+ tools: customTools.map((name) => ({
307
+ name,
308
+ description: "Custom tool enabled for this agent.",
309
+ enabled: true,
310
+ })),
311
+ });
312
+ }
313
+
314
+ const firstActive =
315
+ tabs.find((t) => !t.core && t.enabled > 0)?.key ?? tabs[0]?.key ?? "core";
316
+ const [active, setActive] = useState(firstActive);
317
+
318
+ async function persist(next: string[]) {
319
+ if (!agent) return;
320
+ const prev = allowed;
321
+ setAllowed(next); // optimistic
322
+ setSaving(true);
323
+ try {
324
+ const res = await fetch(
325
+ `${API_URL}/v1/projects/${projectId}/data/v1/agents/${encodeURIComponent(agent.name)}`,
326
+ {
327
+ method: "PATCH",
328
+ credentials: "include",
329
+ headers: { "Content-Type": "application/json" },
330
+ body: JSON.stringify({ allowedTools: next }),
331
+ },
332
+ );
333
+ if (!res.ok) {
334
+ setAllowed(prev); // rollback
335
+ } else {
336
+ router.refresh();
337
+ }
338
+ } catch {
339
+ setAllowed(prev);
340
+ }
341
+ setSaving(false);
342
+ }
343
+
344
+ /** Run a toggle against the materialised baseline, then tidy + persist.
345
+ * Every edit goes through here so enabling an extended tool never silently
346
+ * strips the file/shell/HTTP baseline. */
347
+ const applyEdit = (fn: (base: string[]) => string[]) =>
348
+ persist(tidyBaseline(fn(materialize(allowed))));
349
+
350
+ if (!agent) {
351
+ return (
352
+ <div
353
+ data-testid="agent-not-found"
354
+ className="border border-border p-8 text-center text-sm text-muted-foreground"
355
+ >
356
+ Agent not found.
357
+ </div>
358
+ );
359
+ }
360
+
361
+ const activeTab = tabs.find((t) => t.key === active) ?? tabs[0];
362
+
363
+ return (
364
+ <div>
365
+ <SectionHeader
366
+ description={
367
+ <span className="inline-flex items-center gap-1.5">
368
+ Toggle the tools this agent can call. Vault access is always on; file,
369
+ shell and HTTP tools are on until you customise the selection.
370
+ {saving && <Loader2 className="h-3 w-3 animate-spin" />}
371
+ </span>
372
+ }
373
+ />
374
+
375
+ <div className="mt-4 flex flex-col gap-6 md:flex-row md:gap-8">
376
+ {/* Vertical category nav — sticky; only the tool list (right) scrolls. */}
377
+ <nav className="flex shrink-0 flex-col gap-0.5 md:w-48 md:self-start md:sticky md:top-0">
378
+ {tabs.map((t) => {
379
+ const Icon = t.icon;
380
+ const isActive = t.key === active;
381
+ const hot = t.enabled > 0;
382
+ return (
383
+ <button
384
+ key={t.key}
385
+ type="button"
386
+ onClick={() => setActive(t.key)}
387
+ className={`flex items-center gap-2 border-l-2 px-3 py-1.5 text-sm transition-colors ${
388
+ isActive
389
+ ? "border-foreground bg-foreground/5 font-medium text-foreground"
390
+ : "border-transparent text-muted-foreground hover:border-border hover:text-foreground"
391
+ }`}
392
+ >
393
+ <Icon
394
+ className={`h-3.5 w-3.5 shrink-0 ${isActive || hot ? "" : "text-muted-foreground/50"}`}
395
+ strokeWidth={1.5}
396
+ />
397
+ <span className="flex-1 text-left">{t.label}</span>
398
+ <span
399
+ className={`font-mono text-[10px] font-bold tabular-nums ${
400
+ hot ? "text-emerald-500" : "text-muted-foreground/40"
401
+ }`}
402
+ >
403
+ {t.core ? t.total : `${t.enabled}/${t.total}`}
404
+ </span>
405
+ </button>
406
+ );
407
+ })}
408
+ </nav>
409
+
410
+ {/* Active category — flat (no card background) */}
411
+ <div className="min-w-0 flex-1">
412
+ {activeTab && (
413
+ <div>
414
+ {/* Category status + master toggle */}
415
+ <div className="flex flex-wrap items-center gap-2 border-b border-border/60 pb-2.5">
416
+ {activeTab.core ? (
417
+ <span className="font-mono text-[9px] font-bold uppercase tracking-[0.16em] text-muted-foreground">
418
+ always on
419
+ </span>
420
+ ) : (
421
+ <>
422
+ <Switch
423
+ checked={activeTab.enabled > 0}
424
+ disabled={activeTab.key === "__custom"}
425
+ onChange={(v) =>
426
+ applyEdit((base) =>
427
+ activeTab.baseline
428
+ ? toggleCategoryExplicit(base, activeTab.groupTools, v)
429
+ : toggleCategory(base, activeTab.key, activeTab.groupTools, v),
430
+ )
431
+ }
432
+ />
433
+ <span
434
+ className={`font-mono text-[10px] font-bold uppercase tracking-[0.12em] ${
435
+ activeTab.enabled > 0 ? "text-emerald-500" : "text-muted-foreground/40"
436
+ }`}
437
+ >
438
+ {activeTab.enabled === 0
439
+ ? "All off"
440
+ : activeTab.enabled === activeTab.total
441
+ ? "All enabled"
442
+ : `${activeTab.enabled} of ${activeTab.total} on`}
443
+ </span>
444
+ </>
445
+ )}
446
+ {activeTab.requiresKey && (
447
+ <span
448
+ className="ml-auto inline-flex items-center gap-1 font-mono text-[9px] font-medium uppercase tracking-[0.12em] text-muted-foreground/60"
449
+ title="Needs an external API key / credential"
450
+ >
451
+ <Lock className="h-3 w-3" strokeWidth={2} />
452
+ key
453
+ </span>
454
+ )}
455
+ </div>
456
+
457
+ {activeTab.note && (
458
+ <p className="border-b border-border/60 py-2 text-[11px] leading-relaxed text-muted-foreground/70">
459
+ {activeTab.note}
460
+ </p>
461
+ )}
462
+
463
+ <div>
464
+ {activeTab.tools.map((t) => {
465
+ const ToolIcon = iconForTool(t.name, activeTab.icon);
466
+ const isCore = !!activeTab.core;
467
+ const on = isCore || t.enabled;
468
+ return (
469
+ <div
470
+ key={t.name}
471
+ className="flex items-center gap-3 border-b border-border/60 py-2.5 last:border-0"
472
+ >
473
+ <ToolIcon
474
+ className={`h-4 w-4 shrink-0 ${on ? "text-foreground" : "text-muted-foreground/40"}`}
475
+ strokeWidth={1.5}
476
+ />
477
+ <div className="min-w-0 flex-1">
478
+ <span
479
+ className={`font-mono text-xs ${on ? "text-foreground" : "text-muted-foreground/60"}`}
480
+ >
481
+ {t.name}
482
+ </span>
483
+ <p
484
+ className="truncate text-[11px] text-muted-foreground/70"
485
+ title={t.description}
486
+ >
487
+ {t.description}
488
+ </p>
489
+ </div>
490
+ {/* Switch always on the right. Core tools show it
491
+ checked + disabled (can't be turned off). */}
492
+ <Switch
493
+ checked={on}
494
+ disabled={isCore || activeTab.key === "__custom"}
495
+ onChange={(v) =>
496
+ applyEdit((base) =>
497
+ activeTab.baseline
498
+ ? toggleToolExplicit(base, t.name, v)
499
+ : toggleTool(base, activeTab.key, activeTab.groupTools, t.name, v),
500
+ )
501
+ }
502
+ />
503
+ </div>
504
+ );
505
+ })}
506
+ </div>
507
+ </div>
508
+ )}
509
+ </div>
510
+ </div>
511
+
512
+ <p className="mt-4 text-[11px] leading-relaxed text-muted-foreground/60">
513
+ Some categories need an external key (vault).{" "}
514
+ <a
515
+ href="https://docs.polpo.sh/docs/agents/tools"
516
+ target="_blank"
517
+ rel="noopener noreferrer"
518
+ className="underline decoration-dotted underline-offset-2 transition-colors hover:text-foreground"
519
+ >
520
+ Tool docs →
521
+ </a>
522
+ </p>
523
+ </div>
524
+ );
525
+ }
@@ -0,0 +1,61 @@
1
+ import { Hint } from "#/components/dashboard/hint";
2
+
3
+ interface VaultEntry {
4
+ service: string;
5
+ type: string;
6
+ label: string | null;
7
+ }
8
+
9
+ const typeLabel: Record<string, string> = {
10
+ smtp: "SMTP",
11
+ imap: "IMAP",
12
+ oauth: "OAuth",
13
+ api_key: "API Key",
14
+ login: "Login",
15
+ custom: "Custom",
16
+ };
17
+
18
+ export default function AgentVaultView({ entries }: { entries: VaultEntry[] }) {
19
+ return (
20
+ <div>
21
+ <div className="mb-4">
22
+ <Hint>
23
+ Credentials this agent can access at runtime via{" "}
24
+ <span className="font-mono">vault_get</span>. AES-256-GCM encrypted at rest —
25
+ never exposed in the dashboard.
26
+ </Hint>
27
+ </div>
28
+
29
+ {entries.length > 0 ? (
30
+ <div data-testid="vault-table" className="border border-border overflow-hidden overflow-x-auto">
31
+ <table className="w-full text-sm min-w-[400px]">
32
+ <thead>
33
+ <tr className="border-b border-border bg-secondary/50">
34
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground">Service</th>
35
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground">Type</th>
36
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-muted-foreground">Label</th>
37
+ </tr>
38
+ </thead>
39
+ <tbody>
40
+ {entries.map((entry) => (
41
+ <tr key={entry.service} className="border-b border-border last:border-0">
42
+ <td className="px-4 py-3 font-mono text-xs font-medium">{entry.service}</td>
43
+ <td className="px-4 py-3">
44
+ <span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
45
+ {typeLabel[entry.type] ?? entry.type}
46
+ </span>
47
+ </td>
48
+ <td className="px-4 py-3 text-muted-foreground">{entry.label}</td>
49
+ </tr>
50
+ ))}
51
+ </tbody>
52
+ </table>
53
+ </div>
54
+ ) : (
55
+ <div data-testid="vault-empty" className="border border-border p-8 text-center text-sm text-muted-foreground">
56
+ No credentials stored for this agent.
57
+ </div>
58
+ )}
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,39 @@
1
+ import { Suspense } from "react";
2
+ import { dataApi } from "#/lib/api";
3
+ import type { AgentConfig } from "@polpo-ai/core";
4
+ import { AgentsListSkeleton } from "#/components/dashboard/skeletons";
5
+ import AgentsView, { type Team } from "./view";
6
+
7
+ async function AgentsData({ id }: { id: string }) {
8
+ let agents: AgentConfig[] = [];
9
+ let teams: Team[] = [];
10
+
11
+ try {
12
+ const [agentsRes, teamsRes] = await Promise.all([
13
+ dataApi<{ ok: boolean; data: AgentConfig[] }>(id, "/v1/agents").catch(
14
+ () => ({ ok: false, data: [] as AgentConfig[] }),
15
+ ),
16
+ dataApi<{ ok: boolean; data: Team[] }>(id, "/v1/agents/teams").catch(
17
+ () => ({ ok: false, data: [] as Team[] }),
18
+ ),
19
+ ]);
20
+ agents = agentsRes.data ?? [];
21
+ teams = teamsRes.data ?? [];
22
+ } catch {}
23
+
24
+ return <AgentsView initialAgents={agents} initialTeams={teams} />;
25
+ }
26
+
27
+ export default async function AgentsPage({
28
+ params,
29
+ }: {
30
+ params: Promise<{ id: string }>;
31
+ }) {
32
+ const { id } = await params;
33
+
34
+ return (
35
+ <Suspense fallback={<AgentsListSkeleton />}>
36
+ <AgentsData id={id} />
37
+ </Suspense>
38
+ );
39
+ }