@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,418 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Builder chat — the Meta Agent surface. Implements the `@lumea-labs/chat`
5
+ * `ChatAdapter` against the control-plane `POST /v1/builder/chat` SSE
6
+ * endpoint (gateway loop + data-plane tools), and renders it with the same
7
+ * `<Chat>` UI the playground uses. When the Meta Agent mutates an agent
8
+ * (create/update/delete/assign_skill), `onMutation` fires so the right pane
9
+ * can refresh.
10
+ *
11
+ * SSE protocol (see packages/server/src/routes/builder.ts):
12
+ * event: text data: { delta }
13
+ * event: tool-call data: { toolCallId, toolName, args }
14
+ * event: tool-result data: { toolCallId, toolName, result }
15
+ * event: ask-user data: { questions } ← human-in-the-loop pause
16
+ * event: error data: { message }
17
+ * event: done data: {}
18
+ */
19
+
20
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
21
+ import {
22
+ ChatAskUser,
23
+ ChatInput,
24
+ useChatContext,
25
+ type AskUserQuestion,
26
+ type ChatMessageData,
27
+ type ChatStatus,
28
+ type ContentPart,
29
+ type PendingToolCall,
30
+ } from "@lumea-labs/chat";
31
+ import type { ToolCallEvent } from "@lumea-labs/tool-calls";
32
+ import { ChatShell } from "#/components/dashboard/chat-shell";
33
+ import type { BuilderContext, NavigateTarget } from "#/lib/builder-context";
34
+
35
+ /**
36
+ * A tool-call mutated project data → trigger a refresh. The builder's data
37
+ * tools are the MCP tools (`polpo_<resource>_<action>`); anything that isn't
38
+ * a pure read (`_list`/`_get`/`_read`) changes state. Local tools
39
+ * (search_web/http_fetch/navigate/ask_user_question) are never mutating.
40
+ */
41
+ function isMutatingTool(name: string): boolean {
42
+ return name.startsWith("polpo_") && !/_(list|get|read)$/.test(name);
43
+ }
44
+
45
+ function uid(): string {
46
+ try {
47
+ return crypto.randomUUID();
48
+ } catch {
49
+ return `id-${Math.round(performance.now() * 1000)}`;
50
+ }
51
+ }
52
+
53
+ function contentToText(content: string | ContentPart[]): string {
54
+ if (typeof content === "string") return content;
55
+ return content
56
+ .map((p) => (p.type === "text" ? p.text : ""))
57
+ .join("")
58
+ .trim();
59
+ }
60
+
61
+ function useBuilderChatAdapter({
62
+ projectId,
63
+ apiUrl,
64
+ agentName,
65
+ context,
66
+ onMutation,
67
+ onNavigate,
68
+ persistKey,
69
+ }: {
70
+ projectId: string;
71
+ apiUrl: string;
72
+ /** localStorage key — when set, the conversation persists across reloads
73
+ * (scoped by whatever the caller keys it on, e.g. project). */
74
+ persistKey?: string;
75
+ /** Agent currently open in the builder — sent so the server injects it
76
+ * into the Meta Agent's system context. */
77
+ agentName?: string;
78
+ /** Route-derived page context (global copilot) — sent so the server
79
+ * scopes the Meta Agent to what the user is looking at. */
80
+ context?: BuilderContext;
81
+ onMutation?: (tool: string) => void;
82
+ /** Client-side navigation requested by the Meta Agent (navigate tool). */
83
+ onNavigate?: (target: NavigateTarget) => void;
84
+ }) {
85
+ const [messages, setMessages] = useState<ChatMessageData[]>([]);
86
+ const [status, setStatus] = useState<ChatStatus>("idle");
87
+ const [error, setError] = useState<Error | null>(null);
88
+ const [pendingToolCall, setPendingToolCall] = useState<PendingToolCall | null>(null);
89
+ const abortRef = useRef<AbortController | null>(null);
90
+ const didPersist = useRef(false);
91
+
92
+ // Hydrate the conversation from localStorage once on mount.
93
+ useEffect(() => {
94
+ if (!persistKey || typeof window === "undefined") return;
95
+ try {
96
+ const raw = window.localStorage.getItem(persistKey);
97
+ if (raw) {
98
+ const parsed = JSON.parse(raw);
99
+ if (Array.isArray(parsed) && parsed.length) setMessages(parsed as ChatMessageData[]);
100
+ }
101
+ } catch {
102
+ /* ignore corrupt storage */
103
+ }
104
+ // eslint-disable-next-line react-hooks/exhaustive-deps
105
+ }, [persistKey]);
106
+
107
+ // Persist on change. Skip the mount commit so we don't clobber stored
108
+ // history with the empty initial state before hydration runs.
109
+ useEffect(() => {
110
+ if (!persistKey || typeof window === "undefined") return;
111
+ if (!didPersist.current) {
112
+ didPersist.current = true;
113
+ return;
114
+ }
115
+ try {
116
+ window.localStorage.setItem(persistKey, JSON.stringify(messages));
117
+ } catch {
118
+ /* quota / serialization — best effort */
119
+ }
120
+ }, [persistKey, messages]);
121
+
122
+ /** Patch the in-flight assistant message by id. */
123
+ const patch = useCallback(
124
+ (id: string, fn: (m: ChatMessageData) => ChatMessageData) => {
125
+ setMessages((prev) => prev.map((m) => (m.id === id ? fn(m) : m)));
126
+ },
127
+ [],
128
+ );
129
+
130
+ const sendMessage = useCallback(
131
+ async (content: string | ContentPart[]) => {
132
+ const text = contentToText(content);
133
+ if (!text) return;
134
+
135
+ const userMsg: ChatMessageData = {
136
+ id: uid(),
137
+ role: "user",
138
+ content: text,
139
+ ts: new Date().toISOString(),
140
+ };
141
+ const assistantId = uid();
142
+ const assistantMsg: ChatMessageData = {
143
+ id: assistantId,
144
+ role: "assistant",
145
+ content: "",
146
+ ts: new Date().toISOString(),
147
+ segments: [],
148
+ };
149
+
150
+ // History sent to the server (role + text). Stateless re-run.
151
+ // Drop empty-text messages: an assistant turn that only emitted tool
152
+ // calls (update_agent, navigate, ask_user_question, …) has no text,
153
+ // and Anthropic 400s on empty text content blocks. We replay text-only
154
+ // history anyway, so those carry nothing.
155
+ const history = [...messages, userMsg]
156
+ .map((m) => ({ role: m.role, content: contentToText(m.content) }))
157
+ .filter((m) => m.content.trim().length > 0);
158
+
159
+ setMessages((prev) => [...prev, userMsg, assistantMsg]);
160
+ setStatus("streaming");
161
+ setError(null);
162
+ setPendingToolCall(null);
163
+
164
+ const controller = new AbortController();
165
+ abortRef.current = controller;
166
+
167
+ // Stream in the background and resolve sendMessage immediately, so the
168
+ // composer clears the input on submit instead of waiting for the whole
169
+ // stream to finish (it awaits this promise before clearing).
170
+ void (async () => {
171
+ try {
172
+ const res = await fetch(`${apiUrl}/v1/builder/chat`, {
173
+ method: "POST",
174
+ credentials: "include",
175
+ headers: { "Content-Type": "application/json" },
176
+ body: JSON.stringify({ projectId, agentName, context, messages: history }),
177
+ signal: controller.signal,
178
+ });
179
+ if (!res.ok || !res.body) {
180
+ throw new Error(`Builder chat failed (${res.status})`);
181
+ }
182
+
183
+ const reader = res.body.getReader();
184
+ const decoder = new TextDecoder();
185
+ let buffer = "";
186
+
187
+ // Parse SSE frames: blocks separated by \n\n, lines `event:`/`data:`.
188
+ const handleFrame = (frame: string) => {
189
+ let event = "message";
190
+ const dataLines: string[] = [];
191
+ for (const line of frame.split("\n")) {
192
+ if (line.startsWith("event:")) event = line.slice(6).trim();
193
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
194
+ }
195
+ if (dataLines.length === 0) return;
196
+ let payload: Record<string, unknown> = {};
197
+ try {
198
+ payload = JSON.parse(dataLines.join("\n"));
199
+ } catch {
200
+ return;
201
+ }
202
+
203
+ if (event === "text") {
204
+ const delta = String(payload.delta ?? "");
205
+ if (!delta) return;
206
+ patch(assistantId, (m) => {
207
+ const segments = [...(m.segments ?? [])];
208
+ const last = segments[segments.length - 1];
209
+ if (last && last.type === "text") {
210
+ segments[segments.length - 1] = {
211
+ type: "text",
212
+ content: last.content + delta,
213
+ };
214
+ } else {
215
+ segments.push({ type: "text", content: delta });
216
+ }
217
+ return {
218
+ ...m,
219
+ content: contentToText(m.content) + delta,
220
+ segments,
221
+ };
222
+ });
223
+ } else if (event === "tool-call") {
224
+ const ev: ToolCallEvent = {
225
+ id: String(payload.toolCallId ?? uid()),
226
+ name: String(payload.toolName ?? "tool"),
227
+ arguments: (payload.args as Record<string, unknown>) ?? {},
228
+ state: "calling",
229
+ };
230
+ patch(assistantId, (m) => ({
231
+ ...m,
232
+ toolCalls: [...(m.toolCalls ?? []), ev],
233
+ segments: [...(m.segments ?? []), { type: "tool_call", toolCall: ev }],
234
+ }));
235
+ } else if (event === "tool-result") {
236
+ const tcId = String(payload.toolCallId ?? "");
237
+ const toolName = String(payload.toolName ?? "");
238
+ const result =
239
+ typeof payload.result === "string"
240
+ ? payload.result
241
+ : JSON.stringify(payload.result ?? null);
242
+ patch(assistantId, (m) => ({
243
+ ...m,
244
+ toolCalls: (m.toolCalls ?? []).map((t) =>
245
+ t.id === tcId ? { ...t, state: "completed", result } : t,
246
+ ),
247
+ segments: (m.segments ?? []).map((s) =>
248
+ s.type === "tool_call" && s.toolCall.id === tcId
249
+ ? { ...s, toolCall: { ...s.toolCall, state: "completed", result } }
250
+ : s,
251
+ ),
252
+ }));
253
+ if (isMutatingTool(toolName)) onMutation?.(toolName);
254
+ } else if (event === "navigate") {
255
+ const section = payload.section as NavigateTarget["section"];
256
+ const name = (payload.name as string | null) ?? null;
257
+ const tab = (payload.tab as string | null) ?? null;
258
+ // Render it as a completed tool-call chip so the user sees the
259
+ // agent navigated, then perform the navigation.
260
+ const where = [section, name].filter(Boolean).join("/") + (tab ? ` · ${tab}` : "");
261
+ const ev: ToolCallEvent = {
262
+ id: String(payload.toolCallId ?? uid()),
263
+ name: "navigate",
264
+ arguments: { section, name, tab },
265
+ result: `Opened ${where}`,
266
+ state: "completed",
267
+ };
268
+ patch(assistantId, (m) => ({
269
+ ...m,
270
+ toolCalls: [...(m.toolCalls ?? []), ev],
271
+ segments: [...(m.segments ?? []), { type: "tool_call", toolCall: ev }],
272
+ }));
273
+ onNavigate?.({ section, name, tab });
274
+ } else if (event === "ask-user") {
275
+ // Human-in-the-loop: the loop paused. Surface the questions via
276
+ // pendingToolCall so the composer swaps to the ask-user panel.
277
+ // Drop the in-flight assistant bubble if it never produced text.
278
+ setMessages((prev) =>
279
+ prev.filter(
280
+ (m) =>
281
+ m.id !== assistantId ||
282
+ contentToText(m.content).length > 0 ||
283
+ (m.segments?.length ?? 0) > 0,
284
+ ),
285
+ );
286
+ setPendingToolCall({
287
+ toolCallId: uid(),
288
+ toolName: "ask_user_question",
289
+ arguments: { questions: (payload.questions as unknown[]) ?? [] },
290
+ });
291
+ } else if (event === "error") {
292
+ throw new Error(String(payload.message ?? "stream error"));
293
+ }
294
+ };
295
+
296
+ // Stream loop.
297
+ for (;;) {
298
+ const { done, value } = await reader.read();
299
+ if (done) break;
300
+ buffer += decoder.decode(value, { stream: true });
301
+ let idx: number;
302
+ while ((idx = buffer.indexOf("\n\n")) !== -1) {
303
+ const frame = buffer.slice(0, idx);
304
+ buffer = buffer.slice(idx + 2);
305
+ if (frame.trim()) handleFrame(frame);
306
+ }
307
+ }
308
+ setStatus("idle");
309
+ } catch (err) {
310
+ if ((err as Error).name === "AbortError") {
311
+ setStatus("idle");
312
+ return;
313
+ }
314
+ setError(err as Error);
315
+ setStatus("error");
316
+ } finally {
317
+ abortRef.current = null;
318
+ }
319
+ })();
320
+ },
321
+ [apiUrl, projectId, agentName, context, messages, patch, onMutation, onNavigate],
322
+ );
323
+
324
+ const abort = useCallback(() => {
325
+ abortRef.current?.abort();
326
+ abortRef.current = null;
327
+ setStatus("idle");
328
+ }, []);
329
+
330
+ const newSession = useCallback(() => {
331
+ abortRef.current?.abort();
332
+ abortRef.current = null;
333
+ setMessages([]);
334
+ setStatus("idle");
335
+ setError(null);
336
+ setPendingToolCall(null);
337
+ if (persistKey && typeof window !== "undefined") {
338
+ try {
339
+ window.localStorage.removeItem(persistKey);
340
+ } catch {
341
+ /* ignore */
342
+ }
343
+ }
344
+ }, [persistKey]);
345
+
346
+ return useMemo(
347
+ () => ({
348
+ messages,
349
+ status,
350
+ isStreaming: status === "streaming",
351
+ error,
352
+ sessionId: null,
353
+ pendingToolCall,
354
+ sendMessage,
355
+ sendToolResult: async () => {},
356
+ setSessionId: async () => {},
357
+ newSession,
358
+ abort,
359
+ }),
360
+ [messages, status, error, pendingToolCall, sendMessage, newSession, abort],
361
+ );
362
+ }
363
+
364
+ /**
365
+ * Builder composer — swaps between the normal input and the ask-user
366
+ * panel when the meta-agent calls ask_user_question (same UX as the
367
+ * playground). Answers go back as a `{ answers }` message, which the
368
+ * server feeds to the next turn.
369
+ */
370
+ function BuilderComposer() {
371
+ const { pendingToolCall, sendMessage } = useChatContext();
372
+
373
+ if (pendingToolCall?.toolName === "ask_user_question") {
374
+ const questions = (pendingToolCall.arguments?.questions ?? []) as AskUserQuestion[];
375
+ return (
376
+ <ChatAskUser
377
+ variant="command"
378
+ questions={questions}
379
+ onSubmit={(answers) => void sendMessage(JSON.stringify({ answers }))}
380
+ />
381
+ );
382
+ }
383
+
384
+ return <ChatInput placeholder="Tell the builder what to change…" />;
385
+ }
386
+
387
+ export function BuilderChat({
388
+ projectId,
389
+ apiUrl,
390
+ agentName,
391
+ context,
392
+ onMutation,
393
+ onNavigate,
394
+ persistKey,
395
+ }: {
396
+ projectId: string;
397
+ apiUrl: string;
398
+ agentName?: string;
399
+ context?: BuilderContext;
400
+ onMutation?: (tool: string) => void;
401
+ onNavigate?: (target: NavigateTarget) => void;
402
+ persistKey?: string;
403
+ }) {
404
+ const adapter = useBuilderChatAdapter({
405
+ projectId,
406
+ apiUrl,
407
+ agentName,
408
+ context,
409
+ onMutation,
410
+ onNavigate,
411
+ persistKey,
412
+ });
413
+ return (
414
+ <ChatShell adapter={adapter}>
415
+ <BuilderComposer />
416
+ </ChatShell>
417
+ );
418
+ }
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Shared chat shell — the single place that wires an adapter into the
5
+ * `@lumea-labs/chat` UI with the `variant="task"` tool-call renderer.
6
+ *
7
+ * Both the playground/agent-test chat (`PolpoChat`, Polpo SDK adapter)
8
+ * and the Meta Agent builder chat (`BuilderChat`, custom SSE adapter)
9
+ * render through here, so the tool-call variant (and the provider stack)
10
+ * is defined exactly once — change it here, both surfaces follow.
11
+ *
12
+ * `children` overrides the default `<Chat>` input (e.g. the playground's
13
+ * custom InputBar with the skills menu); omit for the default composer.
14
+ */
15
+
16
+ import type { ReactNode } from "react";
17
+ import {
18
+ Chat,
19
+ ChatProvider,
20
+ ToolCallVariantProvider,
21
+ type ChatAdapter,
22
+ } from "@lumea-labs/chat";
23
+
24
+ export function ChatShell({
25
+ adapter,
26
+ children,
27
+ }: {
28
+ adapter: ChatAdapter;
29
+ children?: ReactNode;
30
+ }) {
31
+ return (
32
+ <ChatProvider adapter={adapter}>
33
+ {/* variant="task" → tool calls render as task cards (matches
34
+ lumea-agents). Defined ONCE here for every chat surface. */}
35
+ <ToolCallVariantProvider variant="task">
36
+ <Chat className="flex-1">{children}</Chat>
37
+ </ToolCallVariantProvider>
38
+ </ChatProvider>
39
+ );
40
+ }
@@ -0,0 +1,63 @@
1
+ "use client";
2
+
3
+ import { MultiSelect } from "#/components/ui/multi-select";
4
+ import {
5
+ ClaudeCodeIcon,
6
+ ClineIcon,
7
+ CodexIcon,
8
+ CopilotIcon,
9
+ CursorIcon,
10
+ OpenCodeIcon,
11
+ QoderIcon,
12
+ RooIcon,
13
+ TraeIcon,
14
+ WindsurfIcon,
15
+ } from "#/components/icons/coding-agents";
16
+
17
+ /**
18
+ * Coding agent client definitions — shared across ConnectDialog and
19
+ * WelcomeBanner. Each entry maps to a `-a <id>` flag in `polpo install`.
20
+ */
21
+ export const CODING_CLIENTS: Array<{
22
+ id: string;
23
+ label: string;
24
+ Icon: (props: { className?: string }) => React.ReactElement;
25
+ }> = [
26
+ { id: "claude-code", label: "Claude Code", Icon: ClaudeCodeIcon },
27
+ { id: "cursor", label: "Cursor", Icon: CursorIcon },
28
+ { id: "codex", label: "Codex", Icon: CodexIcon },
29
+ { id: "copilot", label: "Copilot", Icon: CopilotIcon },
30
+ { id: "windsurf", label: "Windsurf", Icon: WindsurfIcon },
31
+ { id: "cline", label: "Cline", Icon: ClineIcon },
32
+ { id: "trae", label: "Trae", Icon: TraeIcon },
33
+ { id: "roo-code", label: "Roo Code", Icon: RooIcon },
34
+ { id: "qoder", label: "Qoder", Icon: QoderIcon },
35
+ { id: "opencode", label: "OpenCode", Icon: OpenCodeIcon },
36
+ ];
37
+
38
+ /**
39
+ * MultiSelect picker for coding agents with brand icons.
40
+ * Used in ConnectDialog "Coding Agent" tab and WelcomeBanner step 2.
41
+ */
42
+ export function ClientPicker({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) {
43
+ return (
44
+ <MultiSelect
45
+ options={CODING_CLIENTS.map((c) => ({
46
+ value: c.id,
47
+ label: c.label,
48
+ icon: <c.Icon className="h-4 w-4" />,
49
+ }))}
50
+ value={value}
51
+ onChange={onChange}
52
+ placeholder="Select coding agents…"
53
+ minSelected={1}
54
+ className="w-64 rounded-md px-3 py-2 text-sm font-medium text-foreground"
55
+ />
56
+ );
57
+ }
58
+
59
+ /** Build the install command string from selected client IDs */
60
+ export function buildInstallCommand(clients: string[]): string {
61
+ const clientFlag = clients.length > 0 ? ` --client ${clients.join(",")}` : "";
62
+ return `npx @polpo-ai/cli install${clientFlag}`;
63
+ }
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Check, Copy } from "lucide-react";
5
+
6
+ /**
7
+ * A single terminal-style command line with copy-to-clipboard. Sharp,
8
+ * mono, neo-brutalist — adapted from lumea-agents' skill.sh install
9
+ * snippet. Informational: it copies the command, it does not run it.
10
+ */
11
+ export function CommandSnippet({ command }: { command: string }) {
12
+ const [copied, setCopied] = useState(false);
13
+
14
+ const onCopy = () => {
15
+ void copyToClipboard(command).then(() => {
16
+ setCopied(true);
17
+ setTimeout(() => setCopied(false), 1400);
18
+ });
19
+ };
20
+
21
+ return (
22
+ <div className="flex items-center gap-2 overflow-hidden border border-border bg-background px-3 py-2">
23
+ <span aria-hidden className="select-none font-mono text-xs text-muted-foreground/60">
24
+ $
25
+ </span>
26
+ <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-xs text-foreground scrollbar-none">
27
+ {command}
28
+ </code>
29
+ <button
30
+ type="button"
31
+ onClick={onCopy}
32
+ title="Copy"
33
+ className={`inline-flex h-6 shrink-0 cursor-pointer items-center gap-1 border px-1.5 font-mono text-[10px] font-bold uppercase tracking-[0.12em] transition-colors ${
34
+ copied
35
+ ? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
36
+ : "border-border bg-secondary text-muted-foreground hover:border-foreground/40 hover:text-foreground"
37
+ }`}
38
+ >
39
+ {copied ? (
40
+ <>
41
+ <Check className="h-2.5 w-2.5" aria-hidden />
42
+ copied
43
+ </>
44
+ ) : (
45
+ <>
46
+ <Copy className="h-2.5 w-2.5" aria-hidden />
47
+ copy
48
+ </>
49
+ )}
50
+ </button>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ async function copyToClipboard(text: string): Promise<void> {
56
+ if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
57
+ try {
58
+ await navigator.clipboard.writeText(text);
59
+ return;
60
+ } catch {
61
+ /* fall through */
62
+ }
63
+ }
64
+ if (typeof document === "undefined") return;
65
+ const ta = document.createElement("textarea");
66
+ ta.value = text;
67
+ ta.style.position = "fixed";
68
+ ta.style.opacity = "0";
69
+ document.body.appendChild(ta);
70
+ ta.select();
71
+ try {
72
+ document.execCommand("copy");
73
+ } catch {
74
+ /* swallow */
75
+ }
76
+ document.body.removeChild(ta);
77
+ }