@poncho-ai/cli 0.37.0 → 0.38.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +7 -7
- package/CHANGELOG.md +264 -0
- package/dist/{chunk-GUGBKAIM.js → chunk-W7SQVUB4.js} +6166 -4694
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +197 -128
- package/dist/index.js +111 -5
- package/dist/run-interactive-ink-UKPUGCDW.js +679 -0
- package/package.json +4 -4
- package/src/cron-helpers.ts +183 -0
- package/src/http-utils.ts +220 -0
- package/src/index.ts +1071 -4754
- package/src/logger.ts +9 -0
- package/src/mcp-commands.ts +283 -0
- package/src/project-init.ts +150 -0
- package/src/run-commands.ts +145 -0
- package/src/scaffolding.ts +528 -0
- package/src/skills.ts +372 -0
- package/src/templates.ts +563 -0
- package/src/testing.ts +108 -0
- package/src/web-ui-client.ts +845 -94
- package/src/web-ui-styles.ts +269 -1
- package/src/web-ui.ts +23 -0
- package/test/cli.test.ts +52 -1
- package/dist/run-interactive-ink-75GKYSEC.js +0 -2115
- package/test/run-orchestration.test.ts +0 -171
package/src/web-ui-client.ts
CHANGED
|
@@ -53,6 +53,17 @@ 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
|
+
messages: [],
|
|
63
|
+
isStreaming: false,
|
|
64
|
+
abortController: null,
|
|
65
|
+
pendingFiles: [],
|
|
66
|
+
},
|
|
56
67
|
};
|
|
57
68
|
|
|
58
69
|
const agentInitial = document.body.dataset.agentInitial || "A";
|
|
@@ -91,6 +102,16 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
91
102
|
browserPanelClose: $("browser-panel-close"),
|
|
92
103
|
browserNavBack: $("browser-nav-back"),
|
|
93
104
|
browserNavForward: $("browser-nav-forward"),
|
|
105
|
+
threadPanel: $("thread-panel"),
|
|
106
|
+
threadPanelResize: $("thread-panel-resize"),
|
|
107
|
+
threadPanelClose: $("thread-panel-close"),
|
|
108
|
+
threadPanelMessages: $("thread-panel-messages"),
|
|
109
|
+
threadComposer: $("thread-composer"),
|
|
110
|
+
threadAttachBtn: $("thread-attach-btn"),
|
|
111
|
+
threadFileInput: $("thread-file-input"),
|
|
112
|
+
threadAttachmentPreview: $("thread-attachment-preview"),
|
|
113
|
+
threadPrompt: $("thread-prompt"),
|
|
114
|
+
threadSend: $("thread-send"),
|
|
94
115
|
};
|
|
95
116
|
const sendIconMarkup =
|
|
96
117
|
'<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 +1207,541 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
1186
1207
|
);
|
|
1187
1208
|
};
|
|
1188
1209
|
|
|
1210
|
+
const formatRelativeTime = (ts) => {
|
|
1211
|
+
if (!ts) return "";
|
|
1212
|
+
const diff = Math.max(0, Date.now() - ts);
|
|
1213
|
+
if (diff < 60_000) return "just now";
|
|
1214
|
+
if (diff < 3_600_000) return Math.floor(diff / 60_000) + "m ago";
|
|
1215
|
+
if (diff < 86_400_000) return Math.floor(diff / 3_600_000) + "h ago";
|
|
1216
|
+
return Math.floor(diff / 86_400_000) + "d ago";
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
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>';
|
|
1220
|
+
|
|
1221
|
+
const isThreadAffordanceCandidate = (m) => {
|
|
1222
|
+
if (!m || !m.metadata || typeof m.metadata.id !== "string") return false;
|
|
1223
|
+
if (m.role === "system") return false;
|
|
1224
|
+
if (m.metadata.isCompactionSummary) return false;
|
|
1225
|
+
if (m.metadata._subagentCallback) return false;
|
|
1226
|
+
return true;
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
const deleteThreadConfirmed = (threadId, parentMessageId) => {
|
|
1230
|
+
if (!threadId) return;
|
|
1231
|
+
// Optimistic removal — fire DELETE in the background, mirrors the
|
|
1232
|
+
// sidebar conversation-delete pattern at line ~798.
|
|
1233
|
+
if (parentMessageId && state.threadsByParent[parentMessageId]) {
|
|
1234
|
+
state.threadsByParent[parentMessageId] = state.threadsByParent[parentMessageId]
|
|
1235
|
+
.filter((t) => t.conversationId !== threadId);
|
|
1236
|
+
if (state.threadsByParent[parentMessageId].length === 0) {
|
|
1237
|
+
delete state.threadsByParent[parentMessageId];
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
if (state.threadPanel.threadId === threadId) {
|
|
1241
|
+
closeThreadPanel();
|
|
1242
|
+
}
|
|
1243
|
+
state.confirmDeleteThreadId = null;
|
|
1244
|
+
renderMessages(state.activeMessages, state.isStreaming);
|
|
1245
|
+
api(
|
|
1246
|
+
"/api/conversations/" + encodeURIComponent(threadId),
|
|
1247
|
+
{ method: "DELETE" },
|
|
1248
|
+
).catch(() => {});
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
// Single absolute-positioned wrap on each message row. Behaves as:
|
|
1252
|
+
// - no threads: hover-only "Reply in thread" pill (creates a thread)
|
|
1253
|
+
// - >=1 thread: always-visible badge per thread (opens that thread)
|
|
1254
|
+
// Position (bottom-offset overlapping the message) is identical in both
|
|
1255
|
+
// states so the badge sits exactly where the hover pill would.
|
|
1256
|
+
const appendThreadAffordances = (row, m) => {
|
|
1257
|
+
if (!isThreadAffordanceCandidate(m)) return;
|
|
1258
|
+
const messageId = m.metadata.id;
|
|
1259
|
+
const threads = state.threadsByParent[messageId] || [];
|
|
1260
|
+
const wrap = document.createElement("span");
|
|
1261
|
+
wrap.className = "reply-pill-wrap" + (threads.length > 0 ? " has-threads" : "");
|
|
1262
|
+
|
|
1263
|
+
if (threads.length === 0) {
|
|
1264
|
+
const btn = document.createElement("button");
|
|
1265
|
+
btn.type = "button";
|
|
1266
|
+
btn.className = "reply-icon-btn";
|
|
1267
|
+
btn.title = "Reply in thread";
|
|
1268
|
+
btn.innerHTML = REPLY_ICON_SVG + '<span>Reply in thread</span>';
|
|
1269
|
+
btn.addEventListener("click", (e) => {
|
|
1270
|
+
e.stopPropagation();
|
|
1271
|
+
createAndOpenNewThread(messageId);
|
|
1272
|
+
});
|
|
1273
|
+
wrap.appendChild(btn);
|
|
1274
|
+
row.appendChild(wrap);
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
threads.forEach((t) => {
|
|
1279
|
+
const pair = document.createElement("span");
|
|
1280
|
+
pair.className = "thread-pill-pair";
|
|
1281
|
+
const pill = document.createElement("button");
|
|
1282
|
+
pill.type = "button";
|
|
1283
|
+
pill.className = "reply-icon-btn thread-pill";
|
|
1284
|
+
pill.title = "Open thread";
|
|
1285
|
+
const replies = t.replyCount || 0;
|
|
1286
|
+
const repliesLabel = replies === 1 ? "1 reply" : replies + " replies";
|
|
1287
|
+
const meta = t.lastReplyAt ? formatRelativeTime(t.lastReplyAt) : "";
|
|
1288
|
+
pill.innerHTML = '<span class="thread-pill-count">' + repliesLabel + '</span>'
|
|
1289
|
+
+ (meta ? '<span class="thread-pill-meta">' + meta + '</span>' : '');
|
|
1290
|
+
pill.addEventListener("click", (e) => {
|
|
1291
|
+
e.stopPropagation();
|
|
1292
|
+
openThread(t.conversationId, t);
|
|
1293
|
+
});
|
|
1294
|
+
pair.appendChild(pill);
|
|
1295
|
+
|
|
1296
|
+
const isConfirming = state.confirmDeleteThreadId === t.conversationId;
|
|
1297
|
+
const del = document.createElement("button");
|
|
1298
|
+
del.type = "button";
|
|
1299
|
+
del.className = "thread-row-delete" + (isConfirming ? " confirming" : "");
|
|
1300
|
+
del.title = isConfirming ? "Click again to confirm" : "Delete thread";
|
|
1301
|
+
del.textContent = isConfirming ? "sure?" : "×";
|
|
1302
|
+
del.addEventListener("click", (e) => {
|
|
1303
|
+
e.stopPropagation();
|
|
1304
|
+
if (!isConfirming) {
|
|
1305
|
+
state.confirmDeleteThreadId = t.conversationId;
|
|
1306
|
+
renderMessages(state.activeMessages, state.isStreaming);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
deleteThreadConfirmed(t.conversationId, messageId);
|
|
1310
|
+
});
|
|
1311
|
+
pair.appendChild(del);
|
|
1312
|
+
wrap.appendChild(pair);
|
|
1313
|
+
});
|
|
1314
|
+
row.appendChild(wrap);
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
// Hoisted so both renderMessages and buildSimpleMessageRow can use it.
|
|
1318
|
+
const createThinkingIndicator = (label) => {
|
|
1319
|
+
const status = document.createElement("div");
|
|
1320
|
+
status.className = "thinking-status";
|
|
1321
|
+
const spinner = document.createElement("span");
|
|
1322
|
+
spinner.className = "thinking-indicator";
|
|
1323
|
+
const starFrames = ["✶", "✸", "✹", "✺", "✹", "✷"];
|
|
1324
|
+
let frame = 0;
|
|
1325
|
+
spinner.textContent = starFrames[0];
|
|
1326
|
+
spinner._interval = setInterval(() => {
|
|
1327
|
+
frame = (frame + 1) % starFrames.length;
|
|
1328
|
+
spinner.textContent = starFrames[frame];
|
|
1329
|
+
}, 70);
|
|
1330
|
+
status.appendChild(spinner);
|
|
1331
|
+
if (label) {
|
|
1332
|
+
const text = document.createElement("span");
|
|
1333
|
+
text.className = "thinking-status-label";
|
|
1334
|
+
text.textContent = label;
|
|
1335
|
+
status.appendChild(text);
|
|
1336
|
+
}
|
|
1337
|
+
return status;
|
|
1338
|
+
};
|
|
1339
|
+
|
|
1340
|
+
// Render a single message into a target column. Mirrors the assistant /
|
|
1341
|
+
// user branches of renderMessages but without the streaming-specific bits.
|
|
1342
|
+
const buildSimpleMessageRow = (m) => {
|
|
1343
|
+
const r = document.createElement("div");
|
|
1344
|
+
r.className = "message-row " + m.role;
|
|
1345
|
+
if (m.role === "assistant") {
|
|
1346
|
+
const wrap = document.createElement("div");
|
|
1347
|
+
wrap.className = "assistant-wrap";
|
|
1348
|
+
wrap.innerHTML = '<div class="assistant-avatar">' + agentInitial + '</div>';
|
|
1349
|
+
const content = document.createElement("div");
|
|
1350
|
+
content.className = "assistant-content";
|
|
1351
|
+
const sections = (m.metadata && m.metadata.sections) || null;
|
|
1352
|
+
const text = typeof m.content === "string" ? m.content : "";
|
|
1353
|
+
if (sections && sections.length > 0) {
|
|
1354
|
+
sections.forEach((section) => {
|
|
1355
|
+
if (section.type === "text") {
|
|
1356
|
+
const textDiv = document.createElement("div");
|
|
1357
|
+
textDiv.innerHTML = renderAssistantMarkdown(section.content);
|
|
1358
|
+
content.appendChild(textDiv);
|
|
1359
|
+
} else if (section.type === "tools") {
|
|
1360
|
+
content.insertAdjacentHTML(
|
|
1361
|
+
"beforeend",
|
|
1362
|
+
renderToolActivity(section.content, [], []),
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
});
|
|
1366
|
+
} else if (m._streaming && !text) {
|
|
1367
|
+
// Empty + streaming → show a thinking indicator until the first
|
|
1368
|
+
// model:chunk lands.
|
|
1369
|
+
content.appendChild(createThinkingIndicator(""));
|
|
1370
|
+
} else {
|
|
1371
|
+
content.innerHTML = renderAssistantMarkdown(text);
|
|
1372
|
+
}
|
|
1373
|
+
wrap.appendChild(content);
|
|
1374
|
+
r.appendChild(wrap);
|
|
1375
|
+
} else {
|
|
1376
|
+
const bubble = document.createElement("div");
|
|
1377
|
+
bubble.className = "user-bubble";
|
|
1378
|
+
if (typeof m.content === "string") {
|
|
1379
|
+
bubble.textContent = m.content;
|
|
1380
|
+
} else if (Array.isArray(m.content)) {
|
|
1381
|
+
const textParts = m.content.filter((p) => p.type === "text").map((p) => p.text).join("");
|
|
1382
|
+
if (textParts) {
|
|
1383
|
+
const textEl = document.createElement("div");
|
|
1384
|
+
textEl.textContent = textParts;
|
|
1385
|
+
bubble.appendChild(textEl);
|
|
1386
|
+
}
|
|
1387
|
+
// File attachments — same logic as the main renderer
|
|
1388
|
+
const fileParts = m.content.filter((p) => p.type === "file");
|
|
1389
|
+
if (fileParts.length > 0) {
|
|
1390
|
+
const filesEl = document.createElement("div");
|
|
1391
|
+
filesEl.className = "user-file-attachments";
|
|
1392
|
+
fileParts.forEach((fp) => {
|
|
1393
|
+
if (fp.mediaType && fp.mediaType.startsWith("image/")) {
|
|
1394
|
+
const img = document.createElement("img");
|
|
1395
|
+
if (fp.data && fp.data.startsWith("poncho-upload://")) {
|
|
1396
|
+
img.src = "/api/uploads/" + encodeURIComponent(fp.data.replace("poncho-upload://", ""));
|
|
1397
|
+
} else if (fp.data && (fp.data.startsWith("http://") || fp.data.startsWith("https://"))) {
|
|
1398
|
+
img.src = fp.data;
|
|
1399
|
+
} else if (fp.data) {
|
|
1400
|
+
img.src = "data:" + fp.mediaType + ";base64," + fp.data;
|
|
1401
|
+
}
|
|
1402
|
+
img.alt = fp.filename || "image";
|
|
1403
|
+
filesEl.appendChild(img);
|
|
1404
|
+
} else {
|
|
1405
|
+
const badge = document.createElement("span");
|
|
1406
|
+
badge.className = "user-file-badge";
|
|
1407
|
+
badge.textContent = "📎 " + (fp.filename || "file");
|
|
1408
|
+
filesEl.appendChild(badge);
|
|
1409
|
+
}
|
|
1410
|
+
});
|
|
1411
|
+
bubble.appendChild(filesEl);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
r.appendChild(bubble);
|
|
1415
|
+
}
|
|
1416
|
+
return r;
|
|
1417
|
+
};
|
|
1418
|
+
|
|
1419
|
+
const renderThreadPanelMessages = () => {
|
|
1420
|
+
const root = elements.threadPanelMessages;
|
|
1421
|
+
if (!root) return;
|
|
1422
|
+
root.innerHTML = "";
|
|
1423
|
+
const msgs = state.threadPanel.messages || [];
|
|
1424
|
+
if (msgs.length === 0) {
|
|
1425
|
+
const empty = document.createElement("div");
|
|
1426
|
+
empty.className = "thread-panel-parent-empty";
|
|
1427
|
+
empty.textContent = "No replies yet — send the first one below.";
|
|
1428
|
+
root.appendChild(empty);
|
|
1429
|
+
} else {
|
|
1430
|
+
const col = document.createElement("div");
|
|
1431
|
+
col.className = "messages-column";
|
|
1432
|
+
msgs.forEach((m) => col.appendChild(buildSimpleMessageRow(m)));
|
|
1433
|
+
root.appendChild(col);
|
|
1434
|
+
}
|
|
1435
|
+
root.scrollTop = root.scrollHeight;
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
const closeThreadPanel = () => {
|
|
1439
|
+
if (state.threadPanel.abortController) {
|
|
1440
|
+
try { state.threadPanel.abortController.abort(); } catch (e) {}
|
|
1441
|
+
}
|
|
1442
|
+
state.threadPanel.open = false;
|
|
1443
|
+
state.threadPanel.threadId = null;
|
|
1444
|
+
state.threadPanel.parentMessageId = null;
|
|
1445
|
+
state.threadPanel.messages = [];
|
|
1446
|
+
state.threadPanel.isStreaming = false;
|
|
1447
|
+
state.threadPanel.abortController = null;
|
|
1448
|
+
state.threadPanel.pendingFiles = [];
|
|
1449
|
+
renderThreadAttachmentPreview();
|
|
1450
|
+
if (elements.threadPrompt) elements.threadPrompt.value = "";
|
|
1451
|
+
if (elements.threadPanel) {
|
|
1452
|
+
elements.threadPanel.style.display = "none";
|
|
1453
|
+
// Clear inline flex set by drag-resize so next open starts fresh.
|
|
1454
|
+
elements.threadPanel.style.flex = "";
|
|
1455
|
+
}
|
|
1456
|
+
if (elements.threadPanelResize) elements.threadPanelResize.style.display = "none";
|
|
1457
|
+
const mainEl = document.querySelector(".main-chat");
|
|
1458
|
+
if (mainEl) {
|
|
1459
|
+
mainEl.classList.remove("has-thread");
|
|
1460
|
+
// Same fix on the main pane.
|
|
1461
|
+
mainEl.style.flex = "";
|
|
1462
|
+
}
|
|
1463
|
+
try {
|
|
1464
|
+
if (window.location.hash.indexOf("thread=") >= 0) {
|
|
1465
|
+
history.replaceState(null, "", window.location.pathname + window.location.search);
|
|
1466
|
+
}
|
|
1467
|
+
} catch (e) {}
|
|
1468
|
+
};
|
|
1469
|
+
|
|
1470
|
+
const renderActiveTopForThreadPanel = (payload) => {
|
|
1471
|
+
const conv = payload.conversation || {};
|
|
1472
|
+
const allMsgs = Array.isArray(conv.messages) ? conv.messages : [];
|
|
1473
|
+
// Show the anchor message + replies. The earlier snapshot is still
|
|
1474
|
+
// part of the thread's context server-side, but the panel only
|
|
1475
|
+
// displays what's relevant: the message you forked on, plus what
|
|
1476
|
+
// came after.
|
|
1477
|
+
const snapshotLength = (conv.threadMeta && typeof conv.threadMeta.snapshotLength === "number")
|
|
1478
|
+
? conv.threadMeta.snapshotLength
|
|
1479
|
+
: allMsgs.length;
|
|
1480
|
+
const startIdx = Math.max(0, snapshotLength - 1);
|
|
1481
|
+
state.threadPanel.messages = allMsgs.slice(startIdx);
|
|
1482
|
+
renderThreadPanelMessages();
|
|
1483
|
+
};
|
|
1484
|
+
|
|
1485
|
+
const buildAuthHeaders = () => {
|
|
1486
|
+
const headers = {};
|
|
1487
|
+
if (state.tenantToken) {
|
|
1488
|
+
headers["Authorization"] = "Bearer " + state.tenantToken;
|
|
1489
|
+
} else if (state.csrfToken) {
|
|
1490
|
+
headers["x-csrf-token"] = state.csrfToken;
|
|
1491
|
+
}
|
|
1492
|
+
return headers;
|
|
1493
|
+
};
|
|
1494
|
+
|
|
1495
|
+
const subscribeThreadPanelStream = (threadId) => {
|
|
1496
|
+
// Scoped, duplicated SSE handler — independent of the main-pane
|
|
1497
|
+
// subscription so a regression here can't break the main conversation.
|
|
1498
|
+
if (state.threadPanel.abortController) {
|
|
1499
|
+
try { state.threadPanel.abortController.abort(); } catch (e) {}
|
|
1500
|
+
}
|
|
1501
|
+
const ac = new AbortController();
|
|
1502
|
+
state.threadPanel.abortController = ac;
|
|
1503
|
+
const url = "/api/conversations/" + encodeURIComponent(threadId) + "/events?live_only=true";
|
|
1504
|
+
fetch(url, {
|
|
1505
|
+
headers: buildAuthHeaders(),
|
|
1506
|
+
signal: ac.signal,
|
|
1507
|
+
credentials: state.tenantToken ? "omit" : "include",
|
|
1508
|
+
}).then(async (resp) => {
|
|
1509
|
+
if (!resp.ok || !resp.body) return;
|
|
1510
|
+
const reader = resp.body.getReader();
|
|
1511
|
+
const decoder = new TextDecoder();
|
|
1512
|
+
let buf = "";
|
|
1513
|
+
while (true) {
|
|
1514
|
+
const { value, done } = await reader.read();
|
|
1515
|
+
if (done) break;
|
|
1516
|
+
buf += decoder.decode(value, { stream: true });
|
|
1517
|
+
const events = buf.split("\\n\\n");
|
|
1518
|
+
buf = events.pop() || "";
|
|
1519
|
+
for (const block of events) {
|
|
1520
|
+
if (!block) continue;
|
|
1521
|
+
const dataLine = block.split("\\n").find((l) => l.startsWith("data: "));
|
|
1522
|
+
if (!dataLine) continue;
|
|
1523
|
+
if (state.threadPanel.threadId !== threadId) continue;
|
|
1524
|
+
try {
|
|
1525
|
+
const evt = JSON.parse(dataLine.slice(6));
|
|
1526
|
+
if (evt.type === "run:completed" || evt.type === "messages:updated" || evt.type === "messages:appended" || evt.type === "run:cancelled") {
|
|
1527
|
+
const fresh = await api("/api/conversations/" + encodeURIComponent(threadId)).catch(() => null);
|
|
1528
|
+
if (fresh && state.threadPanel.threadId === threadId) {
|
|
1529
|
+
renderActiveTopForThreadPanel(fresh);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
} catch (e) { /* ignore parse errors */ }
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
}).catch(() => { /* aborted or failed; no-op */ });
|
|
1536
|
+
};
|
|
1537
|
+
|
|
1538
|
+
const openThread = async (threadId, summary) => {
|
|
1539
|
+
try {
|
|
1540
|
+
const payload = await api("/api/conversations/" + encodeURIComponent(threadId));
|
|
1541
|
+
state.threadPanel.open = true;
|
|
1542
|
+
state.threadPanel.threadId = threadId;
|
|
1543
|
+
state.threadPanel.parentMessageId = (summary && summary.parentMessageId) || null;
|
|
1544
|
+
renderActiveTopForThreadPanel(payload);
|
|
1545
|
+
if (elements.threadPanel) elements.threadPanel.style.display = "flex";
|
|
1546
|
+
if (elements.threadPanelResize) elements.threadPanelResize.style.display = "block";
|
|
1547
|
+
const mainEl = document.querySelector(".main-chat");
|
|
1548
|
+
if (mainEl) mainEl.classList.add("has-thread");
|
|
1549
|
+
if (elements.threadPrompt) elements.threadPrompt.focus();
|
|
1550
|
+
try {
|
|
1551
|
+
history.replaceState(null, "", window.location.pathname + window.location.search + "#thread=" + encodeURIComponent(threadId));
|
|
1552
|
+
} catch (e) {}
|
|
1553
|
+
subscribeThreadPanelStream(threadId);
|
|
1554
|
+
} catch (e) {
|
|
1555
|
+
alert("Failed to load thread: " + (e && e.message ? e.message : "unknown"));
|
|
1556
|
+
}
|
|
1557
|
+
};
|
|
1558
|
+
|
|
1559
|
+
const createAndOpenNewThread = async (parentMessageId) => {
|
|
1560
|
+
const conversationId = state.activeConversationId;
|
|
1561
|
+
if (!conversationId) return;
|
|
1562
|
+
try {
|
|
1563
|
+
const resp = await api(
|
|
1564
|
+
"/api/conversations/" + encodeURIComponent(conversationId) + "/threads",
|
|
1565
|
+
{ method: "POST", body: JSON.stringify({ parentMessageId }) },
|
|
1566
|
+
);
|
|
1567
|
+
const summary = resp.thread;
|
|
1568
|
+
const list = state.threadsByParent[parentMessageId] || [];
|
|
1569
|
+
state.threadsByParent[parentMessageId] = list.concat([summary]);
|
|
1570
|
+
renderMessages(state.activeMessages, state.isStreaming);
|
|
1571
|
+
await openThread(summary.conversationId, summary);
|
|
1572
|
+
} catch (e) {
|
|
1573
|
+
const code = e && e.payload && e.payload.code;
|
|
1574
|
+
const msg = code || (e && e.message ? e.message : "unknown");
|
|
1575
|
+
alert("Failed to create thread: " + msg);
|
|
1576
|
+
}
|
|
1577
|
+
};
|
|
1578
|
+
|
|
1579
|
+
const refreshThreads = async () => {
|
|
1580
|
+
const conversationId = state.activeConversationId;
|
|
1581
|
+
if (!conversationId) {
|
|
1582
|
+
state.threadsByParent = {};
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
try {
|
|
1586
|
+
const data = await api("/api/conversations/" + encodeURIComponent(conversationId) + "/threads");
|
|
1587
|
+
const grouped = {};
|
|
1588
|
+
(data.threads || []).forEach((t) => {
|
|
1589
|
+
if (!t.parentMessageId) return;
|
|
1590
|
+
(grouped[t.parentMessageId] = grouped[t.parentMessageId] || []).push(t);
|
|
1591
|
+
});
|
|
1592
|
+
state.threadsByParent = grouped;
|
|
1593
|
+
} catch (e) { /* keep existing */ }
|
|
1594
|
+
};
|
|
1595
|
+
|
|
1596
|
+
const refreshActiveMessagesFromServer = async (conversationId) => {
|
|
1597
|
+
if (!conversationId) return;
|
|
1598
|
+
try {
|
|
1599
|
+
const payload = await api("/api/conversations/" + encodeURIComponent(conversationId));
|
|
1600
|
+
if (state.activeConversationId !== conversationId) return;
|
|
1601
|
+
let displayMessages = (payload.conversation && payload.conversation.messages) || [];
|
|
1602
|
+
const compactedHistory = payload.conversation && payload.conversation.compactedHistory;
|
|
1603
|
+
if (Array.isArray(compactedHistory) && compactedHistory.length > 0) {
|
|
1604
|
+
let dividerMsg = { role: "user", content: "", metadata: { isCompactionSummary: true } };
|
|
1605
|
+
const summaryMsg = displayMessages.find((m) => m.metadata && m.metadata.isCompactionSummary);
|
|
1606
|
+
if (summaryMsg) {
|
|
1607
|
+
dividerMsg = summaryMsg;
|
|
1608
|
+
displayMessages = displayMessages.filter((m) => m !== summaryMsg);
|
|
1609
|
+
}
|
|
1610
|
+
displayMessages = [].concat(compactedHistory, [dividerMsg], displayMessages);
|
|
1611
|
+
}
|
|
1612
|
+
state.activeMessages = displayMessages;
|
|
1613
|
+
await refreshThreads();
|
|
1614
|
+
renderMessages(state.activeMessages, false);
|
|
1615
|
+
} catch (e) {
|
|
1616
|
+
// Best-effort refresh — silent on failure
|
|
1617
|
+
}
|
|
1618
|
+
};
|
|
1619
|
+
|
|
1620
|
+
const submitThreadReply = async (text, files) => {
|
|
1621
|
+
const threadId = state.threadPanel.threadId;
|
|
1622
|
+
const messageText = (text || "").trim();
|
|
1623
|
+
const filesToSend = Array.isArray(files) ? files : [];
|
|
1624
|
+
if (!threadId || (!messageText && filesToSend.length === 0)) return;
|
|
1625
|
+
|
|
1626
|
+
// Build the optimistic user message with file ContentParts so the
|
|
1627
|
+
// panel can show attachments immediately, matching the main pane.
|
|
1628
|
+
let optimisticContent;
|
|
1629
|
+
if (filesToSend.length > 0) {
|
|
1630
|
+
optimisticContent = [{ type: "text", text: messageText }];
|
|
1631
|
+
for (const f of filesToSend) {
|
|
1632
|
+
optimisticContent.push({
|
|
1633
|
+
type: "file",
|
|
1634
|
+
data: URL.createObjectURL(f),
|
|
1635
|
+
mediaType: f.type,
|
|
1636
|
+
filename: f.name,
|
|
1637
|
+
_localBlob: f,
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
} else {
|
|
1641
|
+
optimisticContent = messageText;
|
|
1642
|
+
}
|
|
1643
|
+
// Optimistic user + empty assistant placeholder — model:chunk events
|
|
1644
|
+
// from the POST /messages SSE stream will fill the assistant content.
|
|
1645
|
+
const optimisticAssistant = { role: "assistant", content: "", _streaming: true };
|
|
1646
|
+
state.threadPanel.messages = (state.threadPanel.messages || []).concat([
|
|
1647
|
+
{ role: "user", content: optimisticContent },
|
|
1648
|
+
optimisticAssistant,
|
|
1649
|
+
]);
|
|
1650
|
+
renderThreadPanelMessages();
|
|
1651
|
+
|
|
1652
|
+
// Optimistic bump on the inline thread-row reply count in the main pane.
|
|
1653
|
+
if (state.threadPanel.parentMessageId) {
|
|
1654
|
+
const list = state.threadsByParent[state.threadPanel.parentMessageId] || [];
|
|
1655
|
+
const idx = list.findIndex((t) => t.conversationId === threadId);
|
|
1656
|
+
if (idx >= 0) {
|
|
1657
|
+
list[idx] = { ...list[idx], replyCount: (list[idx].replyCount || 0) + 1, lastReplyAt: Date.now() };
|
|
1658
|
+
state.threadsByParent[state.threadPanel.parentMessageId] = list;
|
|
1659
|
+
renderMessages(state.activeMessages, state.isStreaming);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// Build the request body — FormData when files are present, JSON otherwise.
|
|
1664
|
+
let fetchOpts;
|
|
1665
|
+
if (filesToSend.length > 0) {
|
|
1666
|
+
const formData = new FormData();
|
|
1667
|
+
formData.append("message", messageText);
|
|
1668
|
+
for (const f of filesToSend) {
|
|
1669
|
+
formData.append("files", f, f.name);
|
|
1670
|
+
}
|
|
1671
|
+
fetchOpts = {
|
|
1672
|
+
method: "POST",
|
|
1673
|
+
headers: buildAuthHeaders(),
|
|
1674
|
+
credentials: state.tenantToken ? "omit" : "include",
|
|
1675
|
+
body: formData,
|
|
1676
|
+
};
|
|
1677
|
+
} else {
|
|
1678
|
+
fetchOpts = {
|
|
1679
|
+
method: "POST",
|
|
1680
|
+
headers: { ...buildAuthHeaders(), "Content-Type": "application/json" },
|
|
1681
|
+
credentials: state.tenantToken ? "omit" : "include",
|
|
1682
|
+
body: JSON.stringify({ message: messageText }),
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
try {
|
|
1687
|
+
const resp = await fetch(
|
|
1688
|
+
"/api/conversations/" + encodeURIComponent(threadId) + "/messages",
|
|
1689
|
+
fetchOpts,
|
|
1690
|
+
);
|
|
1691
|
+
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
|
1692
|
+
// Stream the SSE body — incrementally append model:chunk text to the
|
|
1693
|
+
// optimistic assistant message so the user sees tokens land live.
|
|
1694
|
+
if (resp.body) {
|
|
1695
|
+
const reader = resp.body.getReader();
|
|
1696
|
+
const decoder = new TextDecoder();
|
|
1697
|
+
let buffer = "";
|
|
1698
|
+
let chunkCount = 0;
|
|
1699
|
+
while (true) {
|
|
1700
|
+
const { value, done } = await reader.read();
|
|
1701
|
+
if (done) break;
|
|
1702
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1703
|
+
buffer = parseSseChunk(buffer, (eventName, payload) => {
|
|
1704
|
+
if (state.threadPanel.threadId !== threadId) return;
|
|
1705
|
+
if (eventName === "model:chunk") {
|
|
1706
|
+
const chunk = String((payload && payload.content) || "");
|
|
1707
|
+
if (!chunk) return;
|
|
1708
|
+
chunkCount += 1;
|
|
1709
|
+
optimisticAssistant._streaming = true;
|
|
1710
|
+
optimisticAssistant.content = String(optimisticAssistant.content || "") + chunk;
|
|
1711
|
+
renderThreadPanelMessages();
|
|
1712
|
+
}
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
if (chunkCount === 0) {
|
|
1716
|
+
console.warn("[thread] no model:chunk events received — server may be buffering the response");
|
|
1717
|
+
} else {
|
|
1718
|
+
console.debug("[thread] streamed " + chunkCount + " chunks");
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
// After the run completes, refetch so the panel reflects canonical
|
|
1722
|
+
// server state (including any tool sections/metadata we didn't
|
|
1723
|
+
// incrementally render here).
|
|
1724
|
+
const fresh = await api("/api/conversations/" + encodeURIComponent(threadId)).catch(() => null);
|
|
1725
|
+
if (fresh && state.threadPanel.threadId === threadId) {
|
|
1726
|
+
renderActiveTopForThreadPanel(fresh);
|
|
1727
|
+
}
|
|
1728
|
+
} catch (e) {
|
|
1729
|
+
// Drop the streaming placeholder if the post failed before producing any text.
|
|
1730
|
+
if (!optimisticAssistant.content) {
|
|
1731
|
+
state.threadPanel.messages = (state.threadPanel.messages || []).filter(
|
|
1732
|
+
(m) => m !== optimisticAssistant,
|
|
1733
|
+
);
|
|
1734
|
+
renderThreadPanelMessages();
|
|
1735
|
+
}
|
|
1736
|
+
alert("Failed to send reply: " + (e && e.message ? e.message : "unknown"));
|
|
1737
|
+
}
|
|
1738
|
+
};
|
|
1739
|
+
|
|
1189
1740
|
const renderMessages = (messages, isStreaming = false, options = {}) => {
|
|
1190
1741
|
const previousScrollTop = elements.messages.scrollTop;
|
|
1191
1742
|
const shouldStickToBottom =
|
|
1192
1743
|
options.forceScrollBottom === true || state.isMessagesPinnedToBottom;
|
|
1193
1744
|
|
|
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
1745
|
// Preserve open state of tool-activity disclosures across re-renders.
|
|
1217
1746
|
// Track by message row index + disclosure index within the row.
|
|
1218
1747
|
const openDisclosures = new Map();
|
|
@@ -1272,12 +1801,7 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
1272
1801
|
(!Array.isArray(m._currentTools) || m._currentTools.length === 0) &&
|
|
1273
1802
|
!hasPendingApprovals;
|
|
1274
1803
|
|
|
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) {
|
|
1804
|
+
if (shouldRenderEmptyStreamingIndicator && !m._error) {
|
|
1281
1805
|
content.appendChild(createThinkingIndicator(getThinkingStatusLabel(m)));
|
|
1282
1806
|
} else {
|
|
1283
1807
|
// Merge stored sections (persisted) with live sections (from
|
|
@@ -1340,12 +1864,18 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
1340
1864
|
renderToolActivity([], pendingApprovals, m._toolImages || []),
|
|
1341
1865
|
);
|
|
1342
1866
|
}
|
|
1343
|
-
if (isStreaming && isLastAssistant && !hasPendingApprovals) {
|
|
1867
|
+
if (isStreaming && isLastAssistant && !hasPendingApprovals && !m._error) {
|
|
1344
1868
|
const waitIndicator = document.createElement("div");
|
|
1345
1869
|
waitIndicator.appendChild(createThinkingIndicator(getThinkingStatusLabel(m)));
|
|
1346
1870
|
content.appendChild(waitIndicator);
|
|
1347
1871
|
}
|
|
1348
1872
|
}
|
|
1873
|
+
if (m._error) {
|
|
1874
|
+
const errorEl = document.createElement("div");
|
|
1875
|
+
errorEl.className = "message-error";
|
|
1876
|
+
errorEl.innerHTML = "<strong>Error</strong><br>" + escapeHtml(m._error);
|
|
1877
|
+
content.appendChild(errorEl);
|
|
1878
|
+
}
|
|
1349
1879
|
wrap.appendChild(content);
|
|
1350
1880
|
row.appendChild(wrap);
|
|
1351
1881
|
} else if (m.metadata && m.metadata._subagentCallback) {
|
|
@@ -1423,6 +1953,7 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
1423
1953
|
}
|
|
1424
1954
|
row.appendChild(bubble);
|
|
1425
1955
|
}
|
|
1956
|
+
appendThreadAffordances(row, m);
|
|
1426
1957
|
col.appendChild(row);
|
|
1427
1958
|
});
|
|
1428
1959
|
elements.messages.appendChild(col);
|
|
@@ -1459,11 +1990,15 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
1459
1990
|
|
|
1460
1991
|
const loadConversation = async (conversationId) => {
|
|
1461
1992
|
if (window._resetBrowserPanel) window._resetBrowserPanel();
|
|
1462
|
-
//
|
|
1463
|
-
|
|
1993
|
+
// Switching conversations always closes any open thread panel.
|
|
1994
|
+
closeThreadPanel();
|
|
1995
|
+
// Kick off conversation + todos + threads fetches in parallel — they
|
|
1996
|
+
// only need the id, so there's no reason to wait for the conversation.
|
|
1464
1997
|
const conversationPromise = api("/api/conversations/" + encodeURIComponent(conversationId));
|
|
1465
1998
|
const todosPromise = api("/api/conversations/" + encodeURIComponent(conversationId) + "/todos")
|
|
1466
1999
|
.catch(() => ({ todos: [] }));
|
|
2000
|
+
const threadsPromise = api("/api/conversations/" + encodeURIComponent(conversationId) + "/threads")
|
|
2001
|
+
.catch(() => ({ threads: [] }));
|
|
1467
2002
|
const payload = await conversationPromise;
|
|
1468
2003
|
elements.chatTitle.textContent = payload.conversation.title;
|
|
1469
2004
|
// Merge own pending approvals + subagent pending approvals
|
|
@@ -1517,6 +2052,19 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
1517
2052
|
_autoCollapseTodos();
|
|
1518
2053
|
renderTodoPanel();
|
|
1519
2054
|
|
|
2055
|
+
// Group thread summaries by parentMessageId for inline rendering.
|
|
2056
|
+
try {
|
|
2057
|
+
const threadsPayload = await threadsPromise;
|
|
2058
|
+
const grouped = {};
|
|
2059
|
+
(threadsPayload.threads || []).forEach((t) => {
|
|
2060
|
+
if (!t.parentMessageId) return;
|
|
2061
|
+
(grouped[t.parentMessageId] = grouped[t.parentMessageId] || []).push(t);
|
|
2062
|
+
});
|
|
2063
|
+
state.threadsByParent = grouped;
|
|
2064
|
+
} catch (e) {
|
|
2065
|
+
state.threadsByParent = {};
|
|
2066
|
+
}
|
|
2067
|
+
|
|
1520
2068
|
updateContextRing();
|
|
1521
2069
|
var willStream = !!payload.hasActiveRun;
|
|
1522
2070
|
var hasSendMessageStream = state.activeStreamConversationId === conversationId && state._activeStreamMessages;
|
|
@@ -1526,6 +2074,21 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
1526
2074
|
} else {
|
|
1527
2075
|
renderMessages(state.activeMessages, willStream, { forceScrollBottom: true });
|
|
1528
2076
|
}
|
|
2077
|
+
// If the URL has #thread=<id>, reopen that thread panel after main render.
|
|
2078
|
+
try {
|
|
2079
|
+
const hash = window.location.hash || "";
|
|
2080
|
+
const m = hash.match(/thread=([^&]+)/);
|
|
2081
|
+
if (m && m[1]) {
|
|
2082
|
+
const threadId = decodeURIComponent(m[1]);
|
|
2083
|
+
// Find the matching summary so we can pin parentMessageId.
|
|
2084
|
+
let summary = null;
|
|
2085
|
+
for (const k of Object.keys(state.threadsByParent)) {
|
|
2086
|
+
const found = (state.threadsByParent[k] || []).find((t) => t.conversationId === threadId);
|
|
2087
|
+
if (found) { summary = found; break; }
|
|
2088
|
+
}
|
|
2089
|
+
if (summary) openThread(threadId, summary);
|
|
2090
|
+
}
|
|
2091
|
+
} catch (e) { /* ignore */ }
|
|
1529
2092
|
if (!state.viewingSubagentId) {
|
|
1530
2093
|
elements.prompt.focus();
|
|
1531
2094
|
}
|
|
@@ -1707,43 +2270,65 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
1707
2270
|
});
|
|
1708
2271
|
};
|
|
1709
2272
|
|
|
2273
|
+
// Fetch the full conversation and sync UI state. Extracted so both
|
|
2274
|
+
// poll loops can call it only when the cheap /status endpoint shows
|
|
2275
|
+
// something has actually changed.
|
|
2276
|
+
const refetchConversationAndRender = async (conversationId, streaming) => {
|
|
2277
|
+
const payload = await api("/api/conversations/" + encodeURIComponent(conversationId));
|
|
2278
|
+
if (state.activeConversationId !== conversationId || !payload.conversation) return payload;
|
|
2279
|
+
var allPending = [].concat(payload.conversation.pendingApprovals || []);
|
|
2280
|
+
if (Array.isArray(payload.subagentPendingApprovals)) {
|
|
2281
|
+
payload.subagentPendingApprovals.forEach(function(sa) {
|
|
2282
|
+
var subIdShort = sa.subagentId && sa.subagentId.length > 12 ? sa.subagentId.slice(0, 12) + "..." : (sa.subagentId || "");
|
|
2283
|
+
allPending.push({
|
|
2284
|
+
approvalId: sa.approvalId,
|
|
2285
|
+
tool: sa.tool,
|
|
2286
|
+
input: sa.input,
|
|
2287
|
+
_subagentId: sa.subagentId,
|
|
2288
|
+
_subagentLabel: "subagent " + subIdShort,
|
|
2289
|
+
});
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
state.activeMessages = hydratePendingApprovals(
|
|
2293
|
+
payload.conversation.messages || [],
|
|
2294
|
+
allPending,
|
|
2295
|
+
);
|
|
2296
|
+
if (typeof payload.conversation.contextTokens === "number") {
|
|
2297
|
+
state.contextTokens = payload.conversation.contextTokens;
|
|
2298
|
+
}
|
|
2299
|
+
if (typeof payload.conversation.contextWindow === "number" && payload.conversation.contextWindow > 0) {
|
|
2300
|
+
state.contextWindow = payload.conversation.contextWindow;
|
|
2301
|
+
}
|
|
2302
|
+
updateContextRing();
|
|
2303
|
+
renderMessages(state.activeMessages, streaming);
|
|
2304
|
+
return payload;
|
|
2305
|
+
};
|
|
2306
|
+
|
|
1710
2307
|
const pollUntilRunIdle = (conversationId) => {
|
|
2308
|
+
let lastUpdatedAt = 0;
|
|
2309
|
+
let lastMessageCount = -1;
|
|
2310
|
+
let lastPendingSignature = "";
|
|
1711
2311
|
const poll = async () => {
|
|
1712
2312
|
if (state.activeConversationId !== conversationId) return;
|
|
1713
2313
|
try {
|
|
1714
|
-
|
|
2314
|
+
// Cheap status check — no data blob, no archive, no messages.
|
|
2315
|
+
const status = await api("/api/conversations/" + encodeURIComponent(conversationId) + "/status");
|
|
1715
2316
|
if (state.activeConversationId !== conversationId) return;
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
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);
|
|
2317
|
+
const pendingSignature =
|
|
2318
|
+
(status.hasPendingApprovals ? 1 : 0) + ":" + (status.subagentPendingApprovalsCount || 0);
|
|
2319
|
+
const changed =
|
|
2320
|
+
status.updatedAt > lastUpdatedAt ||
|
|
2321
|
+
status.messageCount !== lastMessageCount ||
|
|
2322
|
+
pendingSignature !== lastPendingSignature;
|
|
2323
|
+
if (changed) {
|
|
2324
|
+
lastUpdatedAt = status.updatedAt;
|
|
2325
|
+
lastMessageCount = status.messageCount;
|
|
2326
|
+
lastPendingSignature = pendingSignature;
|
|
2327
|
+
await refetchConversationAndRender(conversationId, status.hasActiveRun);
|
|
2328
|
+
if (state.activeConversationId !== conversationId) return;
|
|
1744
2329
|
}
|
|
1745
|
-
if (
|
|
1746
|
-
if (
|
|
2330
|
+
if (status.hasActiveRun || status.hasRunningSubagents) {
|
|
2331
|
+
if (status.hasActiveRun && window._connectBrowserStream) window._connectBrowserStream();
|
|
1747
2332
|
setTimeout(poll, 2000);
|
|
1748
2333
|
} else {
|
|
1749
2334
|
setStreaming(false);
|
|
@@ -1762,49 +2347,66 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
1762
2347
|
state.subagentPollInFlight[conversationId] = true;
|
|
1763
2348
|
let lastMessageCount = state.activeMessages ? state.activeMessages.length : 0;
|
|
1764
2349
|
let lastUpdatedAt = 0;
|
|
2350
|
+
let lastPendingSignature = "";
|
|
2351
|
+
let streamingCallback = false;
|
|
1765
2352
|
const poll = async () => {
|
|
1766
2353
|
if (state.activeConversationId !== conversationId) {
|
|
1767
2354
|
delete state.subagentPollInFlight[conversationId];
|
|
1768
2355
|
return;
|
|
1769
2356
|
}
|
|
1770
2357
|
try {
|
|
1771
|
-
|
|
2358
|
+
const status = await api("/api/conversations/" + encodeURIComponent(conversationId) + "/status");
|
|
1772
2359
|
if (state.activeConversationId !== conversationId) return;
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
2360
|
+
const pendingSignature =
|
|
2361
|
+
(status.hasPendingApprovals ? 1 : 0) + ":" + (status.subagentPendingApprovalsCount || 0);
|
|
2362
|
+
const changed =
|
|
2363
|
+
status.messageCount > lastMessageCount ||
|
|
2364
|
+
status.updatedAt > lastUpdatedAt ||
|
|
2365
|
+
pendingSignature !== lastPendingSignature;
|
|
2366
|
+
if (changed) {
|
|
2367
|
+
lastMessageCount = status.messageCount;
|
|
2368
|
+
lastUpdatedAt = status.updatedAt;
|
|
2369
|
+
lastPendingSignature = pendingSignature;
|
|
2370
|
+
await refetchConversationAndRender(conversationId, status.hasActiveRun || status.hasRunningSubagents);
|
|
2371
|
+
if (state.activeConversationId !== conversationId) return;
|
|
2372
|
+
}
|
|
2373
|
+
if (status.hasActiveRun && !streamingCallback) {
|
|
2374
|
+
// The parent callback run is active — subscribe to the SSE
|
|
2375
|
+
// event stream so the response streams live instead of only
|
|
2376
|
+
// appearing after the run finishes.
|
|
2377
|
+
streamingCallback = true;
|
|
2378
|
+
// Refetch so the injected subagent result message is visible
|
|
2379
|
+
// before we start streaming the assistant's response.
|
|
2380
|
+
await refetchConversationAndRender(conversationId, true);
|
|
2381
|
+
if (state.activeConversationId !== conversationId) {
|
|
2382
|
+
delete state.subagentPollInFlight[conversationId];
|
|
2383
|
+
return;
|
|
1789
2384
|
}
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
2385
|
+
lastMessageCount = status.messageCount;
|
|
2386
|
+
lastUpdatedAt = status.updatedAt;
|
|
2387
|
+
setStreaming(true);
|
|
2388
|
+
try {
|
|
2389
|
+
await streamConversationEvents(conversationId, { liveOnly: true });
|
|
2390
|
+
} catch {}
|
|
2391
|
+
streamingCallback = false;
|
|
2392
|
+
// After the stream ends, update counts and resume polling
|
|
2393
|
+
// to catch any remaining subagent work.
|
|
2394
|
+
const fresh = await api("/api/conversations/" + encodeURIComponent(conversationId) + "/status").catch(function() { return null; });
|
|
2395
|
+
if (fresh) {
|
|
2396
|
+
lastMessageCount = fresh.messageCount;
|
|
2397
|
+
lastUpdatedAt = fresh.updatedAt;
|
|
1797
2398
|
}
|
|
1798
|
-
if (
|
|
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();
|
|
2399
|
+
if (state.activeConversationId !== conversationId) {
|
|
1806
2400
|
delete state.subagentPollInFlight[conversationId];
|
|
2401
|
+
return;
|
|
1807
2402
|
}
|
|
2403
|
+
setTimeout(poll, 1000);
|
|
2404
|
+
} else if (status.hasActiveRun || status.hasRunningSubagents) {
|
|
2405
|
+
setTimeout(poll, 2000);
|
|
2406
|
+
} else {
|
|
2407
|
+
renderMessages(state.activeMessages, false);
|
|
2408
|
+
await loadConversations();
|
|
2409
|
+
delete state.subagentPollInFlight[conversationId];
|
|
1808
2410
|
}
|
|
1809
2411
|
} catch {
|
|
1810
2412
|
// Polling error; retry
|
|
@@ -3174,6 +3776,11 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
3174
3776
|
setStreaming(false);
|
|
3175
3777
|
if (didCompact && conversationId) {
|
|
3176
3778
|
loadConversation(conversationId).catch(function() {});
|
|
3779
|
+
} else if (conversationId) {
|
|
3780
|
+
// After a normal turn, replace the locally-built activeMessages
|
|
3781
|
+
// (which lack metadata.id) with the server's persisted version so
|
|
3782
|
+
// the "Reply in thread" affordance and other id-based features work.
|
|
3783
|
+
refreshActiveMessagesFromServer(conversationId).catch(function() {});
|
|
3177
3784
|
}
|
|
3178
3785
|
elements.prompt.focus();
|
|
3179
3786
|
}
|
|
@@ -3522,6 +4129,146 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
3522
4129
|
}
|
|
3523
4130
|
});
|
|
3524
4131
|
|
|
4132
|
+
if (elements.threadPanelClose) {
|
|
4133
|
+
elements.threadPanelClose.addEventListener("click", () => {
|
|
4134
|
+
closeThreadPanel();
|
|
4135
|
+
});
|
|
4136
|
+
}
|
|
4137
|
+
|
|
4138
|
+
// ── Thread composer (separate from the main composer) ──
|
|
4139
|
+
const renderThreadAttachmentPreview = () => {
|
|
4140
|
+
const el = elements.threadAttachmentPreview;
|
|
4141
|
+
if (!el) return;
|
|
4142
|
+
const files = state.threadPanel.pendingFiles || [];
|
|
4143
|
+
if (files.length === 0) {
|
|
4144
|
+
el.style.display = "none";
|
|
4145
|
+
el.innerHTML = "";
|
|
4146
|
+
return;
|
|
4147
|
+
}
|
|
4148
|
+
el.style.display = "";
|
|
4149
|
+
el.innerHTML = files.map((f, i) => {
|
|
4150
|
+
const isImage = f.type && f.type.startsWith("image/");
|
|
4151
|
+
const preview = isImage
|
|
4152
|
+
? '<img src="' + URL.createObjectURL(f) + '" />'
|
|
4153
|
+
: '<span class="user-file-badge">📎 ' + escapeHtml(f.name) + '</span>';
|
|
4154
|
+
return '<div class="attachment-item">' + preview
|
|
4155
|
+
+ '<button type="button" class="remove-attachment" data-idx="' + i + '">×</button></div>';
|
|
4156
|
+
}).join("");
|
|
4157
|
+
};
|
|
4158
|
+
|
|
4159
|
+
const addThreadFiles = (fileList) => {
|
|
4160
|
+
const arr = Array.from(fileList || []);
|
|
4161
|
+
for (const f of arr) {
|
|
4162
|
+
if (f.size > 25 * 1024 * 1024) {
|
|
4163
|
+
alert("File too large: " + f.name + " (max 25MB)");
|
|
4164
|
+
continue;
|
|
4165
|
+
}
|
|
4166
|
+
state.threadPanel.pendingFiles.push(f);
|
|
4167
|
+
}
|
|
4168
|
+
renderThreadAttachmentPreview();
|
|
4169
|
+
};
|
|
4170
|
+
|
|
4171
|
+
const autoResizeThreadPrompt = () => {
|
|
4172
|
+
const el = elements.threadPrompt;
|
|
4173
|
+
if (!el) return;
|
|
4174
|
+
el.style.height = "auto";
|
|
4175
|
+
el.style.height = Math.min(el.scrollHeight, 200) + "px";
|
|
4176
|
+
};
|
|
4177
|
+
|
|
4178
|
+
if (elements.threadAttachBtn && elements.threadFileInput) {
|
|
4179
|
+
elements.threadAttachBtn.addEventListener("click", () => elements.threadFileInput.click());
|
|
4180
|
+
elements.threadFileInput.addEventListener("change", () => {
|
|
4181
|
+
if (elements.threadFileInput.files && elements.threadFileInput.files.length > 0) {
|
|
4182
|
+
addThreadFiles(elements.threadFileInput.files);
|
|
4183
|
+
elements.threadFileInput.value = "";
|
|
4184
|
+
}
|
|
4185
|
+
});
|
|
4186
|
+
}
|
|
4187
|
+
if (elements.threadAttachmentPreview) {
|
|
4188
|
+
elements.threadAttachmentPreview.addEventListener("click", (e) => {
|
|
4189
|
+
const rm = e.target.closest(".remove-attachment");
|
|
4190
|
+
if (rm) {
|
|
4191
|
+
const idx = parseInt(rm.dataset.idx, 10);
|
|
4192
|
+
state.threadPanel.pendingFiles.splice(idx, 1);
|
|
4193
|
+
renderThreadAttachmentPreview();
|
|
4194
|
+
}
|
|
4195
|
+
});
|
|
4196
|
+
}
|
|
4197
|
+
if (elements.threadPrompt) {
|
|
4198
|
+
elements.threadPrompt.addEventListener("input", autoResizeThreadPrompt);
|
|
4199
|
+
elements.threadPrompt.addEventListener("paste", (e) => {
|
|
4200
|
+
const items = e.clipboardData && e.clipboardData.items;
|
|
4201
|
+
if (!items) return;
|
|
4202
|
+
const files = [];
|
|
4203
|
+
for (let i = 0; i < items.length; i++) {
|
|
4204
|
+
if (items[i].kind === "file") {
|
|
4205
|
+
const f = items[i].getAsFile();
|
|
4206
|
+
if (f) files.push(f);
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
if (files.length > 0) {
|
|
4210
|
+
e.preventDefault();
|
|
4211
|
+
addThreadFiles(files);
|
|
4212
|
+
}
|
|
4213
|
+
});
|
|
4214
|
+
elements.threadPrompt.addEventListener("keydown", (e) => {
|
|
4215
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
4216
|
+
e.preventDefault();
|
|
4217
|
+
elements.threadComposer.requestSubmit();
|
|
4218
|
+
}
|
|
4219
|
+
});
|
|
4220
|
+
}
|
|
4221
|
+
if (elements.threadComposer) {
|
|
4222
|
+
elements.threadComposer.addEventListener("submit", async (event) => {
|
|
4223
|
+
event.preventDefault();
|
|
4224
|
+
if (!state.threadPanel.open || !state.threadPanel.threadId) return;
|
|
4225
|
+
const value = elements.threadPrompt.value;
|
|
4226
|
+
const filesToSend = [...state.threadPanel.pendingFiles];
|
|
4227
|
+
if (!value.trim() && filesToSend.length === 0) return;
|
|
4228
|
+
elements.threadPrompt.value = "";
|
|
4229
|
+
state.threadPanel.pendingFiles = [];
|
|
4230
|
+
renderThreadAttachmentPreview();
|
|
4231
|
+
autoResizeThreadPrompt();
|
|
4232
|
+
await submitThreadReply(value, filesToSend);
|
|
4233
|
+
});
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
// Drag-to-resize between main pane and thread panel — mirrors the
|
|
4237
|
+
// browser-panel resize pattern.
|
|
4238
|
+
(function () {
|
|
4239
|
+
const handle = elements.threadPanelResize;
|
|
4240
|
+
const panel = elements.threadPanel;
|
|
4241
|
+
const mainEl = document.querySelector(".main-chat");
|
|
4242
|
+
if (!handle || !panel || !mainEl) return;
|
|
4243
|
+
let dragging = false;
|
|
4244
|
+
handle.addEventListener("mousedown", (e) => {
|
|
4245
|
+
e.preventDefault();
|
|
4246
|
+
dragging = true;
|
|
4247
|
+
handle.classList.add("dragging");
|
|
4248
|
+
document.body.style.cursor = "col-resize";
|
|
4249
|
+
document.body.style.userSelect = "none";
|
|
4250
|
+
});
|
|
4251
|
+
document.addEventListener("mousemove", (e) => {
|
|
4252
|
+
if (!dragging) return;
|
|
4253
|
+
const body = mainEl.parentElement;
|
|
4254
|
+
if (!body) return;
|
|
4255
|
+
const bodyRect = body.getBoundingClientRect();
|
|
4256
|
+
const available = bodyRect.width - 1;
|
|
4257
|
+
let chatW = e.clientX - bodyRect.left;
|
|
4258
|
+
chatW = Math.max(280, Math.min(chatW, available - 320));
|
|
4259
|
+
const panelW = available - chatW;
|
|
4260
|
+
mainEl.style.flex = "0 0 " + chatW + "px";
|
|
4261
|
+
panel.style.flex = "0 0 " + panelW + "px";
|
|
4262
|
+
});
|
|
4263
|
+
document.addEventListener("mouseup", () => {
|
|
4264
|
+
if (!dragging) return;
|
|
4265
|
+
dragging = false;
|
|
4266
|
+
handle.classList.remove("dragging");
|
|
4267
|
+
document.body.style.cursor = "";
|
|
4268
|
+
document.body.style.userSelect = "";
|
|
4269
|
+
});
|
|
4270
|
+
})();
|
|
4271
|
+
|
|
3525
4272
|
elements.composer.addEventListener("submit", async (event) => {
|
|
3526
4273
|
event.preventDefault();
|
|
3527
4274
|
if (state.isStreaming) {
|
|
@@ -3820,6 +4567,10 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
3820
4567
|
state.confirmDeleteId = null;
|
|
3821
4568
|
renderConversationList();
|
|
3822
4569
|
}
|
|
4570
|
+
if (!event.target.closest(".thread-row") && state.confirmDeleteThreadId) {
|
|
4571
|
+
state.confirmDeleteThreadId = null;
|
|
4572
|
+
renderMessages(state.activeMessages, state.isStreaming);
|
|
4573
|
+
}
|
|
3823
4574
|
});
|
|
3824
4575
|
|
|
3825
4576
|
window.addEventListener("resize", () => {
|