@poncho-ai/cli 0.14.1 → 0.16.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.
@@ -0,0 +1,2605 @@
1
+ export const getWebUiClientScript = (markedSource: string): string => `
2
+ // Marked library (inlined)
3
+ ${markedSource}
4
+
5
+ // Configure marked for GitHub Flavored Markdown (tables, etc.)
6
+ marked.setOptions({
7
+ gfm: true,
8
+ breaks: true
9
+ });
10
+
11
+ const state = {
12
+ csrfToken: "",
13
+ conversations: [],
14
+ activeConversationId: null,
15
+ activeMessages: [],
16
+ isStreaming: false,
17
+ activeStreamAbortController: null,
18
+ activeStreamConversationId: null,
19
+ activeStreamRunId: null,
20
+ isMessagesPinnedToBottom: true,
21
+ confirmDeleteId: null,
22
+ approvalRequestsInFlight: {},
23
+ pendingFiles: [],
24
+ contextTokens: 0,
25
+ contextWindow: 0,
26
+ };
27
+
28
+ const agentInitial = document.body.dataset.agentInitial || "A";
29
+ const $ = (id) => document.getElementById(id);
30
+ const elements = {
31
+ auth: $("auth"),
32
+ app: $("app"),
33
+ loginForm: $("login-form"),
34
+ passphrase: $("passphrase"),
35
+ loginError: $("login-error"),
36
+ list: $("conversation-list"),
37
+ newChat: $("new-chat"),
38
+ topbarNewChat: $("topbar-new-chat"),
39
+ messages: $("messages"),
40
+ chatTitle: $("chat-title"),
41
+ logout: $("logout"),
42
+ composer: $("composer"),
43
+ prompt: $("prompt"),
44
+ send: $("send"),
45
+ shell: $("app"),
46
+ sidebarToggle: $("sidebar-toggle"),
47
+ sidebarBackdrop: $("sidebar-backdrop"),
48
+ attachBtn: $("attach-btn"),
49
+ fileInput: $("file-input"),
50
+ attachmentPreview: $("attachment-preview"),
51
+ dragOverlay: $("drag-overlay"),
52
+ lightbox: $("lightbox"),
53
+ contextRingFill: $("context-ring-fill"),
54
+ contextTooltip: $("context-tooltip"),
55
+ sendBtnWrapper: $("send-btn-wrapper"),
56
+ browserPanel: $("browser-panel"),
57
+ browserPanelResize: $("browser-panel-resize"),
58
+ browserPanelFrame: $("browser-panel-frame"),
59
+ browserPanelUrl: $("browser-panel-url"),
60
+ browserPanelPlaceholder: $("browser-panel-placeholder"),
61
+ browserPanelClose: $("browser-panel-close"),
62
+ browserNavBack: $("browser-nav-back"),
63
+ browserNavForward: $("browser-nav-forward"),
64
+ };
65
+ const sendIconMarkup =
66
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 12V4M4 7l4-4 4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
67
+ const stopIconMarkup =
68
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="4" y="4" width="8" height="8" rx="2" fill="currentColor"/></svg>';
69
+
70
+ const CONTEXT_RING_CIRCUMFERENCE = 2 * Math.PI * 14.5;
71
+ const formatTokenCount = (n) => {
72
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\\.0$/, "") + "M";
73
+ if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\\.0$/, "") + "k";
74
+ return String(n);
75
+ };
76
+ const updateContextRing = () => {
77
+ const ring = elements.contextRingFill;
78
+ const tooltip = elements.contextTooltip;
79
+ if (!ring || !tooltip) return;
80
+ if (state.contextWindow <= 0) {
81
+ ring.style.strokeDasharray = String(CONTEXT_RING_CIRCUMFERENCE);
82
+ ring.style.strokeDashoffset = String(CONTEXT_RING_CIRCUMFERENCE);
83
+ tooltip.textContent = "";
84
+ return;
85
+ }
86
+ const ratio = Math.min(state.contextTokens / state.contextWindow, 1);
87
+ const offset = CONTEXT_RING_CIRCUMFERENCE * (1 - ratio);
88
+ ring.style.strokeDasharray = String(CONTEXT_RING_CIRCUMFERENCE);
89
+ ring.style.strokeDashoffset = String(offset);
90
+ ring.classList.toggle("warning", ratio >= 0.7 && ratio < 0.9);
91
+ ring.classList.toggle("critical", ratio >= 0.9);
92
+ const pct = (ratio * 100).toFixed(1).replace(/\\.0$/, "");
93
+ tooltip.textContent = formatTokenCount(state.contextTokens) + " / " + formatTokenCount(state.contextWindow) + " tokens (" + pct + "%)";
94
+ };
95
+
96
+ const pushConversationUrl = (conversationId) => {
97
+ const target = conversationId ? "/c/" + encodeURIComponent(conversationId) : "/";
98
+ if (window.location.pathname !== target) {
99
+ history.pushState({ conversationId: conversationId || null }, "", target);
100
+ }
101
+ };
102
+
103
+ const replaceConversationUrl = (conversationId) => {
104
+ const target = conversationId ? "/c/" + encodeURIComponent(conversationId) : "/";
105
+ if (window.location.pathname !== target) {
106
+ history.replaceState({ conversationId: conversationId || null }, "", target);
107
+ }
108
+ };
109
+
110
+ const getConversationIdFromUrl = () => {
111
+ const match = window.location.pathname.match(/^\\/c\\/([^\\/]+)/);
112
+ return match ? decodeURIComponent(match[1]) : null;
113
+ };
114
+
115
+ const mutatingMethods = new Set(["POST", "PATCH", "PUT", "DELETE"]);
116
+
117
+ const api = async (path, options = {}) => {
118
+ const method = (options.method || "GET").toUpperCase();
119
+ const headers = { ...(options.headers || {}) };
120
+ if (mutatingMethods.has(method) && state.csrfToken) {
121
+ headers["x-csrf-token"] = state.csrfToken;
122
+ }
123
+ if (options.body && !headers["Content-Type"]) {
124
+ headers["Content-Type"] = "application/json";
125
+ }
126
+ const response = await fetch(path, { credentials: "include", ...options, method, headers });
127
+ if (!response.ok) {
128
+ let payload = {};
129
+ try { payload = await response.json(); } catch {}
130
+ const error = new Error(payload.message || ("Request failed: " + response.status));
131
+ error.status = response.status;
132
+ error.payload = payload;
133
+ throw error;
134
+ }
135
+ const contentType = response.headers.get("content-type") || "";
136
+ if (contentType.includes("application/json")) {
137
+ return await response.json();
138
+ }
139
+ return await response.text();
140
+ };
141
+
142
+ const escapeHtml = (value) =>
143
+ String(value || "")
144
+ .replace(/&/g, "&amp;")
145
+ .replace(/</g, "&lt;")
146
+ .replace(/>/g, "&gt;")
147
+ .replace(/"/g, "&quot;")
148
+ .replace(/'/g, "&#39;");
149
+
150
+ const renderAssistantMarkdown = (value) => {
151
+ const source = String(value || "").trim();
152
+ if (!source) return "<p></p>";
153
+
154
+ try {
155
+ return marked.parse(source);
156
+ } catch (error) {
157
+ console.error("Markdown parsing error:", error);
158
+ // Fallback to escaped text
159
+ return "<p>" + escapeHtml(source) + "</p>";
160
+ }
161
+ };
162
+
163
+ const extractToolActivity = (value) => {
164
+ const source = String(value || "");
165
+ let markerIndex = source.lastIndexOf("\\n### Tool activity\\n");
166
+ if (markerIndex < 0 && source.startsWith("### Tool activity\\n")) {
167
+ markerIndex = 0;
168
+ }
169
+ if (markerIndex < 0) {
170
+ return { content: source, activities: [] };
171
+ }
172
+ const content = markerIndex === 0 ? "" : source.slice(0, markerIndex).trimEnd();
173
+ const rawSection = markerIndex === 0 ? source : source.slice(markerIndex + 1);
174
+ const afterHeading = rawSection.replace(/^### Tool activity\\s*\\n?/, "");
175
+ const activities = afterHeading
176
+ .split("\\n")
177
+ .map((line) => line.trim())
178
+ .filter((line) => line.startsWith("- "))
179
+ .map((line) => line.slice(2).trim())
180
+ .filter(Boolean);
181
+ return { content, activities };
182
+ };
183
+
184
+ const renderApprovalRequests = (requests) => {
185
+ if (!Array.isArray(requests) || requests.length === 0) {
186
+ return "";
187
+ }
188
+ const rows = requests
189
+ .map((req) => {
190
+ const approvalId = typeof req.approvalId === "string" ? req.approvalId : "";
191
+ const tool = typeof req.tool === "string" ? req.tool : "tool";
192
+ const input = req.input != null ? req.input : {};
193
+ const submitting = req.state === "submitting";
194
+ const approveLabel = submitting && req.pendingDecision === "approve" ? "Approving..." : "Approve";
195
+ const denyLabel = submitting && req.pendingDecision === "deny" ? "Denying..." : "Deny";
196
+ const errorHtml = req._error
197
+ ? '<div style="color: var(--deny); font-size: 11px; margin-top: 4px;">Submit failed: ' + escapeHtml(req._error) + "</div>"
198
+ : "";
199
+ return (
200
+ '<div class="approval-request-item">' +
201
+ '<div class="approval-requests-label">Approval required: <code>' +
202
+ escapeHtml(tool) +
203
+ "</code></div>" +
204
+ renderInputTable(input) +
205
+ errorHtml +
206
+ '<div class="approval-request-actions">' +
207
+ '<button class="approval-action-btn approve" data-approval-id="' +
208
+ escapeHtml(approvalId) +
209
+ '" data-approval-decision="approve" ' +
210
+ (submitting ? "disabled" : "") +
211
+ ">" +
212
+ approveLabel +
213
+ "</button>" +
214
+ '<button class="approval-action-btn deny" data-approval-id="' +
215
+ escapeHtml(approvalId) +
216
+ '" data-approval-decision="deny" ' +
217
+ (submitting ? "disabled" : "") +
218
+ ">" +
219
+ denyLabel +
220
+ "</button>" +
221
+ "</div>" +
222
+ "</div>"
223
+ );
224
+ })
225
+ .join("");
226
+ return (
227
+ '<div class="approval-requests">' +
228
+ rows +
229
+ "</div>"
230
+ );
231
+ };
232
+
233
+ const extractToolImages = (output) => {
234
+ const images = [];
235
+ if (!output || typeof output !== "object") return images;
236
+ const check = (val) => {
237
+ if (val && typeof val === "object" && val.type === "file" && val.data && typeof val.mediaType === "string" && val.mediaType.startsWith("image/")) {
238
+ images.push(val);
239
+ }
240
+ };
241
+ if (Array.isArray(output)) { output.forEach(check); } else { Object.values(output).forEach(check); }
242
+ return images;
243
+ };
244
+
245
+ const renderToolActivity = (items, approvalRequests = [], toolImages = []) => {
246
+ const hasItems = Array.isArray(items) && items.length > 0;
247
+ const hasApprovals = Array.isArray(approvalRequests) && approvalRequests.length > 0;
248
+ if (!hasItems && !hasApprovals) {
249
+ return "";
250
+ }
251
+ const chips = hasItems
252
+ ? items
253
+ .map((item) => '<div class="tool-activity-item">' + escapeHtml(item) + "</div>")
254
+ .join("")
255
+ : "";
256
+ const disclosure = hasItems
257
+ ? (
258
+ '<details class="tool-activity-disclosure">' +
259
+ '<summary class="tool-activity-summary">' +
260
+ '<span class="tool-activity-label">Tool activity</span>' +
261
+ '<span class="tool-activity-caret" aria-hidden="true"><svg viewBox="0 0 12 12" fill="none"><path d="M4.5 2.75L8 6L4.5 9.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg></span>' +
262
+ "</summary>" +
263
+ '<div class="tool-activity-list">' +
264
+ chips +
265
+ "</div>" +
266
+ "</details>"
267
+ )
268
+ : "";
269
+ const hasImages = Array.isArray(toolImages) && toolImages.length > 0;
270
+ const imagesHtml = hasImages
271
+ ? '<div class="tool-images">' + toolImages.map((img) =>
272
+ '<img class="tool-screenshot" src="data:' + escapeHtml(img.mediaType) + ';base64,' + img.data + '" alt="' + escapeHtml(img.filename || "screenshot") + '" />'
273
+ ).join("") + "</div>"
274
+ : "";
275
+ const cls = "tool-activity" + (hasApprovals ? " has-approvals" : "");
276
+ return (
277
+ '<div class="' + cls + '">' +
278
+ imagesHtml +
279
+ disclosure +
280
+ renderApprovalRequests(approvalRequests) +
281
+ "</div>"
282
+ );
283
+ };
284
+
285
+ const renderInputTable = (input) => {
286
+ if (!input || typeof input !== "object") {
287
+ return '<div class="av-complex">' + escapeHtml(String(input ?? "{}")) + "</div>";
288
+ }
289
+ const keys = Object.keys(input);
290
+ if (keys.length === 0) {
291
+ return '<div class="av-complex">{}</div>';
292
+ }
293
+ const formatValue = (val) => {
294
+ if (val === null || val === undefined) return escapeHtml("null");
295
+ if (typeof val === "boolean" || typeof val === "number") return escapeHtml(String(val));
296
+ if (typeof val === "string") return escapeHtml(val);
297
+ try {
298
+ const replacer = (_, v) => typeof v === "bigint" ? String(v) : v;
299
+ return escapeHtml(JSON.stringify(val, replacer, 2));
300
+ } catch {
301
+ return escapeHtml("[unserializable]");
302
+ }
303
+ };
304
+ const rows = keys.map((key) => {
305
+ const val = input[key];
306
+ const isComplex = val !== null && typeof val === "object";
307
+ const cls = isComplex ? "av-complex" : "av";
308
+ return (
309
+ "<tr>" +
310
+ '<td class="ak">' + escapeHtml(key) + "</td>" +
311
+ '<td><div class="' + cls + '">' + formatValue(val) + "</div></td>" +
312
+ "</tr>"
313
+ );
314
+ }).join("");
315
+ return '<table class="approval-request-table">' + rows + "</table>";
316
+ };
317
+
318
+ const updatePendingApproval = (approvalId, updater) => {
319
+ if (!approvalId || typeof updater !== "function") {
320
+ return false;
321
+ }
322
+ const messages = state.activeMessages || [];
323
+ for (const message of messages) {
324
+ if (!message || !Array.isArray(message._pendingApprovals)) {
325
+ continue;
326
+ }
327
+ const idx = message._pendingApprovals.findIndex((req) => req.approvalId === approvalId);
328
+ if (idx < 0) {
329
+ continue;
330
+ }
331
+ const next = updater(message._pendingApprovals[idx], message._pendingApprovals);
332
+ if (next === null) {
333
+ message._pendingApprovals.splice(idx, 1);
334
+ } else if (next && typeof next === "object") {
335
+ message._pendingApprovals[idx] = next;
336
+ }
337
+ return true;
338
+ }
339
+ return false;
340
+ };
341
+
342
+ const toUiPendingApprovals = (pendingApprovals) => {
343
+ if (!Array.isArray(pendingApprovals)) {
344
+ return [];
345
+ }
346
+ return pendingApprovals
347
+ .map((item) => {
348
+ const approvalId =
349
+ item && typeof item.approvalId === "string" ? item.approvalId : "";
350
+ if (!approvalId) {
351
+ return null;
352
+ }
353
+ const toolName = item && typeof item.tool === "string" ? item.tool : "tool";
354
+ return {
355
+ approvalId,
356
+ tool: toolName,
357
+ input: item?.input ?? {},
358
+ state: "pending",
359
+ };
360
+ })
361
+ .filter(Boolean);
362
+ };
363
+
364
+ const hydratePendingApprovals = (messages, pendingApprovals) => {
365
+ const nextMessages = Array.isArray(messages) ? [...messages] : [];
366
+ const pending = toUiPendingApprovals(pendingApprovals);
367
+ if (pending.length === 0) {
368
+ return nextMessages;
369
+ }
370
+ const toolLines = pending.map((request) => "- approval required \\x60" + request.tool + "\\x60");
371
+ for (let idx = nextMessages.length - 1; idx >= 0; idx -= 1) {
372
+ const message = nextMessages[idx];
373
+ if (!message || message.role !== "assistant") {
374
+ continue;
375
+ }
376
+ const metadata = message.metadata && typeof message.metadata === "object" ? message.metadata : {};
377
+ const existingTimeline = Array.isArray(metadata.toolActivity) ? metadata.toolActivity : [];
378
+ const mergedTimeline = [...existingTimeline];
379
+ toolLines.forEach((line) => {
380
+ if (!mergedTimeline.includes(line)) {
381
+ mergedTimeline.push(line);
382
+ }
383
+ });
384
+ nextMessages[idx] = {
385
+ ...message,
386
+ metadata: {
387
+ ...metadata,
388
+ toolActivity: mergedTimeline,
389
+ },
390
+ _pendingApprovals: pending,
391
+ };
392
+ return nextMessages;
393
+ }
394
+ nextMessages.push({
395
+ role: "assistant",
396
+ content: "",
397
+ metadata: { toolActivity: toolLines },
398
+ _pendingApprovals: pending,
399
+ });
400
+ return nextMessages;
401
+ };
402
+
403
+ const formatDate = (epoch) => {
404
+ try {
405
+ const date = new Date(epoch);
406
+ const now = new Date();
407
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
408
+ const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
409
+ const dayDiff = Math.floor((startOfToday - startOfDate) / 86400000);
410
+ if (dayDiff === 0) {
411
+ return "Today";
412
+ }
413
+ if (dayDiff === 1) {
414
+ return "Yesterday";
415
+ }
416
+ if (dayDiff < 7 && dayDiff > 1) {
417
+ return date.toLocaleDateString(undefined, { weekday: "short" });
418
+ }
419
+ return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
420
+ } catch {
421
+ return "";
422
+ }
423
+ };
424
+
425
+ const isMobile = () => window.matchMedia("(max-width: 900px)").matches;
426
+
427
+ const setSidebarOpen = (open) => {
428
+ if (!isMobile()) {
429
+ elements.shell.classList.remove("sidebar-open");
430
+ return;
431
+ }
432
+ elements.shell.classList.toggle("sidebar-open", open);
433
+ };
434
+
435
+ const buildConversationItem = (c) => {
436
+ const item = document.createElement("div");
437
+ item.className = "conversation-item" + (c.conversationId === state.activeConversationId ? " active" : "");
438
+
439
+ if (c.hasPendingApprovals) {
440
+ const dot = document.createElement("span");
441
+ dot.className = "approval-dot";
442
+ item.appendChild(dot);
443
+ }
444
+
445
+ const titleSpan = document.createElement("span");
446
+ titleSpan.textContent = c.title;
447
+ item.appendChild(titleSpan);
448
+
449
+ const isConfirming = state.confirmDeleteId === c.conversationId;
450
+ const deleteBtn = document.createElement("button");
451
+ deleteBtn.className = "delete-btn" + (isConfirming ? " confirming" : "");
452
+ deleteBtn.textContent = isConfirming ? "sure?" : "\\u00d7";
453
+ deleteBtn.onclick = async (e) => {
454
+ e.stopPropagation();
455
+ if (!isConfirming) {
456
+ state.confirmDeleteId = c.conversationId;
457
+ renderConversationList();
458
+ return;
459
+ }
460
+ await api("/api/conversations/" + c.conversationId, { method: "DELETE" });
461
+ if (state.activeConversationId === c.conversationId) {
462
+ state.activeConversationId = null;
463
+ state.activeMessages = [];
464
+ state.contextTokens = 0;
465
+ state.contextWindow = 0;
466
+ updateContextRing();
467
+ pushConversationUrl(null);
468
+ elements.chatTitle.textContent = "";
469
+ renderMessages([]);
470
+ }
471
+ state.confirmDeleteId = null;
472
+ await loadConversations();
473
+ };
474
+ item.appendChild(deleteBtn);
475
+
476
+ item.onclick = async () => {
477
+ if (state.confirmDeleteId) {
478
+ state.confirmDeleteId = null;
479
+ }
480
+ state.activeConversationId = c.conversationId;
481
+ pushConversationUrl(c.conversationId);
482
+ renderConversationList();
483
+ await loadConversation(c.conversationId);
484
+ if (isMobile()) setSidebarOpen(false);
485
+ };
486
+
487
+ return item;
488
+ };
489
+
490
+ const renderConversationList = () => {
491
+ elements.list.innerHTML = "";
492
+ const pending = state.conversations.filter(c => c.hasPendingApprovals);
493
+ const rest = state.conversations.filter(c => !c.hasPendingApprovals);
494
+
495
+ if (pending.length > 0) {
496
+ const label = document.createElement("div");
497
+ label.className = "sidebar-section-label";
498
+ label.textContent = "Awaiting approval";
499
+ elements.list.appendChild(label);
500
+ for (const c of pending) {
501
+ elements.list.appendChild(buildConversationItem(c));
502
+ }
503
+ if (rest.length > 0) {
504
+ const divider = document.createElement("div");
505
+ divider.className = "sidebar-section-divider";
506
+ elements.list.appendChild(divider);
507
+ const recentLabel = document.createElement("div");
508
+ recentLabel.className = "sidebar-section-label";
509
+ recentLabel.textContent = "Recent";
510
+ elements.list.appendChild(recentLabel);
511
+ }
512
+ }
513
+
514
+ for (const c of rest) {
515
+ elements.list.appendChild(buildConversationItem(c));
516
+ }
517
+ };
518
+
519
+ const isNearBottom = (element, threshold = 64) => {
520
+ if (!element) return true;
521
+ return (
522
+ element.scrollHeight - element.clientHeight - element.scrollTop <= threshold
523
+ );
524
+ };
525
+
526
+ const renderMessages = (messages, isStreaming = false, options = {}) => {
527
+ const previousScrollTop = elements.messages.scrollTop;
528
+ const shouldStickToBottom =
529
+ options.forceScrollBottom === true || state.isMessagesPinnedToBottom;
530
+
531
+ const createThinkingIndicator = (label) => {
532
+ const status = document.createElement("div");
533
+ status.className = "thinking-status";
534
+ const spinner = document.createElement("span");
535
+ spinner.className = "thinking-indicator";
536
+ const starFrames = ["✶", "✸", "✹", "✺", "✹", "✷"];
537
+ let frame = 0;
538
+ spinner.textContent = starFrames[0];
539
+ spinner._interval = setInterval(() => {
540
+ frame = (frame + 1) % starFrames.length;
541
+ spinner.textContent = starFrames[frame];
542
+ }, 70);
543
+ status.appendChild(spinner);
544
+ if (label) {
545
+ const text = document.createElement("span");
546
+ text.className = "thinking-status-label";
547
+ text.textContent = label;
548
+ status.appendChild(text);
549
+ }
550
+ return status;
551
+ };
552
+
553
+ elements.messages.innerHTML = "";
554
+ if (!messages || !messages.length) {
555
+ elements.messages.innerHTML = '<div class="empty-state"><div class="assistant-avatar">' + agentInitial + '</div><div>How can I help you today?</div></div>';
556
+ elements.messages.scrollTop = 0;
557
+ return;
558
+ }
559
+ const col = document.createElement("div");
560
+ col.className = "messages-column";
561
+ messages.forEach((m, i) => {
562
+ const row = document.createElement("div");
563
+ row.className = "message-row " + m.role;
564
+ if (m.role === "assistant") {
565
+ const wrap = document.createElement("div");
566
+ wrap.className = "assistant-wrap";
567
+ wrap.innerHTML = '<div class="assistant-avatar">' + agentInitial + '</div>';
568
+ const content = document.createElement("div");
569
+ content.className = "assistant-content";
570
+ const text = String(m.content || "");
571
+ const isLastAssistant = i === messages.length - 1;
572
+ const hasPendingApprovals =
573
+ Array.isArray(m._pendingApprovals) && m._pendingApprovals.length > 0;
574
+ const shouldRenderEmptyStreamingIndicator =
575
+ isStreaming &&
576
+ isLastAssistant &&
577
+ !text &&
578
+ (!Array.isArray(m._sections) || m._sections.length === 0) &&
579
+ (!Array.isArray(m._currentTools) || m._currentTools.length === 0) &&
580
+ !hasPendingApprovals;
581
+
582
+ if (m._error) {
583
+ const errorEl = document.createElement("div");
584
+ errorEl.className = "message-error";
585
+ errorEl.innerHTML = "<strong>Error</strong><br>" + escapeHtml(m._error);
586
+ content.appendChild(errorEl);
587
+ } else if (shouldRenderEmptyStreamingIndicator) {
588
+ content.appendChild(createThinkingIndicator(getThinkingStatusLabel(m)));
589
+ } else {
590
+ // Merge stored sections (persisted) with live sections (from
591
+ // an active stream). For normal messages only one source
592
+ // exists; for liveOnly reconnects both contribute.
593
+ const storedSections = (m.metadata && m.metadata.sections) || [];
594
+ const liveSections = m._sections || [];
595
+ const sections = liveSections.length > 0 && storedSections.length > 0
596
+ ? storedSections.concat(liveSections)
597
+ : liveSections.length > 0 ? liveSections : (storedSections.length > 0 ? storedSections : null);
598
+ const pendingApprovals = Array.isArray(m._pendingApprovals) ? m._pendingApprovals : [];
599
+
600
+ if (sections && sections.length > 0) {
601
+ let lastToolsSectionIndex = -1;
602
+ for (let sectionIdx = sections.length - 1; sectionIdx >= 0; sectionIdx -= 1) {
603
+ if (sections[sectionIdx] && sections[sectionIdx].type === "tools") {
604
+ lastToolsSectionIndex = sectionIdx;
605
+ break;
606
+ }
607
+ }
608
+ // Render sections interleaved
609
+ sections.forEach((section, sectionIdx) => {
610
+ if (section.type === "text") {
611
+ const textDiv = document.createElement("div");
612
+ textDiv.innerHTML = renderAssistantMarkdown(section.content);
613
+ content.appendChild(textDiv);
614
+ } else if (section.type === "tools") {
615
+ const sectionApprovals =
616
+ !isStreaming &&
617
+ pendingApprovals.length > 0 &&
618
+ sectionIdx === lastToolsSectionIndex
619
+ ? pendingApprovals
620
+ : [];
621
+ const sectionImages =
622
+ sectionIdx === lastToolsSectionIndex
623
+ ? (m._toolImages || [])
624
+ : [];
625
+ content.insertAdjacentHTML(
626
+ "beforeend",
627
+ renderToolActivity(section.content, sectionApprovals, sectionImages),
628
+ );
629
+ }
630
+ });
631
+ // While streaming, show current tools if any
632
+ if (isStreaming && i === messages.length - 1 && m._currentTools && m._currentTools.length > 0) {
633
+ content.insertAdjacentHTML(
634
+ "beforeend",
635
+ renderToolActivity(m._currentTools, m._pendingApprovals || [], m._toolImages || []),
636
+ );
637
+ }
638
+ // When reloading with unresolved approvals, show them even when not streaming
639
+ if (!isStreaming && pendingApprovals.length > 0 && lastToolsSectionIndex < 0) {
640
+ content.insertAdjacentHTML("beforeend", renderToolActivity([], m._pendingApprovals));
641
+ }
642
+ // Show current text being typed
643
+ if (isStreaming && i === messages.length - 1 && m._currentText) {
644
+ const textDiv = document.createElement("div");
645
+ textDiv.innerHTML = renderAssistantMarkdown(m._currentText);
646
+ content.appendChild(textDiv);
647
+ }
648
+ } else {
649
+ // Fallback: render text and tools the old way (for old messages without sections)
650
+ if (text) {
651
+ const parsed = extractToolActivity(text);
652
+ content.innerHTML = renderAssistantMarkdown(parsed.content);
653
+ }
654
+ const metadataToolActivity =
655
+ m.metadata && Array.isArray(m.metadata.toolActivity)
656
+ ? m.metadata.toolActivity
657
+ : [];
658
+ if (metadataToolActivity.length > 0 || pendingApprovals.length > 0) {
659
+ content.insertAdjacentHTML(
660
+ "beforeend",
661
+ renderToolActivity(metadataToolActivity, pendingApprovals),
662
+ );
663
+ }
664
+ }
665
+ if (isStreaming && isLastAssistant && !hasPendingApprovals) {
666
+ const waitIndicator = document.createElement("div");
667
+ waitIndicator.appendChild(createThinkingIndicator(getThinkingStatusLabel(m)));
668
+ content.appendChild(waitIndicator);
669
+ }
670
+ }
671
+ wrap.appendChild(content);
672
+ row.appendChild(wrap);
673
+ } else {
674
+ const bubble = document.createElement("div");
675
+ bubble.className = "user-bubble";
676
+ if (typeof m.content === "string") {
677
+ bubble.textContent = m.content;
678
+ } else if (Array.isArray(m.content)) {
679
+ const textParts = m.content.filter(p => p.type === "text").map(p => p.text).join("");
680
+ if (textParts) {
681
+ const textEl = document.createElement("div");
682
+ textEl.textContent = textParts;
683
+ bubble.appendChild(textEl);
684
+ }
685
+ const fileParts = m.content.filter(p => p.type === "file");
686
+ if (fileParts.length > 0) {
687
+ const filesEl = document.createElement("div");
688
+ filesEl.className = "user-file-attachments";
689
+ fileParts.forEach(fp => {
690
+ if (fp.mediaType && fp.mediaType.startsWith("image/")) {
691
+ const img = document.createElement("img");
692
+ if (fp._localBlob) {
693
+ if (!fp._cachedUrl) fp._cachedUrl = URL.createObjectURL(fp._localBlob);
694
+ img.src = fp._cachedUrl;
695
+ } else if (fp.data && fp.data.startsWith("poncho-upload://")) {
696
+ img.src = "/api/uploads/" + encodeURIComponent(fp.data.replace("poncho-upload://", ""));
697
+ } else if (fp.data && (fp.data.startsWith("http://") || fp.data.startsWith("https://"))) {
698
+ img.src = fp.data;
699
+ } else if (fp.data) {
700
+ img.src = "data:" + fp.mediaType + ";base64," + fp.data;
701
+ }
702
+ img.alt = fp.filename || "image";
703
+ filesEl.appendChild(img);
704
+ } else {
705
+ const badge = document.createElement("span");
706
+ badge.className = "user-file-badge";
707
+ badge.textContent = "📎 " + (fp.filename || "file");
708
+ filesEl.appendChild(badge);
709
+ }
710
+ });
711
+ bubble.appendChild(filesEl);
712
+ }
713
+ }
714
+ row.appendChild(bubble);
715
+ }
716
+ col.appendChild(row);
717
+ });
718
+ elements.messages.appendChild(col);
719
+ if (shouldStickToBottom) {
720
+ elements.messages.scrollTop = elements.messages.scrollHeight;
721
+ state.isMessagesPinnedToBottom = true;
722
+ return;
723
+ }
724
+ if (options.preserveScroll !== false) {
725
+ elements.messages.scrollTop = previousScrollTop;
726
+ }
727
+ };
728
+
729
+ const loadConversations = async () => {
730
+ const payload = await api("/api/conversations");
731
+ state.conversations = payload.conversations || [];
732
+ renderConversationList();
733
+ };
734
+
735
+ const loadConversation = async (conversationId) => {
736
+ if (window._resetBrowserPanel) window._resetBrowserPanel();
737
+ const payload = await api("/api/conversations/" + encodeURIComponent(conversationId));
738
+ elements.chatTitle.textContent = payload.conversation.title;
739
+ state.activeMessages = hydratePendingApprovals(
740
+ payload.conversation.messages || [],
741
+ payload.conversation.pendingApprovals || payload.pendingApprovals || [],
742
+ );
743
+ state.contextTokens = 0;
744
+ state.contextWindow = 0;
745
+ updateContextRing();
746
+ renderMessages(state.activeMessages, false, { forceScrollBottom: true });
747
+ elements.prompt.focus();
748
+ if (payload.hasActiveRun && !state.isStreaming) {
749
+ setStreaming(true);
750
+ streamConversationEvents(conversationId, { liveOnly: true }).finally(() => {
751
+ if (state.activeConversationId === conversationId) {
752
+ setStreaming(false);
753
+ renderMessages(state.activeMessages, false);
754
+ }
755
+ });
756
+ }
757
+ };
758
+
759
+ const renameConversation = async (conversationId, title) => {
760
+ const payload = await api("/api/conversations/" + encodeURIComponent(conversationId), {
761
+ method: "PATCH",
762
+ body: JSON.stringify({ title }),
763
+ });
764
+ elements.chatTitle.textContent = payload.conversation.title;
765
+ const entry = state.conversations.find(c => c.conversationId === conversationId);
766
+ if (entry) entry.title = payload.conversation.title;
767
+ renderConversationList();
768
+ };
769
+
770
+ const beginTitleEdit = () => {
771
+ if (!state.activeConversationId) return;
772
+ if (elements.chatTitle.querySelector("input")) return;
773
+
774
+ const current = elements.chatTitle.textContent || "";
775
+ elements.chatTitle.textContent = "";
776
+
777
+ const input = document.createElement("input");
778
+ input.type = "text";
779
+ input.className = "topbar-title-input";
780
+ input.value = current;
781
+
782
+ const sizer = document.createElement("span");
783
+ sizer.style.cssText = "position:absolute;visibility:hidden;white-space:pre;font:inherit;font-weight:inherit;letter-spacing:inherit;padding:0 6px;";
784
+ const autoSize = () => {
785
+ sizer.textContent = input.value || " ";
786
+ elements.chatTitle.appendChild(sizer);
787
+ input.style.width = sizer.offsetWidth + 12 + "px";
788
+ sizer.remove();
789
+ };
790
+
791
+ elements.chatTitle.appendChild(input);
792
+ autoSize();
793
+ input.focus();
794
+ input.select();
795
+ input.addEventListener("input", autoSize);
796
+
797
+ const commit = async () => {
798
+ const newTitle = input.value.trim();
799
+ if (input._committed) return;
800
+ input._committed = true;
801
+
802
+ if (newTitle && newTitle !== current) {
803
+ try {
804
+ await renameConversation(state.activeConversationId, newTitle);
805
+ } catch {
806
+ elements.chatTitle.textContent = current;
807
+ }
808
+ } else {
809
+ elements.chatTitle.textContent = current;
810
+ }
811
+ };
812
+
813
+ input.addEventListener("blur", commit);
814
+ input.addEventListener("keydown", (e) => {
815
+ if (e.key === "Enter") { e.preventDefault(); input.blur(); }
816
+ if (e.key === "Escape") { input.value = current; input.blur(); }
817
+ });
818
+ };
819
+
820
+ const streamConversationEvents = (conversationId, options) => {
821
+ const liveOnly = options && options.liveOnly;
822
+ return new Promise((resolve) => {
823
+ const localMessages = state.activeMessages || [];
824
+ const renderIfActiveConversation = (streaming) => {
825
+ if (state.activeConversationId !== conversationId) {
826
+ return;
827
+ }
828
+ state.activeMessages = localMessages;
829
+ renderMessages(localMessages, streaming);
830
+ };
831
+ let assistantMessage = localMessages[localMessages.length - 1];
832
+ if (!assistantMessage || assistantMessage.role !== "assistant") {
833
+ assistantMessage = {
834
+ role: "assistant",
835
+ content: "",
836
+ _sections: [],
837
+ _currentText: "",
838
+ _currentTools: [],
839
+ _toolImages: [],
840
+ _pendingApprovals: [],
841
+ _activeActivities: [],
842
+ metadata: { toolActivity: [] },
843
+ };
844
+ localMessages.push(assistantMessage);
845
+ state.activeMessages = localMessages;
846
+ }
847
+ if (liveOnly) {
848
+ // Live-only mode: keep metadata.sections intact (the stored
849
+ // base content) and start _sections empty so it only collects
850
+ // NEW sections from live events. The renderer merges both.
851
+ assistantMessage._sections = [];
852
+ assistantMessage._currentText = "";
853
+ assistantMessage._currentTools = [];
854
+ if (!assistantMessage._activeActivities) assistantMessage._activeActivities = [];
855
+ if (!assistantMessage._pendingApprovals) assistantMessage._pendingApprovals = [];
856
+ if (!assistantMessage.metadata) assistantMessage.metadata = {};
857
+ if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
858
+ } else {
859
+ // Full replay mode: reset transient state so replayed events
860
+ // rebuild from scratch (the buffer has the full event history).
861
+ assistantMessage.content = "";
862
+ assistantMessage._sections = [];
863
+ assistantMessage._currentText = "";
864
+ assistantMessage._currentTools = [];
865
+ assistantMessage._activeActivities = [];
866
+ assistantMessage._pendingApprovals = [];
867
+ assistantMessage.metadata = { toolActivity: [] };
868
+ }
869
+
870
+ const url = "/api/conversations/" + encodeURIComponent(conversationId) + "/events" + (liveOnly ? "?live_only=true" : "");
871
+ fetch(url, { credentials: "include" }).then((response) => {
872
+ if (!response.ok || !response.body) {
873
+ resolve(undefined);
874
+ return;
875
+ }
876
+ const reader = response.body.getReader();
877
+ const decoder = new TextDecoder();
878
+ let buffer = "";
879
+ const processChunks = async () => {
880
+ while (true) {
881
+ const { value, done } = await reader.read();
882
+ if (done) {
883
+ break;
884
+ }
885
+ buffer += decoder.decode(value, { stream: true });
886
+ buffer = parseSseChunk(buffer, (eventName, payload) => {
887
+ try {
888
+ if (eventName === "stream:end") {
889
+ return;
890
+ }
891
+ if (eventName === "run:started") {
892
+ if (typeof payload.contextWindow === "number" && payload.contextWindow > 0) {
893
+ state.contextWindow = payload.contextWindow;
894
+ }
895
+ }
896
+ if (eventName === "model:chunk") {
897
+ const chunk = String(payload.content || "");
898
+ if (assistantMessage._currentTools.length > 0 && chunk.length > 0) {
899
+ assistantMessage._sections.push({
900
+ type: "tools",
901
+ content: assistantMessage._currentTools,
902
+ });
903
+ assistantMessage._currentTools = [];
904
+ }
905
+ assistantMessage.content += chunk;
906
+ assistantMessage._currentText += chunk;
907
+ renderIfActiveConversation(true);
908
+ }
909
+ if (eventName === "model:response") {
910
+ if (typeof payload.usage?.input === "number") {
911
+ state.contextTokens = payload.usage.input;
912
+ updateContextRing();
913
+ }
914
+ }
915
+ if (eventName === "tool:started") {
916
+ const toolName = payload.tool || "tool";
917
+ const startedActivity = addActiveActivityFromToolStart(
918
+ assistantMessage,
919
+ payload,
920
+ );
921
+ if (assistantMessage._currentText.length > 0) {
922
+ assistantMessage._sections.push({
923
+ type: "text",
924
+ content: assistantMessage._currentText,
925
+ });
926
+ assistantMessage._currentText = "";
927
+ }
928
+ const detail =
929
+ startedActivity && typeof startedActivity.detail === "string"
930
+ ? startedActivity.detail.trim()
931
+ : "";
932
+ const toolText =
933
+ "- start \\x60" +
934
+ toolName +
935
+ "\\x60" +
936
+ (detail ? " (" + detail + ")" : "");
937
+ assistantMessage._currentTools.push(toolText);
938
+ assistantMessage.metadata.toolActivity.push(toolText);
939
+ renderIfActiveConversation(true);
940
+ }
941
+ if (eventName === "tool:completed") {
942
+ const toolName = payload.tool || "tool";
943
+ const activeActivity = removeActiveActivityForTool(
944
+ assistantMessage,
945
+ toolName,
946
+ );
947
+ const duration =
948
+ typeof payload.duration === "number" ? payload.duration : null;
949
+ const detail =
950
+ activeActivity && typeof activeActivity.detail === "string"
951
+ ? activeActivity.detail.trim()
952
+ : "";
953
+ const meta = [];
954
+ if (duration !== null) meta.push(duration + "ms");
955
+ if (detail) meta.push(detail);
956
+ const toolText =
957
+ "- done \\x60" +
958
+ toolName +
959
+ "\\x60" +
960
+ (meta.length > 0 ? " (" + meta.join(", ") + ")" : "");
961
+ assistantMessage._currentTools.push(toolText);
962
+ assistantMessage.metadata.toolActivity.push(toolText);
963
+ renderIfActiveConversation(true);
964
+ }
965
+ if (eventName === "tool:error") {
966
+ const toolName = payload.tool || "tool";
967
+ const activeActivity = removeActiveActivityForTool(
968
+ assistantMessage,
969
+ toolName,
970
+ );
971
+ const errorMsg = payload.error || "unknown error";
972
+ const detail =
973
+ activeActivity && typeof activeActivity.detail === "string"
974
+ ? activeActivity.detail.trim()
975
+ : "";
976
+ const toolText =
977
+ "- error \\x60" +
978
+ toolName +
979
+ "\\x60" +
980
+ (detail ? " (" + detail + ")" : "") +
981
+ ": " +
982
+ errorMsg;
983
+ assistantMessage._currentTools.push(toolText);
984
+ assistantMessage.metadata.toolActivity.push(toolText);
985
+ renderIfActiveConversation(true);
986
+ }
987
+ if (eventName === "browser:status" && payload.active) {
988
+ if (window._connectBrowserStream) window._connectBrowserStream();
989
+ }
990
+ if (eventName === "tool:approval:required") {
991
+ const toolName = payload.tool || "tool";
992
+ const activeActivity = removeActiveActivityForTool(
993
+ assistantMessage,
994
+ toolName,
995
+ );
996
+ const detailFromPayload = describeToolStart(payload);
997
+ const detail =
998
+ (activeActivity && typeof activeActivity.detail === "string"
999
+ ? activeActivity.detail.trim()
1000
+ : "") ||
1001
+ (detailFromPayload && typeof detailFromPayload.detail === "string"
1002
+ ? detailFromPayload.detail.trim()
1003
+ : "");
1004
+ const toolText =
1005
+ "- approval required \\x60" +
1006
+ toolName +
1007
+ "\\x60" +
1008
+ (detail ? " (" + detail + ")" : "");
1009
+ assistantMessage._currentTools.push(toolText);
1010
+ assistantMessage.metadata.toolActivity.push(toolText);
1011
+ const approvalId =
1012
+ typeof payload.approvalId === "string" ? payload.approvalId : "";
1013
+ if (approvalId) {
1014
+ if (!Array.isArray(assistantMessage._pendingApprovals)) {
1015
+ assistantMessage._pendingApprovals = [];
1016
+ }
1017
+ const exists = assistantMessage._pendingApprovals.some(
1018
+ (req) => req.approvalId === approvalId,
1019
+ );
1020
+ if (!exists) {
1021
+ assistantMessage._pendingApprovals.push({
1022
+ approvalId,
1023
+ tool: toolName,
1024
+ input: payload.input ?? {},
1025
+ state: "pending",
1026
+ });
1027
+ }
1028
+ }
1029
+ renderIfActiveConversation(true);
1030
+ }
1031
+ if (eventName === "tool:approval:granted") {
1032
+ const toolText = "- approval granted";
1033
+ assistantMessage._currentTools.push(toolText);
1034
+ assistantMessage.metadata.toolActivity.push(toolText);
1035
+ const approvalId =
1036
+ typeof payload.approvalId === "string" ? payload.approvalId : "";
1037
+ if (approvalId && Array.isArray(assistantMessage._pendingApprovals)) {
1038
+ assistantMessage._pendingApprovals = assistantMessage._pendingApprovals.filter(
1039
+ (req) => req.approvalId !== approvalId,
1040
+ );
1041
+ }
1042
+ renderIfActiveConversation(true);
1043
+ }
1044
+ if (eventName === "tool:approval:denied") {
1045
+ const toolText = "- approval denied";
1046
+ assistantMessage._currentTools.push(toolText);
1047
+ assistantMessage.metadata.toolActivity.push(toolText);
1048
+ const approvalId =
1049
+ typeof payload.approvalId === "string" ? payload.approvalId : "";
1050
+ if (approvalId && Array.isArray(assistantMessage._pendingApprovals)) {
1051
+ assistantMessage._pendingApprovals = assistantMessage._pendingApprovals.filter(
1052
+ (req) => req.approvalId !== approvalId,
1053
+ );
1054
+ }
1055
+ renderIfActiveConversation(true);
1056
+ }
1057
+ if (eventName === "run:completed") {
1058
+ assistantMessage._activeActivities = [];
1059
+ if (
1060
+ !assistantMessage.content ||
1061
+ assistantMessage.content.length === 0
1062
+ ) {
1063
+ assistantMessage.content = String(
1064
+ payload.result?.response || "",
1065
+ );
1066
+ }
1067
+ if (assistantMessage._currentTools.length > 0) {
1068
+ assistantMessage._sections.push({
1069
+ type: "tools",
1070
+ content: assistantMessage._currentTools,
1071
+ });
1072
+ assistantMessage._currentTools = [];
1073
+ }
1074
+ if (assistantMessage._currentText.length > 0) {
1075
+ assistantMessage._sections.push({
1076
+ type: "text",
1077
+ content: assistantMessage._currentText,
1078
+ });
1079
+ assistantMessage._currentText = "";
1080
+ }
1081
+ renderIfActiveConversation(false);
1082
+ }
1083
+ if (eventName === "run:cancelled") {
1084
+ assistantMessage._activeActivities = [];
1085
+ if (assistantMessage._currentTools.length > 0) {
1086
+ assistantMessage._sections.push({
1087
+ type: "tools",
1088
+ content: assistantMessage._currentTools,
1089
+ });
1090
+ assistantMessage._currentTools = [];
1091
+ }
1092
+ if (assistantMessage._currentText.length > 0) {
1093
+ assistantMessage._sections.push({
1094
+ type: "text",
1095
+ content: assistantMessage._currentText,
1096
+ });
1097
+ assistantMessage._currentText = "";
1098
+ }
1099
+ renderIfActiveConversation(false);
1100
+ }
1101
+ if (eventName === "run:error") {
1102
+ assistantMessage._activeActivities = [];
1103
+ if (assistantMessage._currentTools.length > 0) {
1104
+ assistantMessage._sections.push({
1105
+ type: "tools",
1106
+ content: assistantMessage._currentTools,
1107
+ });
1108
+ assistantMessage._currentTools = [];
1109
+ }
1110
+ if (assistantMessage._currentText.length > 0) {
1111
+ assistantMessage._sections.push({
1112
+ type: "text",
1113
+ content: assistantMessage._currentText,
1114
+ });
1115
+ assistantMessage._currentText = "";
1116
+ }
1117
+ const errMsg =
1118
+ payload.error?.message || "Something went wrong";
1119
+ assistantMessage._error = errMsg;
1120
+ renderIfActiveConversation(false);
1121
+ }
1122
+ } catch (error) {
1123
+ console.error("SSE reconnect event error:", eventName, error);
1124
+ }
1125
+ });
1126
+ }
1127
+ };
1128
+ processChunks().finally(() => {
1129
+ if (state.activeConversationId === conversationId) {
1130
+ state.activeMessages = localMessages;
1131
+ }
1132
+ resolve(undefined);
1133
+ });
1134
+ }).catch(() => {
1135
+ resolve(undefined);
1136
+ });
1137
+ });
1138
+ };
1139
+
1140
+ const createConversation = async (title, options = {}) => {
1141
+ if (window._resetBrowserPanel) window._resetBrowserPanel();
1142
+ const shouldLoadConversation = options.loadConversation !== false;
1143
+ const payload = await api("/api/conversations", {
1144
+ method: "POST",
1145
+ body: JSON.stringify(title ? { title } : {})
1146
+ });
1147
+ state.activeConversationId = payload.conversation.conversationId;
1148
+ state.confirmDeleteId = null;
1149
+ pushConversationUrl(state.activeConversationId);
1150
+ await loadConversations();
1151
+ if (shouldLoadConversation) {
1152
+ await loadConversation(state.activeConversationId);
1153
+ } else {
1154
+ elements.chatTitle.textContent = payload.conversation.title || "New conversation";
1155
+ }
1156
+ return state.activeConversationId;
1157
+ };
1158
+
1159
+ const parseSseChunk = (buffer, onEvent) => {
1160
+ let rest = buffer;
1161
+ while (true) {
1162
+ const index = rest.indexOf("\\n\\n");
1163
+ if (index < 0) {
1164
+ return rest;
1165
+ }
1166
+ const raw = rest.slice(0, index);
1167
+ rest = rest.slice(index + 2);
1168
+ const lines = raw.split("\\n");
1169
+ let eventName = "message";
1170
+ let data = "";
1171
+ for (const line of lines) {
1172
+ if (line.startsWith("event:")) {
1173
+ eventName = line.slice(6).trim();
1174
+ } else if (line.startsWith("data:")) {
1175
+ data += line.slice(5).trim();
1176
+ }
1177
+ }
1178
+ if (data) {
1179
+ try {
1180
+ onEvent(eventName, JSON.parse(data));
1181
+ } catch {}
1182
+ }
1183
+ }
1184
+ };
1185
+
1186
+ const setStreaming = (value) => {
1187
+ state.isStreaming = value;
1188
+ const canStop = value && !!state.activeStreamRunId;
1189
+ elements.send.disabled = value ? !canStop : false;
1190
+ elements.send.innerHTML = value ? stopIconMarkup : sendIconMarkup;
1191
+ elements.send.classList.toggle("stop-mode", value);
1192
+ if (elements.sendBtnWrapper) {
1193
+ elements.sendBtnWrapper.classList.toggle("stop-mode", value);
1194
+ }
1195
+ elements.send.setAttribute("aria-label", value ? "Stop response" : "Send message");
1196
+ elements.send.setAttribute(
1197
+ "title",
1198
+ value ? (canStop ? "Stop response" : "Starting response...") : "Send message",
1199
+ );
1200
+ };
1201
+
1202
+ const pushToolActivity = (assistantMessage, line) => {
1203
+ if (!line) {
1204
+ return;
1205
+ }
1206
+ if (
1207
+ !assistantMessage.metadata ||
1208
+ !Array.isArray(assistantMessage.metadata.toolActivity)
1209
+ ) {
1210
+ assistantMessage.metadata = {
1211
+ ...(assistantMessage.metadata || {}),
1212
+ toolActivity: [],
1213
+ };
1214
+ }
1215
+ assistantMessage.metadata.toolActivity.push(line);
1216
+ };
1217
+
1218
+ const ensureActiveActivities = (assistantMessage) => {
1219
+ if (!Array.isArray(assistantMessage._activeActivities)) {
1220
+ assistantMessage._activeActivities = [];
1221
+ }
1222
+ return assistantMessage._activeActivities;
1223
+ };
1224
+
1225
+ const getStringInputField = (input, key) => {
1226
+ if (!input || typeof input !== "object") {
1227
+ return "";
1228
+ }
1229
+ const value = input[key];
1230
+ return typeof value === "string" ? value.trim() : "";
1231
+ };
1232
+
1233
+ const describeToolStart = (payload) => {
1234
+ const toolName = payload && typeof payload.tool === "string" ? payload.tool : "tool";
1235
+ const input = payload && payload.input && typeof payload.input === "object" ? payload.input : {};
1236
+
1237
+ if (toolName === "activate_skill") {
1238
+ const skillName = getStringInputField(input, "name") || "skill";
1239
+ return {
1240
+ kind: "skill",
1241
+ tool: toolName,
1242
+ label: "Activating " + skillName + " skill",
1243
+ detail: "skill: " + skillName,
1244
+ };
1245
+ }
1246
+
1247
+ if (toolName === "run_skill_script") {
1248
+ const scriptPath = getStringInputField(input, "script");
1249
+ const skillName = getStringInputField(input, "skill");
1250
+ if (scriptPath && skillName) {
1251
+ return {
1252
+ kind: "tool",
1253
+ tool: toolName,
1254
+ label: "Running script " + scriptPath + " in " + skillName + " skill",
1255
+ detail: "script: " + scriptPath + ", skill: " + skillName,
1256
+ };
1257
+ }
1258
+ if (scriptPath) {
1259
+ return {
1260
+ kind: "tool",
1261
+ tool: toolName,
1262
+ label: "Running script " + scriptPath,
1263
+ detail: "script: " + scriptPath,
1264
+ };
1265
+ }
1266
+ }
1267
+
1268
+ if (toolName === "read_skill_resource") {
1269
+ const resourcePath = getStringInputField(input, "path");
1270
+ const skillName = getStringInputField(input, "skill");
1271
+ if (resourcePath && skillName) {
1272
+ return {
1273
+ kind: "tool",
1274
+ tool: toolName,
1275
+ label: "Reading " + resourcePath + " from " + skillName + " skill",
1276
+ detail: "path: " + resourcePath + ", skill: " + skillName,
1277
+ };
1278
+ }
1279
+ if (resourcePath) {
1280
+ return {
1281
+ kind: "tool",
1282
+ tool: toolName,
1283
+ label: "Reading " + resourcePath,
1284
+ detail: "path: " + resourcePath,
1285
+ };
1286
+ }
1287
+ }
1288
+
1289
+ if (toolName === "read_file") {
1290
+ const path = getStringInputField(input, "path");
1291
+ if (path) {
1292
+ return {
1293
+ kind: "tool",
1294
+ tool: toolName,
1295
+ label: "Reading " + path,
1296
+ detail: "path: " + path,
1297
+ };
1298
+ }
1299
+ }
1300
+
1301
+ return {
1302
+ kind: "tool",
1303
+ tool: toolName,
1304
+ label: "Running " + toolName + " tool",
1305
+ detail: "",
1306
+ };
1307
+ };
1308
+
1309
+ const addActiveActivityFromToolStart = (assistantMessage, payload) => {
1310
+ const activities = ensureActiveActivities(assistantMessage);
1311
+ const activity = describeToolStart(payload);
1312
+ activities.push(activity);
1313
+ return activity;
1314
+ };
1315
+
1316
+ const removeActiveActivityForTool = (assistantMessage, toolName) => {
1317
+ if (!toolName || !Array.isArray(assistantMessage._activeActivities)) {
1318
+ return null;
1319
+ }
1320
+ const activities = assistantMessage._activeActivities;
1321
+ const idx = activities.findIndex((item) => item && item.tool === toolName);
1322
+ if (idx >= 0) {
1323
+ return activities.splice(idx, 1)[0] || null;
1324
+ }
1325
+ return null;
1326
+ };
1327
+
1328
+ const getThinkingStatusLabel = (assistantMessage) => {
1329
+ const activities = Array.isArray(assistantMessage?._activeActivities)
1330
+ ? assistantMessage._activeActivities
1331
+ : [];
1332
+ const labels = [];
1333
+ activities.forEach((item) => {
1334
+ if (!item || typeof item.label !== "string") {
1335
+ return;
1336
+ }
1337
+ const label = item.label.trim();
1338
+ if (!label || labels.includes(label)) {
1339
+ return;
1340
+ }
1341
+ labels.push(label);
1342
+ });
1343
+ if (labels.length === 1) {
1344
+ return labels[0];
1345
+ }
1346
+ if (labels.length === 2) {
1347
+ return labels[0] + ", " + labels[1];
1348
+ }
1349
+ if (labels.length > 2) {
1350
+ return labels[0] + ", " + labels[1] + " +" + (labels.length - 2) + " more";
1351
+ }
1352
+
1353
+ if (Array.isArray(assistantMessage?._currentTools)) {
1354
+ const tick = String.fromCharCode(96);
1355
+ const startPrefix = "- start " + tick;
1356
+ for (let idx = assistantMessage._currentTools.length - 1; idx >= 0; idx -= 1) {
1357
+ const item = String(assistantMessage._currentTools[idx] || "");
1358
+ if (item.startsWith(startPrefix)) {
1359
+ const rest = item.slice(startPrefix.length);
1360
+ const endIdx = rest.indexOf(tick);
1361
+ const toolName = (endIdx >= 0 ? rest.slice(0, endIdx) : rest).trim();
1362
+ if (toolName) {
1363
+ return "Running " + toolName + " tool";
1364
+ }
1365
+ }
1366
+ }
1367
+ }
1368
+ return "";
1369
+ };
1370
+
1371
+ const autoResizePrompt = () => {
1372
+ const el = elements.prompt;
1373
+ el.style.height = "auto";
1374
+ const scrollHeight = el.scrollHeight;
1375
+ const nextHeight = Math.min(scrollHeight, 200);
1376
+ el.style.height = nextHeight + "px";
1377
+ el.style.overflowY = scrollHeight > 200 ? "auto" : "hidden";
1378
+ };
1379
+
1380
+ const stopActiveRun = async () => {
1381
+ const stopRunId = state.activeStreamRunId;
1382
+ if (!stopRunId) return;
1383
+ const conversationId = state.activeStreamConversationId || state.activeConversationId;
1384
+ if (!conversationId) return;
1385
+ // Disable the stop button immediately so the user sees feedback.
1386
+ state.activeStreamRunId = null;
1387
+ setStreaming(state.isStreaming);
1388
+ // Signal the server to cancel the run. The server will emit
1389
+ // run:cancelled through the still-open SSE stream, which
1390
+ // sendMessage() processes naturally – the stream ends on its own
1391
+ // and cleanup happens in one finally block. No fetch abort needed.
1392
+ try {
1393
+ await api(
1394
+ "/api/conversations/" + encodeURIComponent(conversationId) + "/stop",
1395
+ {
1396
+ method: "POST",
1397
+ body: JSON.stringify({ runId: stopRunId }),
1398
+ },
1399
+ );
1400
+ } catch (e) {
1401
+ console.warn("Failed to stop conversation run:", e);
1402
+ // Fallback: abort the local fetch so the UI at least stops.
1403
+ const abortController = state.activeStreamAbortController;
1404
+ if (abortController && !abortController.signal.aborted) {
1405
+ abortController.abort();
1406
+ }
1407
+ }
1408
+ };
1409
+
1410
+ const renderAttachmentPreview = () => {
1411
+ const el = elements.attachmentPreview;
1412
+ if (state.pendingFiles.length === 0) {
1413
+ el.style.display = "none";
1414
+ el.innerHTML = "";
1415
+ return;
1416
+ }
1417
+ el.style.display = "flex";
1418
+ el.innerHTML = state.pendingFiles.map((f, i) => {
1419
+ const isImage = f.type.startsWith("image/");
1420
+ const thumbHtml = isImage
1421
+ ? '<img src="' + URL.createObjectURL(f) + '" alt="" />'
1422
+ : '<span class="file-icon">📎</span>';
1423
+ return '<div class="attachment-chip" data-idx="' + i + '">'
1424
+ + thumbHtml
1425
+ + '<span class="filename">' + escapeHtml(f.name) + '</span>'
1426
+ + '<span class="remove-attachment" data-idx="' + i + '">&times;</span>'
1427
+ + '</div>';
1428
+ }).join("");
1429
+ };
1430
+
1431
+ const addFiles = (fileList) => {
1432
+ for (const f of fileList) {
1433
+ if (f.size > 25 * 1024 * 1024) {
1434
+ alert("File too large: " + f.name + " (max 25MB)");
1435
+ continue;
1436
+ }
1437
+ state.pendingFiles.push(f);
1438
+ }
1439
+ renderAttachmentPreview();
1440
+ };
1441
+
1442
+ const sendMessage = async (text) => {
1443
+ const messageText = (text || "").trim();
1444
+ if (!messageText || state.isStreaming) {
1445
+ return;
1446
+ }
1447
+ const filesToSend = [...state.pendingFiles];
1448
+ state.pendingFiles = [];
1449
+ renderAttachmentPreview();
1450
+ let userContent;
1451
+ if (filesToSend.length > 0) {
1452
+ userContent = [{ type: "text", text: messageText }];
1453
+ for (const f of filesToSend) {
1454
+ userContent.push({
1455
+ type: "file",
1456
+ data: URL.createObjectURL(f),
1457
+ mediaType: f.type,
1458
+ filename: f.name,
1459
+ _localBlob: f,
1460
+ });
1461
+ }
1462
+ } else {
1463
+ userContent = messageText;
1464
+ }
1465
+ const localMessages = [...(state.activeMessages || []), { role: "user", content: userContent }];
1466
+ let assistantMessage = {
1467
+ role: "assistant",
1468
+ content: "",
1469
+ _sections: [],
1470
+ _currentText: "",
1471
+ _currentTools: [],
1472
+ _toolImages: [],
1473
+ _activeActivities: [],
1474
+ _pendingApprovals: [],
1475
+ metadata: { toolActivity: [] }
1476
+ };
1477
+ localMessages.push(assistantMessage);
1478
+ state.activeMessages = localMessages;
1479
+ renderMessages(localMessages, true, { forceScrollBottom: true });
1480
+ let conversationId = state.activeConversationId;
1481
+ const streamAbortController = new AbortController();
1482
+ state.activeStreamAbortController = streamAbortController;
1483
+ state.activeStreamRunId = null;
1484
+ setStreaming(true);
1485
+ try {
1486
+ if (!conversationId) {
1487
+ conversationId = await createConversation(messageText, { loadConversation: false });
1488
+ }
1489
+ state.activeStreamConversationId = conversationId;
1490
+ const streamConversationId = conversationId;
1491
+ const renderIfActiveConversation = (streaming) => {
1492
+ if (state.activeConversationId !== streamConversationId) {
1493
+ return;
1494
+ }
1495
+ state.activeMessages = localMessages;
1496
+ renderMessages(localMessages, streaming);
1497
+ };
1498
+ const finalizeAssistantMessage = () => {
1499
+ assistantMessage._activeActivities = [];
1500
+ if (assistantMessage._currentTools.length > 0) {
1501
+ assistantMessage._sections.push({ type: "tools", content: assistantMessage._currentTools });
1502
+ assistantMessage._currentTools = [];
1503
+ }
1504
+ if (assistantMessage._currentText.length > 0) {
1505
+ assistantMessage._sections.push({ type: "text", content: assistantMessage._currentText });
1506
+ assistantMessage._currentText = "";
1507
+ }
1508
+ };
1509
+ let _continuationMessage = messageText;
1510
+ let _totalSteps = 0;
1511
+ let _maxSteps = 0;
1512
+ while (_continuationMessage) {
1513
+ let _shouldContinue = false;
1514
+ let fetchOpts;
1515
+ if (filesToSend.length > 0 && _continuationMessage === messageText) {
1516
+ const formData = new FormData();
1517
+ formData.append("message", _continuationMessage);
1518
+ for (const f of filesToSend) {
1519
+ formData.append("files", f, f.name);
1520
+ }
1521
+ fetchOpts = {
1522
+ method: "POST",
1523
+ credentials: "include",
1524
+ headers: { "x-csrf-token": state.csrfToken },
1525
+ body: formData,
1526
+ signal: streamAbortController.signal,
1527
+ };
1528
+ } else {
1529
+ fetchOpts = {
1530
+ method: "POST",
1531
+ credentials: "include",
1532
+ headers: { "Content-Type": "application/json", "x-csrf-token": state.csrfToken },
1533
+ body: JSON.stringify({ message: _continuationMessage }),
1534
+ signal: streamAbortController.signal,
1535
+ };
1536
+ }
1537
+ const response = await fetch(
1538
+ "/api/conversations/" + encodeURIComponent(conversationId) + "/messages",
1539
+ fetchOpts,
1540
+ );
1541
+ if (!response.ok || !response.body) {
1542
+ throw new Error("Failed to stream response");
1543
+ }
1544
+ const reader = response.body.getReader();
1545
+ const decoder = new TextDecoder();
1546
+ let buffer = "";
1547
+ while (true) {
1548
+ const { value, done } = await reader.read();
1549
+ if (done) {
1550
+ break;
1551
+ }
1552
+ buffer += decoder.decode(value, { stream: true });
1553
+ buffer = parseSseChunk(buffer, (eventName, payload) => {
1554
+ try {
1555
+ if (eventName === "model:chunk") {
1556
+ const chunk = String(payload.content || "");
1557
+ // If we have tools accumulated and text starts again, push tools as a section
1558
+ if (assistantMessage._currentTools.length > 0 && chunk.length > 0) {
1559
+ assistantMessage._sections.push({ type: "tools", content: assistantMessage._currentTools });
1560
+ assistantMessage._currentTools = [];
1561
+ }
1562
+ assistantMessage.content += chunk;
1563
+ assistantMessage._currentText += chunk;
1564
+ renderIfActiveConversation(true);
1565
+ }
1566
+ if (eventName === "run:started") {
1567
+ state.activeStreamRunId = typeof payload.runId === "string" ? payload.runId : null;
1568
+ if (typeof payload.contextWindow === "number" && payload.contextWindow > 0) {
1569
+ state.contextWindow = payload.contextWindow;
1570
+ }
1571
+ setStreaming(state.isStreaming);
1572
+ }
1573
+ if (eventName === "model:response") {
1574
+ if (typeof payload.usage?.input === "number") {
1575
+ state.contextTokens = payload.usage.input;
1576
+ updateContextRing();
1577
+ }
1578
+ }
1579
+ if (eventName === "tool:started") {
1580
+ const toolName = payload.tool || "tool";
1581
+ const startedActivity = addActiveActivityFromToolStart(
1582
+ assistantMessage,
1583
+ payload,
1584
+ );
1585
+ // If we have text accumulated, push it as a text section
1586
+ if (assistantMessage._currentText.length > 0) {
1587
+ assistantMessage._sections.push({ type: "text", content: assistantMessage._currentText });
1588
+ assistantMessage._currentText = "";
1589
+ }
1590
+ const detail =
1591
+ startedActivity && typeof startedActivity.detail === "string"
1592
+ ? startedActivity.detail.trim()
1593
+ : "";
1594
+ const toolText =
1595
+ "- start \\x60" + toolName + "\\x60" + (detail ? " (" + detail + ")" : "");
1596
+ assistantMessage._currentTools.push(toolText);
1597
+ if (!assistantMessage.metadata) assistantMessage.metadata = {};
1598
+ if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
1599
+ assistantMessage.metadata.toolActivity.push(toolText);
1600
+ renderIfActiveConversation(true);
1601
+ }
1602
+ if (eventName === "tool:completed") {
1603
+ const toolName = payload.tool || "tool";
1604
+ const activeActivity = removeActiveActivityForTool(
1605
+ assistantMessage,
1606
+ toolName,
1607
+ );
1608
+ const duration = typeof payload.duration === "number" ? payload.duration : null;
1609
+ const detail =
1610
+ activeActivity && typeof activeActivity.detail === "string"
1611
+ ? activeActivity.detail.trim()
1612
+ : "";
1613
+ const meta = [];
1614
+ if (duration !== null) meta.push(duration + "ms");
1615
+ if (detail) meta.push(detail);
1616
+ const toolText =
1617
+ "- done \\x60" + toolName + "\\x60" + (meta.length > 0 ? " (" + meta.join(", ") + ")" : "");
1618
+ assistantMessage._currentTools.push(toolText);
1619
+ if (!assistantMessage.metadata) assistantMessage.metadata = {};
1620
+ if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
1621
+ assistantMessage.metadata.toolActivity.push(toolText);
1622
+ renderIfActiveConversation(true);
1623
+ }
1624
+ if (eventName === "tool:error") {
1625
+ const toolName = payload.tool || "tool";
1626
+ const activeActivity = removeActiveActivityForTool(
1627
+ assistantMessage,
1628
+ toolName,
1629
+ );
1630
+ const errorMsg = payload.error || "unknown error";
1631
+ const detail =
1632
+ activeActivity && typeof activeActivity.detail === "string"
1633
+ ? activeActivity.detail.trim()
1634
+ : "";
1635
+ const toolText =
1636
+ "- error \\x60" +
1637
+ toolName +
1638
+ "\\x60" +
1639
+ (detail ? " (" + detail + ")" : "") +
1640
+ ": " +
1641
+ errorMsg;
1642
+ assistantMessage._currentTools.push(toolText);
1643
+ if (!assistantMessage.metadata) assistantMessage.metadata = {};
1644
+ if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
1645
+ assistantMessage.metadata.toolActivity.push(toolText);
1646
+ renderIfActiveConversation(true);
1647
+ }
1648
+ if (eventName === "browser:status" && payload.active) {
1649
+ if (window._connectBrowserStream) window._connectBrowserStream();
1650
+ }
1651
+ if (eventName === "tool:approval:required") {
1652
+ const toolName = payload.tool || "tool";
1653
+ const activeActivity = removeActiveActivityForTool(
1654
+ assistantMessage,
1655
+ toolName,
1656
+ );
1657
+ const detailFromPayload = describeToolStart(payload);
1658
+ const detail =
1659
+ (activeActivity && typeof activeActivity.detail === "string"
1660
+ ? activeActivity.detail.trim()
1661
+ : "") ||
1662
+ (detailFromPayload && typeof detailFromPayload.detail === "string"
1663
+ ? detailFromPayload.detail.trim()
1664
+ : "");
1665
+ const toolText =
1666
+ "- approval required \\x60" +
1667
+ toolName +
1668
+ "\\x60" +
1669
+ (detail ? " (" + detail + ")" : "");
1670
+ assistantMessage._currentTools.push(toolText);
1671
+ if (!assistantMessage.metadata) assistantMessage.metadata = {};
1672
+ if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
1673
+ assistantMessage.metadata.toolActivity.push(toolText);
1674
+ const approvalId =
1675
+ typeof payload.approvalId === "string" ? payload.approvalId : "";
1676
+ if (approvalId) {
1677
+ if (!Array.isArray(assistantMessage._pendingApprovals)) {
1678
+ assistantMessage._pendingApprovals = [];
1679
+ }
1680
+ const exists = assistantMessage._pendingApprovals.some(
1681
+ (req) => req.approvalId === approvalId,
1682
+ );
1683
+ if (!exists) {
1684
+ assistantMessage._pendingApprovals.push({
1685
+ approvalId,
1686
+ tool: toolName,
1687
+ input: payload.input ?? {},
1688
+ state: "pending",
1689
+ });
1690
+ }
1691
+ }
1692
+ renderIfActiveConversation(true);
1693
+ }
1694
+ if (eventName === "tool:approval:granted") {
1695
+ const toolText = "- approval granted";
1696
+ assistantMessage._currentTools.push(toolText);
1697
+ if (!assistantMessage.metadata) assistantMessage.metadata = {};
1698
+ if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
1699
+ assistantMessage.metadata.toolActivity.push(toolText);
1700
+ const approvalId =
1701
+ typeof payload.approvalId === "string" ? payload.approvalId : "";
1702
+ if (approvalId && Array.isArray(assistantMessage._pendingApprovals)) {
1703
+ assistantMessage._pendingApprovals = assistantMessage._pendingApprovals.filter(
1704
+ (req) => req.approvalId !== approvalId,
1705
+ );
1706
+ }
1707
+ renderIfActiveConversation(true);
1708
+ }
1709
+ if (eventName === "tool:approval:denied") {
1710
+ const toolText = "- approval denied";
1711
+ assistantMessage._currentTools.push(toolText);
1712
+ if (!assistantMessage.metadata) assistantMessage.metadata = {};
1713
+ if (!assistantMessage.metadata.toolActivity) assistantMessage.metadata.toolActivity = [];
1714
+ assistantMessage.metadata.toolActivity.push(toolText);
1715
+ const approvalId =
1716
+ typeof payload.approvalId === "string" ? payload.approvalId : "";
1717
+ if (approvalId && Array.isArray(assistantMessage._pendingApprovals)) {
1718
+ assistantMessage._pendingApprovals = assistantMessage._pendingApprovals.filter(
1719
+ (req) => req.approvalId !== approvalId,
1720
+ );
1721
+ }
1722
+ renderIfActiveConversation(true);
1723
+ }
1724
+ if (eventName === "run:completed") {
1725
+ _totalSteps += typeof payload.result?.steps === "number" ? payload.result.steps : 0;
1726
+ if (typeof payload.result?.maxSteps === "number") _maxSteps = payload.result.maxSteps;
1727
+ if (payload.result?.continuation === true && (_maxSteps <= 0 || _totalSteps < _maxSteps)) {
1728
+ _shouldContinue = true;
1729
+ } else {
1730
+ finalizeAssistantMessage();
1731
+ if (!assistantMessage.content || assistantMessage.content.length === 0) {
1732
+ assistantMessage.content = String(payload.result?.response || "");
1733
+ }
1734
+ renderIfActiveConversation(false);
1735
+ }
1736
+ }
1737
+ if (eventName === "run:cancelled") {
1738
+ finalizeAssistantMessage();
1739
+ renderIfActiveConversation(false);
1740
+ }
1741
+ if (eventName === "run:error") {
1742
+ finalizeAssistantMessage();
1743
+ const errMsg = payload.error?.message || "Something went wrong";
1744
+ assistantMessage._error = errMsg;
1745
+ renderIfActiveConversation(false);
1746
+ }
1747
+ } catch (error) {
1748
+ console.error("SSE event handling error:", eventName, error);
1749
+ }
1750
+ });
1751
+ }
1752
+ if (!_shouldContinue) break;
1753
+ _continuationMessage = "Continue";
1754
+ }
1755
+ // Update active state only if user is still on this conversation.
1756
+ if (state.activeConversationId === streamConversationId) {
1757
+ state.activeMessages = localMessages;
1758
+ }
1759
+ await loadConversations();
1760
+ // Don't reload the conversation - we already have the latest state with tool chips
1761
+ } catch (error) {
1762
+ if (streamAbortController.signal.aborted) {
1763
+ assistantMessage._activeActivities = [];
1764
+ if (assistantMessage._currentTools.length > 0) {
1765
+ assistantMessage._sections.push({ type: "tools", content: assistantMessage._currentTools });
1766
+ assistantMessage._currentTools = [];
1767
+ }
1768
+ if (assistantMessage._currentText.length > 0) {
1769
+ assistantMessage._sections.push({ type: "text", content: assistantMessage._currentText });
1770
+ assistantMessage._currentText = "";
1771
+ }
1772
+ renderMessages(localMessages, false);
1773
+ } else {
1774
+ assistantMessage._activeActivities = [];
1775
+ assistantMessage._error = error instanceof Error ? error.message : "Something went wrong";
1776
+ renderMessages(localMessages, false);
1777
+ }
1778
+ } finally {
1779
+ if (state.activeStreamAbortController === streamAbortController) {
1780
+ state.activeStreamAbortController = null;
1781
+ }
1782
+ if (state.activeStreamConversationId === conversationId) {
1783
+ state.activeStreamConversationId = null;
1784
+ }
1785
+ state.activeStreamRunId = null;
1786
+ setStreaming(false);
1787
+ elements.prompt.focus();
1788
+ }
1789
+ };
1790
+
1791
+ const requireAuth = async () => {
1792
+ try {
1793
+ const session = await api("/api/auth/session");
1794
+ if (!session.authenticated) {
1795
+ elements.auth.classList.remove("hidden");
1796
+ elements.app.classList.add("hidden");
1797
+ return false;
1798
+ }
1799
+ state.csrfToken = session.csrfToken || "";
1800
+ elements.auth.classList.add("hidden");
1801
+ elements.app.classList.remove("hidden");
1802
+ return true;
1803
+ } catch {
1804
+ elements.auth.classList.remove("hidden");
1805
+ elements.app.classList.add("hidden");
1806
+ return false;
1807
+ }
1808
+ };
1809
+
1810
+ elements.loginForm.addEventListener("submit", async (event) => {
1811
+ event.preventDefault();
1812
+ elements.loginError.textContent = "";
1813
+ try {
1814
+ const result = await api("/api/auth/login", {
1815
+ method: "POST",
1816
+ body: JSON.stringify({ passphrase: elements.passphrase.value || "" })
1817
+ });
1818
+ state.csrfToken = result.csrfToken || "";
1819
+ elements.passphrase.value = "";
1820
+ elements.auth.classList.add("hidden");
1821
+ elements.app.classList.remove("hidden");
1822
+ await loadConversations();
1823
+ const urlConversationId = getConversationIdFromUrl();
1824
+ if (urlConversationId) {
1825
+ state.activeConversationId = urlConversationId;
1826
+ renderConversationList();
1827
+ try {
1828
+ await loadConversation(urlConversationId);
1829
+ } catch {
1830
+ state.activeConversationId = null;
1831
+ state.activeMessages = [];
1832
+ replaceConversationUrl(null);
1833
+ renderMessages([]);
1834
+ renderConversationList();
1835
+ }
1836
+ }
1837
+ } catch (error) {
1838
+ elements.loginError.textContent = error.message || "Login failed";
1839
+ }
1840
+ });
1841
+
1842
+ const startNewChat = () => {
1843
+ if (window._resetBrowserPanel) window._resetBrowserPanel();
1844
+ state.activeConversationId = null;
1845
+ state.activeMessages = [];
1846
+ state.confirmDeleteId = null;
1847
+ state.contextTokens = 0;
1848
+ state.contextWindow = 0;
1849
+ updateContextRing();
1850
+ pushConversationUrl(null);
1851
+ elements.chatTitle.textContent = "";
1852
+ renderMessages([]);
1853
+ renderConversationList();
1854
+ elements.prompt.focus();
1855
+ if (isMobile()) {
1856
+ setSidebarOpen(false);
1857
+ }
1858
+ };
1859
+
1860
+ elements.newChat.addEventListener("click", startNewChat);
1861
+ elements.topbarNewChat.addEventListener("click", startNewChat);
1862
+
1863
+ elements.chatTitle.addEventListener("dblclick", beginTitleEdit);
1864
+
1865
+ elements.prompt.addEventListener("input", () => {
1866
+ autoResizePrompt();
1867
+ });
1868
+
1869
+ elements.prompt.addEventListener("keydown", (event) => {
1870
+ if (event.key === "Enter" && !event.shiftKey) {
1871
+ event.preventDefault();
1872
+ elements.composer.requestSubmit();
1873
+ }
1874
+ });
1875
+
1876
+ elements.sidebarToggle.addEventListener("click", () => {
1877
+ if (isMobile()) setSidebarOpen(!elements.shell.classList.contains("sidebar-open"));
1878
+ });
1879
+
1880
+ elements.sidebarBackdrop.addEventListener("click", () => setSidebarOpen(false));
1881
+
1882
+ elements.logout.addEventListener("click", async () => {
1883
+ await api("/api/auth/logout", { method: "POST" });
1884
+ state.activeConversationId = null;
1885
+ state.activeMessages = [];
1886
+ state.confirmDeleteId = null;
1887
+ state.conversations = [];
1888
+ state.csrfToken = "";
1889
+ state.contextTokens = 0;
1890
+ state.contextWindow = 0;
1891
+ updateContextRing();
1892
+ await requireAuth();
1893
+ });
1894
+
1895
+ elements.composer.addEventListener("submit", async (event) => {
1896
+ event.preventDefault();
1897
+ if (state.isStreaming) {
1898
+ if (!state.activeStreamRunId) {
1899
+ return;
1900
+ }
1901
+ await stopActiveRun();
1902
+ return;
1903
+ }
1904
+ const value = elements.prompt.value;
1905
+ elements.prompt.value = "";
1906
+ autoResizePrompt();
1907
+ await sendMessage(value);
1908
+ });
1909
+
1910
+ elements.attachBtn.addEventListener("click", () => elements.fileInput.click());
1911
+ elements.fileInput.addEventListener("change", () => {
1912
+ if (elements.fileInput.files && elements.fileInput.files.length > 0) {
1913
+ addFiles(elements.fileInput.files);
1914
+ elements.fileInput.value = "";
1915
+ }
1916
+ });
1917
+ elements.attachmentPreview.addEventListener("click", (e) => {
1918
+ const rm = e.target.closest(".remove-attachment");
1919
+ if (rm) {
1920
+ const idx = parseInt(rm.dataset.idx, 10);
1921
+ state.pendingFiles.splice(idx, 1);
1922
+ renderAttachmentPreview();
1923
+ }
1924
+ });
1925
+
1926
+ let dragCounter = 0;
1927
+ document.addEventListener("dragenter", (e) => {
1928
+ e.preventDefault();
1929
+ dragCounter++;
1930
+ if (dragCounter === 1) elements.dragOverlay.classList.add("active");
1931
+ });
1932
+ document.addEventListener("dragleave", (e) => {
1933
+ e.preventDefault();
1934
+ dragCounter--;
1935
+ if (dragCounter <= 0) { dragCounter = 0; elements.dragOverlay.classList.remove("active"); }
1936
+ });
1937
+ document.addEventListener("dragover", (e) => e.preventDefault());
1938
+ document.addEventListener("drop", (e) => {
1939
+ e.preventDefault();
1940
+ dragCounter = 0;
1941
+ elements.dragOverlay.classList.remove("active");
1942
+ if (e.dataTransfer && e.dataTransfer.files.length > 0) {
1943
+ addFiles(e.dataTransfer.files);
1944
+ }
1945
+ });
1946
+
1947
+ // Paste files/images from clipboard
1948
+ elements.prompt.addEventListener("paste", (e) => {
1949
+ const items = e.clipboardData && e.clipboardData.items;
1950
+ if (!items) return;
1951
+ const files = [];
1952
+ for (let i = 0; i < items.length; i++) {
1953
+ if (items[i].kind === "file") {
1954
+ const f = items[i].getAsFile();
1955
+ if (f) files.push(f);
1956
+ }
1957
+ }
1958
+ if (files.length > 0) {
1959
+ e.preventDefault();
1960
+ addFiles(files);
1961
+ }
1962
+ });
1963
+
1964
+ // Lightbox: open/close helpers
1965
+ const lightboxImg = elements.lightbox.querySelector("img");
1966
+ const openLightbox = (src) => {
1967
+ lightboxImg.src = src;
1968
+ elements.lightbox.style.display = "flex";
1969
+ requestAnimationFrame(() => {
1970
+ requestAnimationFrame(() => elements.lightbox.classList.add("active"));
1971
+ });
1972
+ };
1973
+ const closeLightbox = () => {
1974
+ elements.lightbox.classList.remove("active");
1975
+ elements.lightbox.addEventListener("transitionend", function handler() {
1976
+ elements.lightbox.removeEventListener("transitionend", handler);
1977
+ elements.lightbox.style.display = "none";
1978
+ lightboxImg.src = "";
1979
+ });
1980
+ };
1981
+ elements.lightbox.addEventListener("click", closeLightbox);
1982
+ document.addEventListener("keydown", (e) => {
1983
+ if (e.key === "Escape" && elements.lightbox.style.display !== "none") closeLightbox();
1984
+ });
1985
+
1986
+ // Lightbox from message images and tool screenshots
1987
+ elements.messages.addEventListener("click", (e) => {
1988
+ const img = e.target;
1989
+ if (!(img instanceof HTMLImageElement)) return;
1990
+ if (img.closest(".user-file-attachments") || img.classList.contains("tool-screenshot")) {
1991
+ openLightbox(img.src);
1992
+ }
1993
+ });
1994
+
1995
+ // Lightbox from attachment preview chips
1996
+ elements.attachmentPreview.addEventListener("click", (e) => {
1997
+ if (e.target.closest(".remove-attachment")) return;
1998
+ const chip = e.target.closest(".attachment-chip");
1999
+ if (!chip) return;
2000
+ const img = chip.querySelector("img");
2001
+ if (!img) return;
2002
+ e.stopPropagation();
2003
+ openLightbox(img.src);
2004
+ });
2005
+
2006
+ elements.messages.addEventListener("click", async (event) => {
2007
+ const target = event.target;
2008
+ if (!(target instanceof Element)) {
2009
+ return;
2010
+ }
2011
+ const button = target.closest(".approval-action-btn");
2012
+ if (!button) {
2013
+ return;
2014
+ }
2015
+ const approvalId = button.getAttribute("data-approval-id") || "";
2016
+ const decision = button.getAttribute("data-approval-decision") || "";
2017
+ if (!approvalId || (decision !== "approve" && decision !== "deny")) {
2018
+ return;
2019
+ }
2020
+ if (state.approvalRequestsInFlight[approvalId]) {
2021
+ return;
2022
+ }
2023
+ state.approvalRequestsInFlight[approvalId] = true;
2024
+ const wasStreaming = state.isStreaming;
2025
+ if (!wasStreaming) {
2026
+ setStreaming(true);
2027
+ }
2028
+ updatePendingApproval(approvalId, (request) => ({
2029
+ ...request,
2030
+ state: "submitting",
2031
+ pendingDecision: decision,
2032
+ }));
2033
+ renderMessages(state.activeMessages, state.isStreaming);
2034
+ try {
2035
+ await api("/api/approvals/" + encodeURIComponent(approvalId), {
2036
+ method: "POST",
2037
+ body: JSON.stringify({ approved: decision === "approve" }),
2038
+ });
2039
+ updatePendingApproval(approvalId, () => null);
2040
+ renderMessages(state.activeMessages, state.isStreaming);
2041
+ loadConversations();
2042
+ if (!wasStreaming && state.activeConversationId) {
2043
+ await streamConversationEvents(state.activeConversationId, { liveOnly: true });
2044
+ }
2045
+ } catch (error) {
2046
+ const isStale = error && error.payload && error.payload.code === "APPROVAL_NOT_FOUND";
2047
+ if (isStale) {
2048
+ updatePendingApproval(approvalId, () => null);
2049
+ } else {
2050
+ const errMsg = error instanceof Error ? error.message : String(error);
2051
+ updatePendingApproval(approvalId, (request) => ({
2052
+ ...request,
2053
+ state: "pending",
2054
+ pendingDecision: null,
2055
+ _error: errMsg,
2056
+ }));
2057
+ }
2058
+ renderMessages(state.activeMessages, state.isStreaming);
2059
+ } finally {
2060
+ if (!wasStreaming) {
2061
+ setStreaming(false);
2062
+ renderMessages(state.activeMessages, false);
2063
+ }
2064
+ delete state.approvalRequestsInFlight[approvalId];
2065
+ }
2066
+ });
2067
+
2068
+ elements.messages.addEventListener("scroll", () => {
2069
+ state.isMessagesPinnedToBottom = isNearBottom(elements.messages);
2070
+ }, { passive: true });
2071
+
2072
+ document.addEventListener("click", (event) => {
2073
+ if (!(event.target instanceof Node)) {
2074
+ return;
2075
+ }
2076
+ if (!event.target.closest(".conversation-item") && state.confirmDeleteId) {
2077
+ state.confirmDeleteId = null;
2078
+ renderConversationList();
2079
+ }
2080
+ });
2081
+
2082
+ window.addEventListener("resize", () => {
2083
+ setSidebarOpen(false);
2084
+ });
2085
+
2086
+ const navigateToConversation = async (conversationId) => {
2087
+ if (conversationId) {
2088
+ state.activeConversationId = conversationId;
2089
+ renderConversationList();
2090
+ try {
2091
+ await loadConversation(conversationId);
2092
+ } catch {
2093
+ // Conversation not found – fall back to empty state
2094
+ state.activeConversationId = null;
2095
+ state.activeMessages = [];
2096
+ replaceConversationUrl(null);
2097
+ elements.chatTitle.textContent = "";
2098
+ renderMessages([]);
2099
+ renderConversationList();
2100
+ }
2101
+ } else {
2102
+ state.activeConversationId = null;
2103
+ state.activeMessages = [];
2104
+ state.contextTokens = 0;
2105
+ state.contextWindow = 0;
2106
+ updateContextRing();
2107
+ elements.chatTitle.textContent = "";
2108
+ renderMessages([]);
2109
+ renderConversationList();
2110
+ }
2111
+ };
2112
+
2113
+ window.addEventListener("popstate", async () => {
2114
+ if (state.isStreaming) return;
2115
+ const conversationId = getConversationIdFromUrl();
2116
+ await navigateToConversation(conversationId);
2117
+ });
2118
+
2119
+ (async () => {
2120
+ const authenticated = await requireAuth();
2121
+ if (!authenticated) {
2122
+ return;
2123
+ }
2124
+ await loadConversations();
2125
+ const urlConversationId = getConversationIdFromUrl();
2126
+ if (urlConversationId) {
2127
+ state.activeConversationId = urlConversationId;
2128
+ replaceConversationUrl(urlConversationId);
2129
+ renderConversationList();
2130
+ try {
2131
+ await loadConversation(urlConversationId);
2132
+ } catch {
2133
+ // URL pointed to a conversation that no longer exists
2134
+ state.activeConversationId = null;
2135
+ state.activeMessages = [];
2136
+ replaceConversationUrl(null);
2137
+ elements.chatTitle.textContent = "";
2138
+ renderMessages([]);
2139
+ renderConversationList();
2140
+ if (state.conversations.length === 0) {
2141
+ await createConversation();
2142
+ }
2143
+ }
2144
+ } else if (state.conversations.length === 0) {
2145
+ await createConversation();
2146
+ }
2147
+ autoResizePrompt();
2148
+ updateContextRing();
2149
+ elements.prompt.focus();
2150
+ })();
2151
+
2152
+ if ("serviceWorker" in navigator) {
2153
+ navigator.serviceWorker.register("/sw.js").catch(() => {});
2154
+ }
2155
+
2156
+ // Detect iOS standalone mode and add class for CSS targeting
2157
+ if (window.navigator.standalone === true || window.matchMedia("(display-mode: standalone)").matches) {
2158
+ document.documentElement.classList.add("standalone");
2159
+ }
2160
+
2161
+ // iOS viewport and keyboard handling
2162
+ (function() {
2163
+ var shell = document.querySelector(".shell");
2164
+ var pinScroll = function() { if (window.scrollY !== 0) window.scrollTo(0, 0); };
2165
+
2166
+ // Track the "full" height when keyboard is not open
2167
+ var fullHeight = window.innerHeight;
2168
+
2169
+ // Resize shell when iOS keyboard opens/closes
2170
+ var resizeForKeyboard = function() {
2171
+ if (!shell || !window.visualViewport) return;
2172
+ var vvHeight = window.visualViewport.height;
2173
+
2174
+ // Update fullHeight if viewport grew (keyboard closed)
2175
+ if (vvHeight > fullHeight) {
2176
+ fullHeight = vvHeight;
2177
+ }
2178
+
2179
+ // Only apply height override if keyboard appears to be open
2180
+ // (viewport significantly smaller than full height)
2181
+ if (vvHeight < fullHeight - 100) {
2182
+ shell.style.height = vvHeight + "px";
2183
+ } else {
2184
+ // Keyboard closed - remove override, let CSS handle it
2185
+ shell.style.height = "";
2186
+ }
2187
+ pinScroll();
2188
+ };
2189
+
2190
+ if (window.visualViewport) {
2191
+ window.visualViewport.addEventListener("scroll", pinScroll);
2192
+ window.visualViewport.addEventListener("resize", resizeForKeyboard);
2193
+ }
2194
+ document.addEventListener("scroll", pinScroll);
2195
+
2196
+ // Draggable sidebar from left edge (mobile only)
2197
+ (function() {
2198
+ var sidebar = document.querySelector(".sidebar");
2199
+ var backdrop = document.querySelector(".sidebar-backdrop");
2200
+ var shell = document.querySelector(".shell");
2201
+ if (!sidebar || !backdrop || !shell) return;
2202
+
2203
+ var sidebarWidth = 260;
2204
+ var edgeThreshold = 50; // px from left edge to start drag
2205
+ var velocityThreshold = 0.3; // px/ms to trigger open/close
2206
+
2207
+ var dragging = false;
2208
+ var startX = 0;
2209
+ var startY = 0;
2210
+ var currentX = 0;
2211
+ var startTime = 0;
2212
+ var isOpen = false;
2213
+ var directionLocked = false;
2214
+ var isHorizontal = false;
2215
+
2216
+ function getProgress() {
2217
+ // Returns 0 (closed) to 1 (open)
2218
+ if (isOpen) {
2219
+ return Math.max(0, Math.min(1, 1 + currentX / sidebarWidth));
2220
+ } else {
2221
+ return Math.max(0, Math.min(1, currentX / sidebarWidth));
2222
+ }
2223
+ }
2224
+
2225
+ function updatePosition(progress) {
2226
+ var offset = (progress - 1) * sidebarWidth;
2227
+ sidebar.style.transform = "translateX(" + offset + "px)";
2228
+ backdrop.style.opacity = progress;
2229
+ if (progress > 0) {
2230
+ backdrop.style.pointerEvents = "auto";
2231
+ } else {
2232
+ backdrop.style.pointerEvents = "none";
2233
+ }
2234
+ }
2235
+
2236
+ function onTouchStart(e) {
2237
+ if (window.innerWidth > 768) return;
2238
+
2239
+ // Don't intercept touches on interactive elements
2240
+ var target = e.target;
2241
+ if (target.closest("button") || target.closest("a") || target.closest("input") || target.closest("textarea")) {
2242
+ return;
2243
+ }
2244
+
2245
+ var touch = e.touches[0];
2246
+ isOpen = shell.classList.contains("sidebar-open");
2247
+
2248
+ // When sidebar is closed: only respond to edge swipes
2249
+ // When sidebar is open: only respond to backdrop touches (not sidebar content)
2250
+ var fromEdge = touch.clientX < edgeThreshold;
2251
+ var onBackdrop = e.target === backdrop;
2252
+
2253
+ if (!isOpen && !fromEdge) return;
2254
+ if (isOpen && !onBackdrop) return;
2255
+
2256
+ // Prevent Safari back gesture when starting from edge
2257
+ if (fromEdge) {
2258
+ e.preventDefault();
2259
+ }
2260
+
2261
+ startX = touch.clientX;
2262
+ startY = touch.clientY;
2263
+ currentX = 0;
2264
+ startTime = Date.now();
2265
+ directionLocked = false;
2266
+ isHorizontal = false;
2267
+ dragging = true;
2268
+ sidebar.classList.add("dragging");
2269
+ backdrop.classList.add("dragging");
2270
+ }
2271
+
2272
+ function onTouchMove(e) {
2273
+ if (!dragging) return;
2274
+ var touch = e.touches[0];
2275
+ var dx = touch.clientX - startX;
2276
+ var dy = touch.clientY - startY;
2277
+
2278
+ // Lock direction after some movement
2279
+ if (!directionLocked && (Math.abs(dx) > 10 || Math.abs(dy) > 10)) {
2280
+ directionLocked = true;
2281
+ isHorizontal = Math.abs(dx) > Math.abs(dy);
2282
+ if (!isHorizontal) {
2283
+ // Vertical scroll, cancel drag
2284
+ dragging = false;
2285
+ sidebar.classList.remove("dragging");
2286
+ backdrop.classList.remove("dragging");
2287
+ return;
2288
+ }
2289
+ }
2290
+
2291
+ if (!directionLocked) return;
2292
+
2293
+ // Prevent scrolling while dragging sidebar
2294
+ e.preventDefault();
2295
+
2296
+ currentX = dx;
2297
+ updatePosition(getProgress());
2298
+ }
2299
+
2300
+ function onTouchEnd(e) {
2301
+ if (!dragging) return;
2302
+ dragging = false;
2303
+ sidebar.classList.remove("dragging");
2304
+ backdrop.classList.remove("dragging");
2305
+
2306
+ var touch = e.changedTouches[0];
2307
+ var dx = touch.clientX - startX;
2308
+ var dt = Date.now() - startTime;
2309
+ var velocity = dx / dt; // px/ms
2310
+
2311
+ var progress = getProgress();
2312
+ var shouldOpen;
2313
+
2314
+ // Use velocity if fast enough, otherwise use position threshold
2315
+ if (Math.abs(velocity) > velocityThreshold) {
2316
+ shouldOpen = velocity > 0;
2317
+ } else {
2318
+ shouldOpen = progress > 0.5;
2319
+ }
2320
+
2321
+ // Reset inline styles and let CSS handle the animation
2322
+ sidebar.style.transform = "";
2323
+ backdrop.style.opacity = "";
2324
+ backdrop.style.pointerEvents = "";
2325
+
2326
+ if (shouldOpen) {
2327
+ shell.classList.add("sidebar-open");
2328
+ } else {
2329
+ shell.classList.remove("sidebar-open");
2330
+ }
2331
+ }
2332
+
2333
+ document.addEventListener("touchstart", onTouchStart, { passive: false });
2334
+ document.addEventListener("touchmove", onTouchMove, { passive: false });
2335
+ document.addEventListener("touchend", onTouchEnd, { passive: true });
2336
+ document.addEventListener("touchcancel", onTouchEnd, { passive: true });
2337
+ })();
2338
+
2339
+ // Prevent Safari back/forward navigation by manipulating history
2340
+ // This doesn't stop the gesture animation but prevents actual navigation
2341
+ if (window.navigator.standalone || window.matchMedia("(display-mode: standalone)").matches) {
2342
+ history.pushState(null, "", location.href);
2343
+ window.addEventListener("popstate", function() {
2344
+ history.pushState(null, "", location.href);
2345
+ });
2346
+ }
2347
+
2348
+ // Right edge blocker - intercept touch events to prevent forward navigation
2349
+ var rightBlocker = document.querySelector(".edge-blocker-right");
2350
+ if (rightBlocker) {
2351
+ rightBlocker.addEventListener("touchstart", function(e) {
2352
+ e.preventDefault();
2353
+ }, { passive: false });
2354
+ rightBlocker.addEventListener("touchmove", function(e) {
2355
+ e.preventDefault();
2356
+ }, { passive: false });
2357
+ }
2358
+
2359
+ // Browser viewport panel
2360
+ (function initBrowserPanel() {
2361
+ var panel = elements.browserPanel;
2362
+ var frameImg = elements.browserPanelFrame;
2363
+ var urlLabel = elements.browserPanelUrl;
2364
+ var placeholder = elements.browserPanelPlaceholder;
2365
+ var closeBtn = elements.browserPanelClose;
2366
+ if (!panel || !frameImg) return;
2367
+
2368
+ var resizeHandle = elements.browserPanelResize;
2369
+ var mainEl = document.querySelector(".main-chat");
2370
+ var abortController = null;
2371
+ var panelHiddenByUser = false;
2372
+
2373
+ var showPanel = function(show) {
2374
+ var visible = show && !panelHiddenByUser;
2375
+ panel.style.display = visible ? "flex" : "none";
2376
+ if (resizeHandle) resizeHandle.style.display = visible ? "block" : "none";
2377
+ if (mainEl) {
2378
+ if (visible) mainEl.classList.add("has-browser");
2379
+ else mainEl.classList.remove("has-browser");
2380
+ }
2381
+ };
2382
+
2383
+
2384
+ closeBtn && closeBtn.addEventListener("click", function() {
2385
+ panelHiddenByUser = true;
2386
+ showPanel(false);
2387
+ });
2388
+
2389
+ var navBack = elements.browserNavBack;
2390
+ var navFwd = elements.browserNavForward;
2391
+ var sendBrowserNav = function(action) {
2392
+ var headers = { "Content-Type": "application/json" };
2393
+ if (state.csrfToken) headers["x-csrf-token"] = state.csrfToken;
2394
+ if (state.authToken) headers["authorization"] = "Bearer " + state.authToken;
2395
+ fetch("/api/browser/navigate", {
2396
+ method: "POST",
2397
+ headers: headers,
2398
+ body: JSON.stringify({ action: action, conversationId: state.activeConversationId }),
2399
+ });
2400
+ };
2401
+ navBack && navBack.addEventListener("click", function() { sendBrowserNav("back"); });
2402
+ navFwd && navFwd.addEventListener("click", function() { sendBrowserNav("forward"); });
2403
+
2404
+ window._resetBrowserPanel = function() {
2405
+ if (abortController) { abortController.abort(); abortController = null; }
2406
+ streamConversationId = null;
2407
+ panelHiddenByUser = false;
2408
+ showPanel(false);
2409
+ frameImg.style.display = "none";
2410
+ if (placeholder) {
2411
+ placeholder.textContent = "No active browser session";
2412
+ placeholder.style.display = "flex";
2413
+ }
2414
+ if (urlLabel) urlLabel.textContent = "";
2415
+ if (navBack) navBack.disabled = true;
2416
+ if (navFwd) navFwd.disabled = true;
2417
+ var headers = {};
2418
+ if (state.csrfToken) headers["x-csrf-token"] = state.csrfToken;
2419
+ if (state.authToken) headers["authorization"] = "Bearer " + state.authToken;
2420
+ var cid = state.activeConversationId;
2421
+ if (!cid) return;
2422
+ fetch("/api/browser/status?conversationId=" + encodeURIComponent(cid), { headers: headers }).then(function(r) {
2423
+ return r.json();
2424
+ }).then(function(s) {
2425
+ if (s && s.active && cid === state.activeConversationId) {
2426
+ if (urlLabel && s.url) urlLabel.textContent = s.url;
2427
+ if (navBack) navBack.disabled = false;
2428
+ if (navFwd) navFwd.disabled = false;
2429
+ connectBrowserStream();
2430
+ showPanel(true);
2431
+ }
2432
+ }).catch(function() {});
2433
+ };
2434
+
2435
+ // Drag-to-resize between conversation and browser panel
2436
+ if (resizeHandle && mainEl) {
2437
+ var dragging = false;
2438
+ resizeHandle.addEventListener("mousedown", function(e) {
2439
+ e.preventDefault();
2440
+ dragging = true;
2441
+ resizeHandle.classList.add("dragging");
2442
+ document.body.style.cursor = "col-resize";
2443
+ document.body.style.userSelect = "none";
2444
+ });
2445
+ document.addEventListener("mousemove", function(e) {
2446
+ if (!dragging) return;
2447
+ var body = mainEl.parentElement;
2448
+ if (!body) return;
2449
+ var bodyRect = body.getBoundingClientRect();
2450
+ var available = bodyRect.width - 1;
2451
+ var chatW = e.clientX - bodyRect.left;
2452
+ chatW = Math.max(280, Math.min(chatW, available - 280));
2453
+ var browserW = available - chatW;
2454
+ mainEl.style.flex = "0 0 " + chatW + "px";
2455
+ panel.style.flex = "0 0 " + browserW + "px";
2456
+ });
2457
+ document.addEventListener("mouseup", function() {
2458
+ if (!dragging) return;
2459
+ dragging = false;
2460
+ resizeHandle.classList.remove("dragging");
2461
+ document.body.style.cursor = "";
2462
+ document.body.style.userSelect = "";
2463
+ });
2464
+ }
2465
+
2466
+ // --- Browser viewport interaction ---
2467
+ var browserViewportW = 1280;
2468
+ var browserViewportH = 720;
2469
+ var sendBrowserInput = function(kind, event) {
2470
+ var headers = { "Content-Type": "application/json" };
2471
+ if (state.csrfToken) headers["x-csrf-token"] = state.csrfToken;
2472
+ if (state.authToken) headers["authorization"] = "Bearer " + state.authToken;
2473
+ fetch("/api/browser/input", {
2474
+ method: "POST",
2475
+ headers: headers,
2476
+ body: JSON.stringify({ kind: kind, event: event, conversationId: state.activeConversationId }),
2477
+ }).catch(function() {});
2478
+ };
2479
+ var toViewportCoords = function(e) {
2480
+ var rect = frameImg.getBoundingClientRect();
2481
+ var scaleX = browserViewportW / rect.width;
2482
+ var scaleY = browserViewportH / rect.height;
2483
+ return { x: Math.round((e.clientX - rect.left) * scaleX), y: Math.round((e.clientY - rect.top) * scaleY) };
2484
+ };
2485
+
2486
+ frameImg.style.cursor = "default";
2487
+ frameImg.setAttribute("tabindex", "0");
2488
+
2489
+ frameImg.addEventListener("click", function(e) {
2490
+ var coords = toViewportCoords(e);
2491
+ sendBrowserInput("mouse", { type: "mousePressed", x: coords.x, y: coords.y, button: "left", clickCount: 1 });
2492
+ setTimeout(function() {
2493
+ sendBrowserInput("mouse", { type: "mouseReleased", x: coords.x, y: coords.y, button: "left", clickCount: 1 });
2494
+ }, 50);
2495
+ frameImg.focus();
2496
+ });
2497
+
2498
+ frameImg.addEventListener("wheel", function(e) {
2499
+ e.preventDefault();
2500
+ var coords = toViewportCoords(e);
2501
+ sendBrowserInput("scroll", { deltaX: e.deltaX, deltaY: e.deltaY, x: coords.x, y: coords.y });
2502
+ }, { passive: false });
2503
+
2504
+ frameImg.addEventListener("keydown", function(e) {
2505
+ e.preventDefault();
2506
+ e.stopPropagation();
2507
+ if ((e.metaKey || e.ctrlKey) && e.key === "v") {
2508
+ navigator.clipboard.readText().then(function(clip) {
2509
+ if (clip) sendBrowserInput("paste", { text: clip });
2510
+ }).catch(function() {});
2511
+ return;
2512
+ }
2513
+ var text = e.key.length === 1 ? e.key : undefined;
2514
+ sendBrowserInput("keyboard", { type: "keyDown", key: e.key, code: e.code, text: text, keyCode: e.keyCode });
2515
+ });
2516
+ frameImg.addEventListener("keyup", function(e) {
2517
+ e.preventDefault();
2518
+ e.stopPropagation();
2519
+ sendBrowserInput("keyboard", { type: "keyUp", key: e.key, code: e.code, keyCode: e.keyCode });
2520
+ });
2521
+
2522
+ var streamConversationId = null;
2523
+
2524
+ var connectBrowserStream = function() {
2525
+ var cid = state.activeConversationId;
2526
+ if (!cid) return;
2527
+ if (streamConversationId === cid && abortController) return;
2528
+ if (abortController) abortController.abort();
2529
+ streamConversationId = cid;
2530
+ abortController = new AbortController();
2531
+ var headers = {};
2532
+ if (state.csrfToken) headers["x-csrf-token"] = state.csrfToken;
2533
+ if (state.authToken) headers["authorization"] = "Bearer " + state.authToken;
2534
+ fetch("/api/browser/stream?conversationId=" + encodeURIComponent(cid), { headers: headers, signal: abortController.signal })
2535
+ .then(function(res) {
2536
+ if (!res.ok || !res.body) return;
2537
+ var reader = res.body.getReader();
2538
+ var decoder = new TextDecoder();
2539
+ var buf = "";
2540
+ var sseEvent = "";
2541
+ var sseData = "";
2542
+ var pump = function() {
2543
+ reader.read().then(function(result) {
2544
+ if (result.done) return;
2545
+ buf += decoder.decode(result.value, { stream: true });
2546
+ var lines = buf.split("\\n");
2547
+ buf = lines.pop() || "";
2548
+ var eventName = sseEvent;
2549
+ var data = sseData;
2550
+ for (var i = 0; i < lines.length; i++) {
2551
+ var line = lines[i];
2552
+ if (line.startsWith("event: ")) {
2553
+ eventName = line.slice(7).trim();
2554
+ } else if (line.startsWith("data: ")) {
2555
+ data += line.slice(6);
2556
+ } else if (line === "" && eventName && data) {
2557
+ try {
2558
+ var payload = JSON.parse(data);
2559
+ if (streamConversationId !== state.activeConversationId) { eventName = ""; data = ""; continue; }
2560
+ if (eventName === "browser:frame") {
2561
+ frameImg.src = "data:image/jpeg;base64," + payload.data;
2562
+ frameImg.style.display = "block";
2563
+ if (payload.width) browserViewportW = payload.width;
2564
+ if (payload.height) browserViewportH = payload.height;
2565
+ if (placeholder) placeholder.style.display = "none";
2566
+ showPanel(true);
2567
+ void panel.offsetHeight;
2568
+ }
2569
+ if (eventName === "browser:status") {
2570
+ if (payload.url && urlLabel) urlLabel.textContent = payload.url;
2571
+ if (navBack) navBack.disabled = !payload.active;
2572
+ if (navFwd) navFwd.disabled = !payload.active;
2573
+ if (payload.active) {
2574
+ showPanel(true);
2575
+ } else {
2576
+ if (abortController) { abortController.abort(); abortController = null; }
2577
+ streamConversationId = null;
2578
+ frameImg.style.display = "none";
2579
+ if (placeholder) {
2580
+ placeholder.textContent = "Browser closed";
2581
+ placeholder.style.display = "flex";
2582
+ }
2583
+ showPanel(false);
2584
+ }
2585
+ }
2586
+ } catch(e) {}
2587
+ eventName = "";
2588
+ data = "";
2589
+ }
2590
+ }
2591
+ sseEvent = eventName;
2592
+ sseData = data;
2593
+ pump();
2594
+ }).catch(function() {});
2595
+ };
2596
+ pump();
2597
+ })
2598
+ .catch(function() {});
2599
+ };
2600
+
2601
+ window._connectBrowserStream = connectBrowserStream;
2602
+ })();
2603
+ })();
2604
+
2605
+ `;