@poncho-ai/cli 0.36.9 → 0.38.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.
@@ -53,6 +53,18 @@ export const getWebUiClientScript = (markedSource: string): string => `
53
53
  subagentPollInFlight: {},
54
54
  slashCommands: null,
55
55
  slashMenuIndex: 0,
56
+ threadsByParent: {},
57
+ confirmDeleteThreadId: null,
58
+ threadPanel: {
59
+ open: false,
60
+ threadId: null,
61
+ parentMessageId: null,
62
+ parentMessage: null,
63
+ messages: [],
64
+ isStreaming: false,
65
+ abortController: null,
66
+ pendingFiles: [],
67
+ },
56
68
  };
57
69
 
58
70
  const agentInitial = document.body.dataset.agentInitial || "A";
@@ -91,6 +103,17 @@ export const getWebUiClientScript = (markedSource: string): string => `
91
103
  browserPanelClose: $("browser-panel-close"),
92
104
  browserNavBack: $("browser-nav-back"),
93
105
  browserNavForward: $("browser-nav-forward"),
106
+ threadPanel: $("thread-panel"),
107
+ threadPanelResize: $("thread-panel-resize"),
108
+ threadPanelClose: $("thread-panel-close"),
109
+ threadPanelParent: $("thread-panel-parent"),
110
+ threadPanelMessages: $("thread-panel-messages"),
111
+ threadComposer: $("thread-composer"),
112
+ threadAttachBtn: $("thread-attach-btn"),
113
+ threadFileInput: $("thread-file-input"),
114
+ threadAttachmentPreview: $("thread-attachment-preview"),
115
+ threadPrompt: $("thread-prompt"),
116
+ threadSend: $("thread-send"),
94
117
  };
95
118
  const sendIconMarkup =
96
119
  '<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>';
