@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,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
|
+
}
|