@@ -1186,33 +1209,559 @@ export const getWebUiClientScript = (markedSource: string): string => `
1186
1209
  );
1187
1210
  };
1188
1211
 
1212
+ const formatRelativeTime = (ts) => {
1213
+ if (!ts) return "";
1214
+ const diff = Math.max(0, Date.now() - ts);
1215
+ if (diff < 60_000) return "just now";
1216
+ if (diff < 3_600_000) return Math.floor(diff / 60_000) + "m ago";
1217
+ if (diff < 86_400_000) return Math.floor(diff / 3_600_000) + "h ago";
1218
+ return Math.floor(diff / 86_400_000) + "d ago";
1219
+ };
1220
+
1221
+ const REPLY_ICON_SVG = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12V8a4 4 0 0 1 4-4h6"/><path d="M10 1l3 3-3 3"/></svg>';
1222
+
1223
+ const isThreadAffordanceCandidate = (m) => {
1224
+ if (!m || !m.metadata || typeof m.metadata.id !== "string") return false;
1225
+ if (m.role === "system") return false;
1226
+ if (m.metadata.isCompactionSummary) return false;
1227
+ if (m.metadata._subagentCallback) return false;
1228
+ return true;
1229
+ };
1230
+
1231
+ const deleteThreadConfirmed = (threadId, parentMessageId) => {
1232
+ if (!threadId) return;
1233
+ // Optimistic removal — fire DELETE in the background, mirrors the
1234
+ // sidebar conversation-delete pattern at line ~798.
1235
+ if (parentMessageId && state.threadsByParent[parentMessageId]) {
1236
+ state.threadsByParent[parentMessageId] = state.threadsByParent[parentMessageId]
1237
+ .filter((t) => t.conversationId !== threadId);
1238
+ if (state.threadsByParent[parentMessageId].length === 0) {
1239
+ delete state.threadsByParent[parentMessageId];
1240
+ }
1241
+ }
1242
+ if (state.threadPanel.threadId === threadId) {
1243
+ closeThreadPanel();
1244
+ }
1245
+ state.confirmDeleteThreadId = null;
1246
+ renderMessages(state.activeMessages, state.isStreaming);
1247
+ api(
1248
+ "/api/conversations/" + encodeURIComponent(threadId),
1249
+ { method: "DELETE" },
1250
+ ).catch(() => {});
1251
+ };
1252
+
1253
+ // Single absolute-positioned wrap on each message row. Behaves as:
1254
+ // - no threads: hover-only "Reply in thread" pill (creates a thread)
1255
+ // - >=1 thread: always-visible badge per thread (opens that thread)
1256
+ // Position (bottom-offset overlapping the message) is identical in both
1257
+ // states so the badge sits exactly where the hover pill would.
1258
+ const appendThreadAffordances = (row, m) => {
1259
+ if (!isThreadAffordanceCandidate(m)) return;
1260
+ const messageId = m.metadata.id;
1261
+ const threads = state.threadsByParent[messageId] || [];
1262
+ const wrap = document.createElement("span");
1263
+ wrap.className = "reply-pill-wrap" + (threads.length > 0 ? " has-threads" : "");
1264
+
1265
+ if (threads.length === 0) {
1266
+ const btn = document.createElement("button");
1267
+ btn.type = "button";
1268
+ btn.className = "reply-icon-btn";
1269
+ btn.title = "Reply in thread";
1270
+ btn.innerHTML = REPLY_ICON_SVG + '<span>Reply in thread</span>';
1271
+ btn.addEventListener("click", (e) => {
1272
+ e.stopPropagation();
1273
+ createAndOpenNewThread(messageId);
1274
+ });
1275
+ wrap.appendChild(btn);
1276
+ row.appendChild(wrap);
1277
+ return;
1278
+ }
1279
+
1280
+ threads.forEach((t) => {
1281
+ const pair = document.createElement("span");
1282
+ pair.className = "thread-pill-pair";
1283
+ const pill = document.createElement("button");
1284
+ pill.type = "button";
1285
+ pill.className = "reply-icon-btn thread-pill";
1286
+ pill.title = "Open thread";
1287
+ const replies = t.replyCount || 0;
1288
+ const repliesLabel = replies === 1 ? "1 reply" : replies + " replies";
1289
+ const meta = t.lastReplyAt ? formatRelativeTime(t.lastReplyAt) : "";
1290
+ pill.innerHTML = '<span class="thread-pill-count">' + repliesLabel + '</span>'
1291
+ + (meta ? '<span class="thread-pill-meta">' + meta + '</span>' : '');
1292
+ pill.addEventListener("click", (e) => {
1293
+ e.stopPropagation();
1294
+ openThread(t.conversationId, t);
1295
+ });
1296
+ pair.appendChild(pill);
1297
+
1298
+ const isConfirming = state.confirmDeleteThreadId === t.conversationId;
1299
+ const del = document.createElement("button");
1300
+ del.type = "button";
1301
+ del.className = "thread-row-delete" + (isConfirming ? " confirming" : "");
1302
+ del.title = isConfirming ? "Click again to confirm" : "Delete thread";
1303
+ del.textContent = isConfirming ? "sure?" : "×";
1304
+ del.addEventListener("click", (e) => {
1305
+ e.stopPropagation();
1306
+ if (!isConfirming) {
1307
+ state.confirmDeleteThreadId = t.conversationId;
1308
+ renderMessages(state.activeMessages, state.isStreaming);
1309
+ return;
1310
+ }
1311
+ deleteThreadConfirmed(t.conversationId, messageId);
1312
+ });
1313
+ pair.appendChild(del);
1314
+ wrap.appendChild(pair);
1315
+ });
1316
+ row.appendChild(wrap);
1317
+ };
1318
+
1319
+ // Hoisted so both renderMessages and buildSimpleMessageRow can use it.
1320
+ const createThinkingIndicator = (label) => {
1321
+ const status = document.createElement("div");
1322
+ status.className = "thinking-status";
1323
+ const spinner = document.createElement("span");
1324
+ spinner.className = "thinking-indicator";
1325
+ const starFrames = ["✶", "✸", "✹", "✺", "✹", "✷"];
1326
+ let frame = 0;
1327
+ spinner.textContent = starFrames[0];
1328
+ spinner._interval = setInterval(() => {
1329
+ frame = (frame + 1) % starFrames.length;
1330
+ spinner.textContent = starFrames[frame];
1331
+ }, 70);
1332
+ status.appendChild(spinner);
1333
+ if (label) {
1334
+ const text = document.createElement("span");
1335
+ text.className = "thinking-status-label";
1336
+ text.textContent = label;
1337
+ status.appendChild(text);
1338
+ }
1339
+ return status;
1340
+ };
1341
+
1342
+ // Render a single message into a target column. Mirrors the assistant /
1343
+ // user branches of renderMessages but without the streaming-specific bits.
1344
+ const buildSimpleMessageRow = (m) => {
1345
+ const r = document.createElement("div");
1346
+ r.className = "message-row " + m.role;
1347
+ if (m.role === "assistant") {
1348
+ const wrap = document.createElement("div");
1349
+ wrap.className = "assistant-wrap";
1350
+ wrap.innerHTML = '<div class="assistant-avatar">' + agentInitial + '</div>';
1351
+ const content = document.createElement("div");
1352
+ content.className = "assistant-content";
1353
+ const sections = (m.metadata && m.metadata.sections) || null;
1354
+ const text = typeof m.content === "string" ? m.content : "";
1355
+ if (sections && sections.length > 0) {
1356
+ sections.forEach((section) => {
1357
+ if (section.type === "text") {
1358
+ const textDiv = document.createElement("div");
1359
+ textDiv.innerHTML = renderAssistantMarkdown(section.content);
1360
+ content.appendChild(textDiv);
1361
+ } else if (section.type === "tools") {
1362
+ content.insertAdjacentHTML(
1363
+ "beforeend",
1364
+ renderToolActivity(section.content, [], []),
1365
+ );
1366
+ }
1367
+ });
1368
+ } else if (m._streaming && !text) {
1369
+ // Empty + streaming → show a thinking indicator until the first
1370
+ // model:chunk lands.
1371
+ content.appendChild(createThinkingIndicator(""));
1372
+ } else {
1373
+ content.innerHTML = renderAssistantMarkdown(text);
1374
+ }
1375
+ wrap.appendChild(content);
1376
+ r.appendChild(wrap);
1377
+ } else {
1378
+ const bubble = document.createElement("div");
1379
+ bubble.className = "user-bubble";
1380
+ if (typeof m.content === "string") {
1381
+ bubble.textContent = m.content;
1382
+ } else if (Array.isArray(m.content)) {
1383
+ const textParts = m.content.filter((p) => p.type === "text").map((p) => p.text).join("");
1384
+ if (textParts) {
1385
+ const textEl = document.createElement("div");
1386
+ textEl.textContent = textParts;
1387
+ bubble.appendChild(textEl);
1388
+ }
1389
+ // File attachments — same logic as the main renderer
1390
+ const fileParts = m.content.filter((p) => p.type === "file");
1391
+ if (fileParts.length > 0) {
1392
+ const filesEl = document.createElement("div");
1393
+ filesEl.className = "user-file-attachments";
1394
+ fileParts.forEach((fp) => {
1395
+ if (fp.mediaType && fp.mediaType.startsWith("image/")) {
1396
+ const img = document.createElement("img");
1397
+ if (fp.data && fp.data.startsWith("poncho-upload://")) {
1398
+ img.src = "/api/uploads/" + encodeURIComponent(fp.data.replace("poncho-upload://", ""));
1399
+ } else if (fp.data && (fp.data.startsWith("http://") || fp.data.startsWith("https://"))) {
1400
+ img.src = fp.data;
1401
+ } else if (fp.data) {
1402
+ img.src = "data:" + fp.mediaType + ";base64," + fp.data;
1403
+ }
1404
+ img.alt = fp.filename || "image";
1405
+ filesEl.appendChild(img);
1406
+ } else {
1407
+ const badge = document.createElement("span");
1408
+ badge.className = "user-file-badge";
1409
+ badge.textContent = "📎 " + (fp.filename || "file");
1410
+ filesEl.appendChild(badge);
1411
+ }
1412
+ });
1413
+ bubble.appendChild(filesEl);
1414
+ }
1415
+ }
1416
+ r.appendChild(bubble);
1417
+ }
1418
+ return r;
1419
+ };
1420
+
1421
+ const renderThreadPanelMessages = () => {
1422
+ const root = elements.threadPanelMessages;
1423
+ if (!root) return;
1424
+ root.innerHTML = "";
1425
+ const msgs = state.threadPanel.messages || [];
1426
+ if (msgs.length === 0) {
1427
+ const empty = document.createElement("div");
1428
+ empty.className = "thread-panel-parent-empty";
1429
+ empty.textContent = "No replies yet — send the first one below.";
1430
+ root.appendChild(empty);
1431
+ } else {
1432
+ const col = document.createElement("div");
1433
+ col.className = "messages-column";
1434
+ msgs.forEach((m) => col.appendChild(buildSimpleMessageRow(m)));
1435
+ root.appendChild(col);
1436
+ }
1437
+ root.scrollTop = root.scrollHeight;
1438
+ };
1439
+
1440
+ const renderThreadPanelParent = () => {
1441
+ const root = elements.threadPanelParent;
1442
+ if (!root) return;
1443
+ root.innerHTML = "";
1444
+ const parent = state.threadPanel.parentMessage;
1445
+ if (!parent) {
1446
+ const empty = document.createElement("div");
1447
+ empty.className = "thread-panel-parent-empty";
1448
+ empty.textContent = "No parent context";
1449
+ root.appendChild(empty);
1450
+ return;
1451
+ }
1452
+ const col = document.createElement("div");
1453
+ col.className = "messages-column";
1454
+ col.appendChild(buildSimpleMessageRow(parent));
1455
+ root.appendChild(col);
1456
+ };
1457
+
1458
+ const closeThreadPanel = () => {
1459
+ if (state.threadPanel.abortController) {
1460
+ try { state.threadPanel.abortController.abort(); } catch (e) {}
1461
+ }
1462
+ state.threadPanel.open = false;
1463
+ state.threadPanel.threadId = null;
1464
+ state.threadPanel.parentMessageId = null;
1465
+ state.threadPanel.parentMessage = null;
1466
+ state.threadPanel.messages = [];
1467
+ state.threadPanel.isStreaming = false;
1468
+ state.threadPanel.abortController = null;
1469
+ state.threadPanel.pendingFiles = [];
1470
+ renderThreadAttachmentPreview();
1471
+ if (elements.threadPrompt) elements.threadPrompt.value = "";
1472
+ if (elements.threadPanel) {
1473
+ elements.threadPanel.style.display = "none";
1474
+ // Clear inline flex set by drag-resize so next open starts fresh.
1475
+ elements.threadPanel.style.flex = "";
1476
+ }
1477
+ if (elements.threadPanelResize) elements.threadPanelResize.style.display = "none";
1478
+ const mainEl = document.querySelector(".main-chat");
1479
+ if (mainEl) {
1480
+ mainEl.classList.remove("has-thread");
1481
+ // Same fix on the main pane.
1482
+ mainEl.style.flex = "";
1483
+ }
1484
+ try {
1485
+ if (window.location.hash.indexOf("thread=") >= 0) {
1486
+ history.replaceState(null, "", window.location.pathname + window.location.search);
1487
+ }
1488
+ } catch (e) {}
1489
+ };
1490
+
1491
+ const renderActiveTopForThreadPanel = (payload) => {
1492
+ const conv = payload.conversation || {};
1493
+ const allMsgs = Array.isArray(conv.messages) ? conv.messages : [];
1494
+ const snapshotLength = (conv.threadMeta && typeof conv.threadMeta.snapshotLength === "number")
1495
+ ? conv.threadMeta.snapshotLength
1496
+ : allMsgs.length;
1497
+ const parent = allMsgs[snapshotLength - 1] || null;
1498
+ const replies = allMsgs.slice(snapshotLength);
1499
+ state.threadPanel.parentMessage = parent;
1500
+ state.threadPanel.messages = replies;
1501
+ renderThreadPanelParent();
1502
+ renderThreadPanelMessages();
1503
+ };
1504
+
1505
+ const buildAuthHeaders = () => {
1506
+ const headers = {};
1507
+ if (state.tenantToken) {
1508
+ headers["Authorization"] = "Bearer " + state.tenantToken;
1509
+ } else if (state.csrfToken) {
1510
+ headers["x-csrf-token"] = state.csrfToken;
1511
+ }
1512
+ return headers;
1513
+ };
1514
+
1515
+ const subscribeThreadPanelStream = (threadId) => {
1516
+ // Scoped, duplicated SSE handler — independent of the main-pane
1517
+ // subscription so a regression here can't break the main conversation.
1518
+ if (state.threadPanel.abortController) {
1519
+ try { state.threadPanel.abortController.abort(); } catch (e) {}
1520
+ }
1521
+ const ac = new AbortController();
1522
+ state.threadPanel.abortController = ac;
1523
+ const url = "/api/conversations/" + encodeURIComponent(threadId) + "/events?live_only=true";
1524
+ fetch(url, {
1525
+ headers: buildAuthHeaders(),
1526
+ signal: ac.signal,
1527
+ credentials: state.tenantToken ? "omit" : "include",
1528
+ }).then(async (resp) => {
1529
+ if (!resp.ok || !resp.body) return;
1530
+ const reader = resp.body.getReader();
1531
+ const decoder = new TextDecoder();
1532
+ let buf = "";
1533
+ while (true) {
1534
+ const { value, done } = await reader.read();
1535
+ if (done) break;
1536
+ buf += decoder.decode(value, { stream: true });
1537
+ const events = buf.split("\\n\\n");
1538
+ buf = events.pop() || "";
1539
+ for (const block of events) {
1540
+ if (!block) continue;
1541
+ const dataLine = block.split("\\n").find((l) => l.startsWith("data: "));
1542
+ if (!dataLine) continue;
1543
+ if (state.threadPanel.threadId !== threadId) continue;
1544
+ try {
1545
+ const evt = JSON.parse(dataLine.slice(6));
1546
+ if (evt.type === "run:completed" || evt.type === "messages:updated" || evt.type === "messages:appended" || evt.type === "run:cancelled") {
1547
+ const fresh = await api("/api/conversations/" + encodeURIComponent(threadId)).catch(() => null);
1548
+ if (fresh && state.threadPanel.threadId === threadId) {
1549
+ renderActiveTopForThreadPanel(fresh);
1550
+ }
1551
+ }
1552
+ } catch (e) { /* ignore parse errors */ }
1553
+ }
1554
+ }
1555
+ }).catch(() => { /* aborted or failed; no-op */ });
1556
+ };
1557
+
1558
+ const openThread = async (threadId, summary) => {
1559
+ try {
1560
+ const payload = await api("/api/conversations/" + encodeURIComponent(threadId));
1561
+ state.threadPanel.open = true;
1562
+ state.threadPanel.threadId = threadId;
1563
+ state.threadPanel.parentMessageId = (summary && summary.parentMessageId) || null;
1564
+ renderActiveTopForThreadPanel(payload);
1565
+ if (elements.threadPanel) elements.threadPanel.style.display = "flex";
1566
+ if (elements.threadPanelResize) elements.threadPanelResize.style.display = "block";
1567
+ const mainEl = document.querySelector(".main-chat");
1568
+ if (mainEl) mainEl.classList.add("has-thread");
1569
+ if (elements.threadPrompt) elements.threadPrompt.focus();
1570
+ try {
1571
+ history.replaceState(null, "", window.location.pathname + window.location.search + "#thread=" + encodeURIComponent(threadId));
1572
+ } catch (e) {}
1573
+ subscribeThreadPanelStream(threadId);
1574
+ } catch (e) {
1575
+ alert("Failed to load thread: " + (e && e.message ? e.message : "unknown"));
1576
+ }
1577
+ };
1578
+
1579
+ const createAndOpenNewThread = async (parentMessageId) => {
1580
+ const conversationId = state.activeConversationId;
1581
+ if (!conversationId) return;
1582
+ try {
1583
+ const resp = await api(
1584
+ "/api/conversations/" + encodeURIComponent(conversationId) + "/threads",
1585
+ { method: "POST", body: JSON.stringify({ parentMessageId }) },
1586
+ );
1587
+ const summary = resp.thread;
1588
+ const list = state.threadsByParent[parentMessageId] || [];
1589
+ state.threadsByParent[parentMessageId] = list.concat([summary]);
1590
+ renderMessages(state.activeMessages, state.isStreaming);
1591
+ await openThread(summary.conversationId, summary);
1592
+ } catch (e) {
1593
+ const code = e && e.payload && e.payload.code;
1594
+ const msg = code || (e && e.message ? e.message : "unknown");
1595
+ alert("Failed to create thread: " + msg);
1596
+ }
1597
+ };
1598
+
1599
+ const refreshThreads = async () => {
1600
+ const conversationId = state.activeConversationId;
1601
+ if (!conversationId) {
1602
+ state.threadsByParent = {};
1603
+ return;
1604
+ }
1605
+ try {
1606
+ const data = await api("/api/conversations/" + encodeURIComponent(conversationId) + "/threads");
1607
+ const grouped = {};
1608
+ (data.threads || []).forEach((t) => {
1609
+ if (!t.parentMessageId) return;
1610
+ (grouped[t.parentMessageId] = grouped[t.parentMessageId] || []).push(t);
1611
+ });
1612
+ state.threadsByParent = grouped;
1613
+ } catch (e) { /* keep existing */ }
1614
+ };
1615
+
1616
+ const refreshActiveMessagesFromServer = async (conversationId) => {
1617
+ if (!conversationId) return;
1618
+ try {
1619
+ const payload = await api("/api/conversations/" + encodeURIComponent(conversationId));
1620
+ if (state.activeConversationId !== conversationId) return;
1621
+ let displayMessages = (payload.conversation && payload.conversation.messages) || [];
1622
+ const compactedHistory = payload.conversation && payload.conversation.compactedHistory;
1623
+ if (Array.isArray(compactedHistory) && compactedHistory.length > 0) {
1624
+ let dividerMsg = { role: "user", content: "", metadata: { isCompactionSummary: true } };
1625
+ const summaryMsg = displayMessages.find((m) => m.metadata && m.metadata.isCompactionSummary);
1626
+ if (summaryMsg) {
1627
+ dividerMsg = summaryMsg;
1628
+ displayMessages = displayMessages.filter((m) => m !== summaryMsg);
1629
+ }
1630
+ displayMessages = [].concat(compactedHistory, [dividerMsg], displayMessages);
1631
+ }
1632
+ state.activeMessages = displayMessages;
1633
+ await refreshThreads();
1634
+ renderMessages(state.activeMessages, false);
1635
+ } catch (e) {
1636
+ // Best-effort refresh — silent on failure
1637
+ }
1638
+ };
1639
+
1640
+ const submitThreadReply = async (text, files) => {
1641
+ const threadId = state.threadPanel.threadId;
1642
+ const messageText = (text || "").trim();
1643
+ const filesToSend = Array.isArray(files) ? files : [];
1644
+ if (!threadId || (!messageText && filesToSend.length === 0)) return;
1645
+
1646
+ // Build the optimistic user message with file ContentParts so the
1647
+ // panel can show attachments immediately, matching the main pane.
1648
+ let optimisticContent;
1649
+ if (filesToSend.length > 0) {
1650
+ optimisticContent = [{ type: "text", text: messageText }];
1651
+ for (const f of filesToSend) {
1652
+ optimisticContent.push({
1653
+ type: "file",
1654
+ data: URL.createObjectURL(f),
1655
+ mediaType: f.type,
1656
+ filename: f.name,
1657
+ _localBlob: f,
1658
+ });
1659
+ }
1660
+ } else {
1661
+ optimisticContent = messageText;
1662
+ }
1663
+ // Optimistic user + empty assistant placeholder — model:chunk events
1664
+ // from the POST /messages SSE stream will fill the assistant content.
1665
+ const optimisticAssistant = { role: "assistant", content: "", _streaming: true };
1666
+ state.threadPanel.messages = (state.threadPanel.messages || []).concat([
1667
+ { role: "user", content: optimisticContent },
1668
+ optimisticAssistant,
1669
+ ]);
1670
+ renderThreadPanelMessages();
1671
+
1672
+ // Optimistic bump on the inline thread-row reply count in the main pane.
1673
+ if (state.threadPanel.parentMessageId) {
1674
+ const list = state.threadsByParent[state.threadPanel.parentMessageId] || [];
1675
+ const idx = list.findIndex((t) => t.conversationId === threadId);
1676
+ if (idx >= 0) {
1677
+ list[idx] = { ...list[idx], replyCount: (list[idx].replyCount || 0) + 1, lastReplyAt: Date.now() };
1678
+ state.threadsByParent[state.threadPanel.parentMessageId] = list;
1679
+ renderMessages(state.activeMessages, state.isStreaming);
1680
+ }
1681
+ }
1682
+
1683
+ // Build the request body — FormData when files are present, JSON otherwise.
1684
+ let fetchOpts;
1685
+ if (filesToSend.length > 0) {
1686
+ const formData = new FormData();
1687
+ formData.append("message", messageText);
1688
+ for (const f of filesToSend) {
1689
+ formData.append("files", f, f.name);
1690
+ }
1691
+ fetchOpts = {
1692
+ method: "POST",
1693
+ headers: buildAuthHeaders(),
1694
+ credentials: state.tenantToken ? "omit" : "include",
1695
+ body: formData,
1696
+ };
1697
+ } else {
1698
+ fetchOpts = {
1699
+ method: "POST",
1700
+ headers: { ...buildAuthHeaders(), "Content-Type": "application/json" },
1701
+ credentials: state.tenantToken ? "omit" : "include",
1702
+ body: JSON.stringify({ message: messageText }),
1703
+ };
1704
+ }
1705
+
1706
+ try {
1707
+ const resp = await fetch(
1708
+ "/api/conversations/" + encodeURIComponent(threadId) + "/messages",
1709
+ fetchOpts,
1710
+ );
1711
+ if (!resp.ok) throw new Error("HTTP " + resp.status);
1712
+ // Stream the SSE body — incrementally append model:chunk text to the
1713
+ // optimistic assistant message so the user sees tokens land live.
1714
+ if (resp.body) {
1715
+ const reader = resp.body.getReader();
1716
+ const decoder = new TextDecoder();
1717
+ let buffer = "";
1718
+ let chunkCount = 0;
1719
+ while (true) {
1720
+ const { value, done } = await reader.read();
1721
+ if (done) break;
1722
+ buffer += decoder.decode(value, { stream: true });
1723
+ buffer = parseSseChunk(buffer, (eventName, payload) => {
1724
+ if (state.threadPanel.threadId !== threadId) return;
1725
+ if (eventName === "model:chunk") {
1726
+ const chunk = String((payload && payload.content) || "");
1727
+ if (!chunk) return;
1728
+ chunkCount += 1;
1729
+ optimisticAssistant._streaming = true;
1730
+ optimisticAssistant.content = String(optimisticAssistant.content || "") + chunk;
1731
+ renderThreadPanelMessages();
1732
+ }
1733
+ });
1734
+ }
1735
+ if (chunkCount === 0) {
1736
+ console.warn("[thread] no model:chunk events received — server may be buffering the response");
1737
+ } else {
1738
+ console.debug("[thread] streamed " + chunkCount + " chunks");
1739
+ }
1740
+ }
1741
+ // After the run completes, refetch so the panel reflects canonical
1742
+ // server state (including any tool sections/metadata we didn't
1743
+ // incrementally render here).
1744
+ const fresh = await api("/api/conversations/" + encodeURIComponent(threadId)).catch(() => null);
1745
+ if (fresh && state.threadPanel.threadId === threadId) {
1746
+ renderActiveTopForThreadPanel(fresh);
1747
+ }
1748
+ } catch (e) {
1749
+ // Drop the streaming placeholder if the post failed before producing any text.
1750
+ if (!optimisticAssistant.content) {
1751
+ state.threadPanel.messages = (state.threadPanel.messages || []).filter(
1752
+ (m) => m !== optimisticAssistant,
1753
+ );
1754
+ renderThreadPanelMessages();
1755
+ }
1756
+ alert("Failed to send reply: " + (e && e.message ? e.message : "unknown"));
1757
+ }
1758
+ };
1759
+
1189
1760
  const renderMessages = (messages, isStreaming = false, options = {}) => {
1190
1761
  const previousScrollTop = elements.messages.scrollTop;
1191
1762
  const shouldStickToBottom =
1192
1763
  options.forceScrollBottom === true || state.isMessagesPinnedToBottom;
1193
1764
 
1194
- const createThinkingIndicator = (label) => {
1195
- const status = document.createElement("div");
1196
- status.className = "thinking-status";
1197
- const spinner = document.createElement("span");
1198
- spinner.className = "thinking-indicator";
1199
- const starFrames = ["✶", "✸", "✹", "✺", "✹", "✷"];
1200
- let frame = 0;
1201
- spinner.textContent = starFrames[0];
1202
- spinner._interval = setInterval(() => {
1203
- frame = (frame + 1) % starFrames.length;
1204
- spinner.textContent = starFrames[frame];
1205
- }, 70);
1206
- status.appendChild(spinner);
1207
- if (label) {
1208
- const text = document.createElement("span");
1209
- text.className = "thinking-status-label";
1210
- text.textContent = label;
1211
- status.appendChild(text);
1212
- }
1213
- return status;
1214
- };
1215
-
1216
1765
  // Preserve open state of tool-activity disclosures across re-renders.
1217
1766
  // Track by message row index + disclosure index within the row.
1218
1767
  const openDisclosures = new Map();
@@ -1272,12 +1821,7 @@ export const getWebUiClientScript = (markedSource: string): string => `
1272
1821
  (!Array.isArray(m._currentTools) || m._currentTools.length === 0) &&
1273
1822
  !hasPendingApprovals;
1274
1823
 
1275
- if (m._error) {
1276
- const errorEl = document.createElement("div");
1277
- errorEl.className = "message-error";
1278
- errorEl.innerHTML = "<strong>Error</strong><br>" + escapeHtml(m._error);
1279
- content.appendChild(errorEl);
1280
- } else if (shouldRenderEmptyStreamingIndicator) {
1824
+ if (shouldRenderEmptyStreamingIndicator && !m._error) {
1281
1825
  content.appendChild(createThinkingIndicator(getThinkingStatusLabel(m)));
1282
1826
  } else {
1283
1827
  // Merge stored sections (persisted) with live sections (from
@@ -1340,12 +1884,18 @@ export const getWebUiClientScript = (markedSource: string): string => `
1340
1884
  renderToolActivity([], pendingApprovals, m._toolImages || []),
1341
1885
  );
1342
1886
  }
1343
- if (isStreaming && isLastAssistant && !hasPendingApprovals) {
1887
+ if (isStreaming && isLastAssistant && !hasPendingApprovals && !m._error) {
1344
1888
  const waitIndicator = document.createElement("div");
1345
1889
  waitIndicator.appendChild(createThinkingIndicator(getThinkingStatusLabel(m)));
1346
1890
  content.appendChild(waitIndicator);
1347
1891
  }
1348
1892
  }
1893
+ if (m._error) {
1894
+ const errorEl = document.createElement("div");
1895
+ errorEl.className = "message-error";
1896
+ errorEl.innerHTML = "<strong>Error</strong><br>" + escapeHtml(m._error);
1897
+ content.appendChild(errorEl);
1898
+ }
1349
1899
  wrap.appendChild(content);
1350
1900
  row.appendChild(wrap);
1351
1901
  } else if (m.metadata && m.metadata._subagentCallback) {
@@ -1423,6 +1973,7 @@ export const getWebUiClientScript = (markedSource: string): string => `
1423
1973
  }
1424
1974
  row.appendChild(bubble);
1425
1975
  }
1976
+ appendThreadAffordances(row, m);
1426
1977
  col.appendChild(row);
1427
1978
  });
1428
1979
  elements.messages.appendChild(col);
@@ -1459,11 +2010,15 @@ export const getWebUiClientScript = (markedSource: string): string => `
1459
2010
 
1460
2011
  const loadConversation = async (conversationId) => {
1461
2012
  if (window._resetBrowserPanel) window._resetBrowserPanel();
1462
- // Kick off conversation + todos fetches in parallel — todos only needs
1463
- // the id, so there's no reason to wait for the conversation response.
2013
+ // Switching conversations always closes any open thread panel.
2014
+ closeThreadPanel();
2015
+ // Kick off conversation + todos + threads fetches in parallel — they
2016
+ // only need the id, so there's no reason to wait for the conversation.
1464
2017
  const conversationPromise = api("/api/conversations/" + encodeURIComponent(conversationId));
1465
2018
  const todosPromise = api("/api/conversations/" + encodeURIComponent(conversationId) + "/todos")
1466
2019
  .catch(() => ({ todos: [] }));
2020
+ const threadsPromise = api("/api/conversations/" + encodeURIComponent(conversationId) + "/threads")
2021
+ .catch(() => ({ threads: [] }));
1467
2022
  const payload = await conversationPromise;
1468
2023
  elements.chatTitle.textContent = payload.conversation.title;
1469
2024
  // Merge own pending approvals + subagent pending approvals
@@ -1517,6 +2072,19 @@ export const getWebUiClientScript = (markedSource: string): string => `
1517
2072
  _autoCollapseTodos();
1518
2073
  renderTodoPanel();
1519
2074
 
2075
+ // Group thread summaries by parentMessageId for inline rendering.
2076
+ try {
2077
+ const threadsPayload = await threadsPromise;
2078
+ const grouped = {};
2079
+ (threadsPayload.threads || []).forEach((t) => {
2080
+ if (!t.parentMessageId) return;
2081
+ (grouped[t.parentMessageId] = grouped[t.parentMessageId] || []).push(t);
2082
+ });
2083
+ state.threadsByParent = grouped;
2084
+ } catch (e) {
2085
+ state.threadsByParent = {};
2086
+ }
2087
+
1520
2088
  updateContextRing();
1521
2089
  var willStream = !!payload.hasActiveRun;
1522
2090
  var hasSendMessageStream = state.activeStreamConversationId === conversationId && state._activeStreamMessages;
@@ -1526,6 +2094,21 @@ export const getWebUiClientScript = (markedSource: string): string => `
1526
2094
  } else {
1527
2095
  renderMessages(state.activeMessages, willStream, { forceScrollBottom: true });
1528
2096
  }
2097
+ // If the URL has #thread=<id>, reopen that thread panel after main render.
2098
+ try {
2099
+ const hash = window.location.hash || "";
2100
+ const m = hash.match(/thread=([^&]+)/);
2101
+ if (m && m[1]) {
2102
+ const threadId = decodeURIComponent(m[1]);
2103
+ // Find the matching summary so we can pin parentMessageId.
2104
+ let summary = null;
2105
+ for (const k of Object.keys(state.threadsByParent)) {
2106
+ const found = (state.threadsByParent[k] || []).find((t) => t.conversationId === threadId);
2107
+ if (found) { summary = found; break; }
2108
+ }
2109
+ if (summary) openThread(threadId, summary);
2110
+ }
2111
+ } catch (e) { /* ignore */ }
1529
2112
  if (!state.viewingSubagentId) {
1530
2113
  elements.prompt.focus();
1531
2114
  }
@@ -1707,43 +2290,65 @@ export const getWebUiClientScript = (markedSource: string): string => `
1707
2290
  });
1708
2291
  };
1709
2292
 
2293
+ // Fetch the full conversation and sync UI state. Extracted so both
2294
+ // poll loops can call it only when the cheap /status endpoint shows
2295
+ // something has actually changed.
2296
+ const refetchConversationAndRender = async (conversationId, streaming) => {
2297
+ const payload = await api("/api/conversations/" + encodeURIComponent(conversationId));
2298
+ if (state.activeConversationId !== conversationId || !payload.conversation) return payload;
2299
+ var allPending = [].concat(payload.conversation.pendingApprovals || []);
2300
+ if (Array.isArray(payload.subagentPendingApprovals)) {
2301
+ payload.subagentPendingApprovals.forEach(function(sa) {
2302
+ var subIdShort = sa.subagentId && sa.subagentId.length > 12 ? sa.subagentId.slice(0, 12) + "..." : (sa.subagentId || "");
2303
+ allPending.push({
2304
+ approvalId: sa.approvalId,
2305
+ tool: sa.tool,
2306
+ input: sa.input,
2307
+ _subagentId: sa.subagentId,
2308
+ _subagentLabel: "subagent " + subIdShort,
2309
+ });
2310
+ });
2311
+ }
2312
+ state.activeMessages = hydratePendingApprovals(
2313
+ payload.conversation.messages || [],
2314
+ allPending,
2315
+ );
2316
+ if (typeof payload.conversation.contextTokens === "number") {
2317
+ state.contextTokens = payload.conversation.contextTokens;
2318
+ }
2319
+ if (typeof payload.conversation.contextWindow === "number" && payload.conversation.contextWindow > 0) {
2320
+ state.contextWindow = payload.conversation.contextWindow;
2321
+ }
2322
+ updateContextRing();
2323
+ renderMessages(state.activeMessages, streaming);
2324
+ return payload;
2325
+ };
2326
+
1710
2327
  const pollUntilRunIdle = (conversationId) => {
2328
+ let lastUpdatedAt = 0;
2329
+ let lastMessageCount = -1;
2330
+ let lastPendingSignature = "";
1711
2331
  const poll = async () => {
1712
2332
  if (state.activeConversationId !== conversationId) return;
1713
2333
  try {
1714
- var payload = await api("/api/conversations/" + encodeURIComponent(conversationId));
2334
+ // Cheap status check no data blob, no archive, no messages.
2335
+ const status = await api("/api/conversations/" + encodeURIComponent(conversationId) + "/status");
1715
2336
  if (state.activeConversationId !== conversationId) return;
1716
- if (payload.conversation) {
1717
- var allPending = [].concat(
1718
- payload.conversation.pendingApprovals || [],
1719
- );
1720
- if (Array.isArray(payload.subagentPendingApprovals)) {
1721
- payload.subagentPendingApprovals.forEach(function(sa) {
1722
- var subIdShort = sa.subagentId && sa.subagentId.length > 12 ? sa.subagentId.slice(0, 12) + "..." : (sa.subagentId || "");
1723
- allPending.push({
1724
- approvalId: sa.approvalId,
1725
- tool: sa.tool,
1726
- input: sa.input,
1727
- _subagentId: sa.subagentId,
1728
- _subagentLabel: "subagent " + subIdShort,
1729
- });
1730
- });
1731
- }
1732
- state.activeMessages = hydratePendingApprovals(
1733
- payload.conversation.messages || [],
1734
- allPending,
1735
- );
1736
- if (typeof payload.conversation.contextTokens === "number") {
1737
- state.contextTokens = payload.conversation.contextTokens;
1738
- }
1739
- if (typeof payload.conversation.contextWindow === "number" && payload.conversation.contextWindow > 0) {
1740
- state.contextWindow = payload.conversation.contextWindow;
1741
- }
1742
- updateContextRing();
1743
- renderMessages(state.activeMessages, payload.hasActiveRun);
2337
+ const pendingSignature =
2338
+ (status.hasPendingApprovals ? 1 : 0) + ":" + (status.subagentPendingApprovalsCount || 0);
2339
+ const changed =
2340
+ status.updatedAt > lastUpdatedAt ||
2341
+ status.messageCount !== lastMessageCount ||
2342
+ pendingSignature !== lastPendingSignature;
2343
+ if (changed) {
2344
+ lastUpdatedAt = status.updatedAt;
2345
+ lastMessageCount = status.messageCount;
2346
+ lastPendingSignature = pendingSignature;
2347
+ await refetchConversationAndRender(conversationId, status.hasActiveRun);
2348
+ if (state.activeConversationId !== conversationId) return;
1744
2349
  }
1745
- if (payload.hasActiveRun || payload.hasRunningSubagents) {
1746
- if (payload.hasActiveRun && window._connectBrowserStream) window._connectBrowserStream();
2350
+ if (status.hasActiveRun || status.hasRunningSubagents) {
2351
+ if (status.hasActiveRun && window._connectBrowserStream) window._connectBrowserStream();
1747
2352
  setTimeout(poll, 2000);
1748
2353
  } else {
1749
2354
  setStreaming(false);
@@ -1762,49 +2367,66 @@ export const getWebUiClientScript = (markedSource: string): string => `
1762
2367
  state.subagentPollInFlight[conversationId] = true;
1763
2368
  let lastMessageCount = state.activeMessages ? state.activeMessages.length : 0;
1764
2369
  let lastUpdatedAt = 0;
2370
+ let lastPendingSignature = "";
2371
+ let streamingCallback = false;
1765
2372
  const poll = async () => {
1766
2373
  if (state.activeConversationId !== conversationId) {
1767
2374
  delete state.subagentPollInFlight[conversationId];
1768
2375
  return;
1769
2376
  }
1770
2377
  try {
1771
- var payload = await api("/api/conversations/" + encodeURIComponent(conversationId));
2378
+ const status = await api("/api/conversations/" + encodeURIComponent(conversationId) + "/status");
1772
2379
  if (state.activeConversationId !== conversationId) return;
1773
- if (payload.conversation) {
1774
- var messages = payload.conversation.messages || [];
1775
- var allPending = [].concat(
1776
- payload.conversation.pendingApprovals || [],
1777
- );
1778
- if (Array.isArray(payload.subagentPendingApprovals)) {
1779
- payload.subagentPendingApprovals.forEach(function(sa) {
1780
- var subIdShort = sa.subagentId && sa.subagentId.length > 12 ? sa.subagentId.slice(0, 12) + "..." : (sa.subagentId || "");
1781
- allPending.push({
1782
- approvalId: sa.approvalId,
1783
- tool: sa.tool,
1784
- input: sa.input,
1785
- _subagentId: sa.subagentId,
1786
- _subagentLabel: "subagent " + subIdShort,
1787
- });
1788
- });
2380
+ const pendingSignature =
2381
+ (status.hasPendingApprovals ? 1 : 0) + ":" + (status.subagentPendingApprovalsCount || 0);
2382
+ const changed =
2383
+ status.messageCount > lastMessageCount ||
2384
+ status.updatedAt > lastUpdatedAt ||
2385
+ pendingSignature !== lastPendingSignature;
2386
+ if (changed) {
2387
+ lastMessageCount = status.messageCount;
2388
+ lastUpdatedAt = status.updatedAt;
2389
+ lastPendingSignature = pendingSignature;
2390
+ await refetchConversationAndRender(conversationId, status.hasActiveRun || status.hasRunningSubagents);
2391
+ if (state.activeConversationId !== conversationId) return;
2392
+ }
2393
+ if (status.hasActiveRun && !streamingCallback) {
2394
+ // The parent callback run is active — subscribe to the SSE
2395
+ // event stream so the response streams live instead of only
2396
+ // appearing after the run finishes.
2397
+ streamingCallback = true;
2398
+ // Refetch so the injected subagent result message is visible
2399
+ // before we start streaming the assistant's response.
2400
+ await refetchConversationAndRender(conversationId, true);
2401
+ if (state.activeConversationId !== conversationId) {
2402
+ delete state.subagentPollInFlight[conversationId];
2403
+ return;
1789
2404
  }
1790
- const conversationUpdatedAt =
1791
- typeof payload.conversation.updatedAt === "number" ? payload.conversation.updatedAt : 0;
1792
- if (messages.length > lastMessageCount || conversationUpdatedAt > lastUpdatedAt) {
1793
- lastMessageCount = messages.length;
1794
- lastUpdatedAt = conversationUpdatedAt;
1795
- state.activeMessages = hydratePendingApprovals(messages, allPending);
1796
- renderMessages(state.activeMessages, payload.hasActiveRun || payload.hasRunningSubagents);
2405
+ lastMessageCount = status.messageCount;
2406
+ lastUpdatedAt = status.updatedAt;
2407
+ setStreaming(true);
2408
+ try {
2409
+ await streamConversationEvents(conversationId, { liveOnly: true });
2410
+ } catch {}
2411
+ streamingCallback = false;
2412
+ // After the stream ends, update counts and resume polling
2413
+ // to catch any remaining subagent work.
2414
+ const fresh = await api("/api/conversations/" + encodeURIComponent(conversationId) + "/status").catch(function() { return null; });
2415
+ if (fresh) {
2416
+ lastMessageCount = fresh.messageCount;
2417
+ lastUpdatedAt = fresh.updatedAt;
1797
2418
  }
1798
- if (payload.hasActiveRun || payload.hasRunningSubagents) {
1799
- // Keep polling while subagents are running or the parent
1800
- // callback is active. Persisted messages are rendered each
1801
- // cycle so results appear as soon as they're committed.
1802
- setTimeout(poll, 2000);
1803
- } else {
1804
- renderMessages(state.activeMessages, false);
1805
- await loadConversations();
2419
+ if (state.activeConversationId !== conversationId) {
1806
2420
  delete state.subagentPollInFlight[conversationId];
2421
+ return;
1807
2422
  }
2423
+ setTimeout(poll, 1000);
2424
+ } else if (status.hasActiveRun || status.hasRunningSubagents) {
2425
+ setTimeout(poll, 2000);
2426
+ } else {
2427
+ renderMessages(state.activeMessages, false);
2428
+ await loadConversations();
2429
+ delete state.subagentPollInFlight[conversationId];
1808
2430
  }
1809
2431
  } catch {
1810
2432
  // Polling error; retry
@@ -3174,6 +3796,11 @@ export const getWebUiClientScript = (markedSource: string): string => `
3174
3796
  setStreaming(false);
3175
3797
  if (didCompact && conversationId) {
3176
3798
  loadConversation(conversationId).catch(function() {});
3799
+ } else if (conversationId) {
3800
+ // After a normal turn, replace the locally-built activeMessages
3801
+ // (which lack metadata.id) with the server's persisted version so
3802
+ // the "Reply in thread" affordance and other id-based features work.
3803
+ refreshActiveMessagesFromServer(conversationId).catch(function() {});
3177
3804
  }
3178
3805
  elements.prompt.focus();
3179
3806
  }
@@ -3522,6 +4149,146 @@ export const getWebUiClientScript = (markedSource: string): string => `
3522
4149
  }
3523
4150
  });
3524
4151
 
4152
+ if (elements.threadPanelClose) {
4153
+ elements.threadPanelClose.addEventListener("click", () => {
4154
+ closeThreadPanel();
4155
+ });
4156
+ }
4157
+
4158
+ // ── Thread composer (separate from the main composer) ──
4159
+ const renderThreadAttachmentPreview = () => {
4160
+ const el = elements.threadAttachmentPreview;
4161
+ if (!el) return;
4162
+ const files = state.threadPanel.pendingFiles || [];
4163
+ if (files.length === 0) {
4164
+ el.style.display = "none";
4165
+ el.innerHTML = "";
4166
+ return;
4167
+ }
4168
+ el.style.display = "";
4169
+ el.innerHTML = files.map((f, i) => {
4170
+ const isImage = f.type && f.type.startsWith("image/");
4171
+ const preview = isImage
4172
+ ? '<img src="' + URL.createObjectURL(f) + '" />'
4173
+ : '<span class="user-file-badge">📎 ' + escapeHtml(f.name) + '</span>';
4174
+ return '<div class="attachment-item">' + preview
4175
+ + '<button type="button" class="remove-attachment" data-idx="' + i + '">×</button></div>';
4176
+ }).join("");
4177
+ };
4178
+
4179
+ const addThreadFiles = (fileList) => {
4180
+ const arr = Array.from(fileList || []);
4181
+ for (const f of arr) {
4182
+ if (f.size > 25 * 1024 * 1024) {
4183
+ alert("File too large: " + f.name + " (max 25MB)");
4184
+ continue;
4185
+ }
4186
+ state.threadPanel.pendingFiles.push(f);
4187
+ }
4188
+ renderThreadAttachmentPreview();
4189
+ };
4190
+
4191
+ const autoResizeThreadPrompt = () => {
4192
+ const el = elements.threadPrompt;
4193
+ if (!el) return;
4194
+ el.style.height = "auto";
4195
+ el.style.height = Math.min(el.scrollHeight, 200) + "px";
4196
+ };
4197
+
4198
+ if (elements.threadAttachBtn && elements.threadFileInput) {
4199
+ elements.threadAttachBtn.addEventListener("click", () => elements.threadFileInput.click());
4200
+ elements.threadFileInput.addEventListener("change", () => {
4201
+ if (elements.threadFileInput.files && elements.threadFileInput.files.length > 0) {
4202
+ addThreadFiles(elements.threadFileInput.files);
4203
+ elements.threadFileInput.value = "";
4204
+ }
4205
+ });
4206
+ }
4207
+ if (elements.threadAttachmentPreview) {
4208
+ elements.threadAttachmentPreview.addEventListener("click", (e) => {
4209
+ const rm = e.target.closest(".remove-attachment");
4210
+ if (rm) {
4211
+ const idx = parseInt(rm.dataset.idx, 10);
4212
+ state.threadPanel.pendingFiles.splice(idx, 1);
4213
+ renderThreadAttachmentPreview();
4214
+ }
4215
+ });
4216
+ }
4217
+ if (elements.threadPrompt) {
4218
+ elements.threadPrompt.addEventListener("input", autoResizeThreadPrompt);
4219
+ elements.threadPrompt.addEventListener("paste", (e) => {
4220
+ const items = e.clipboardData && e.clipboardData.items;
4221
+ if (!items) return;
4222
+ const files = [];
4223
+ for (let i = 0; i < items.length; i++) {
4224
+ if (items[i].kind === "file") {
4225
+ const f = items[i].getAsFile();
4226
+ if (f) files.push(f);
4227
+ }
4228
+ }
4229
+ if (files.length > 0) {
4230
+ e.preventDefault();
4231
+ addThreadFiles(files);
4232
+ }
4233
+ });
4234
+ elements.threadPrompt.addEventListener("keydown", (e) => {
4235
+ if (e.key === "Enter" && !e.shiftKey) {
4236
+ e.preventDefault();
4237
+ elements.threadComposer.requestSubmit();
4238
+ }
4239
+ });
4240
+ }
4241
+ if (elements.threadComposer) {
4242
+ elements.threadComposer.addEventListener("submit", async (event) => {
4243
+ event.preventDefault();
4244
+ if (!state.threadPanel.open || !state.threadPanel.threadId) return;
4245
+ const value = elements.threadPrompt.value;
4246
+ const filesToSend = [...state.threadPanel.pendingFiles];
4247
+ if (!value.trim() && filesToSend.length === 0) return;
4248
+ elements.threadPrompt.value = "";
4249
+ state.threadPanel.pendingFiles = [];
4250
+ renderThreadAttachmentPreview();
4251
+ autoResizeThreadPrompt();
4252
+ await submitThreadReply(value, filesToSend);
4253
+ });
4254
+ }
4255
+
4256
+ // Drag-to-resize between main pane and thread panel — mirrors the
4257
+ // browser-panel resize pattern.
4258
+ (function () {
4259
+ const handle = elements.threadPanelResize;
4260
+ const panel = elements.threadPanel;
4261
+ const mainEl = document.querySelector(".main-chat");
4262
+ if (!handle || !panel || !mainEl) return;
4263
+ let dragging = false;
4264
+ handle.addEventListener("mousedown", (e) => {
4265
+ e.preventDefault();
4266
+ dragging = true;
4267
+ handle.classList.add("dragging");
4268
+ document.body.style.cursor = "col-resize";
4269
+ document.body.style.userSelect = "none";
4270
+ });
4271
+ document.addEventListener("mousemove", (e) => {
4272
+ if (!dragging) return;
4273
+ const body = mainEl.parentElement;
4274
+ if (!body) return;
4275
+ const bodyRect = body.getBoundingClientRect();
4276
+ const available = bodyRect.width - 1;
4277
+ let chatW = e.clientX - bodyRect.left;
4278
+ chatW = Math.max(280, Math.min(chatW, available - 320));
4279
+ const panelW = available - chatW;
4280
+ mainEl.style.flex = "0 0 " + chatW + "px";
4281
+ panel.style.flex = "0 0 " + panelW + "px";
4282
+ });
4283
+ document.addEventListener("mouseup", () => {
4284
+ if (!dragging) return;
4285
+ dragging = false;
4286
+ handle.classList.remove("dragging");
4287
+ document.body.style.cursor = "";
4288
+ document.body.style.userSelect = "";
4289
+ });
4290
+ })();
4291
+
3525
4292
  elements.composer.addEventListener("submit", async (event) => {
3526
4293
  event.preventDefault();
3527
4294
  if (state.isStreaming) {
@@ -3820,6 +4587,10 @@ export const getWebUiClientScript = (markedSource: string): string => `
3820
4587
  state.confirmDeleteId = null;
3821
4588
  renderConversationList();
3822
4589
  }
4590
+ if (!event.target.closest(".thread-row") && state.confirmDeleteThreadId) {
4591
+ state.confirmDeleteThreadId = null;
4592
+ renderMessages(state.activeMessages, state.isStreaming);
4593
+ }
3823
4594
  });
3824
4595
 
3825
4596
  window.addEventListener("resize", () => {