openclacky 1.2.12 → 1.2.13
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.
- checksums.yaml +4 -4
- data/.clacky/skills/gem-release/SKILL.md +1 -1
- data/.clacky/skills/gem-release/scripts/release.sh +4 -1
- data/CHANGELOG.md +23 -0
- data/lib/clacky/agent/llm_caller.rb +40 -25
- data/lib/clacky/agent/memory_updater.rb +12 -0
- data/lib/clacky/agent/skill_auto_creator.rb +7 -4
- data/lib/clacky/agent/skill_evolution.rb +23 -5
- data/lib/clacky/agent/skill_manager.rb +86 -1
- data/lib/clacky/agent/skill_reflector.rb +18 -23
- data/lib/clacky/agent.rb +9 -1
- data/lib/clacky/agent_config.rb +59 -15
- data/lib/clacky/cli.rb +55 -0
- data/lib/clacky/default_skills/persist-memory/SKILL.md +4 -3
- data/lib/clacky/default_skills/search-skills/SKILL.md +61 -0
- data/lib/clacky/idle_compression_timer.rb +1 -1
- data/lib/clacky/message_format/open_ai.rb +7 -1
- data/lib/clacky/openai_stream_aggregator.rb +4 -1
- data/lib/clacky/providers.rb +40 -12
- data/lib/clacky/server/http_server.rb +117 -3
- data/lib/clacky/server/session_registry.rb +30 -8
- data/lib/clacky/server/web_ui_controller.rb +24 -1
- data/lib/clacky/session_manager.rb +120 -0
- data/lib/clacky/tools/web_search.rb +59 -8
- data/lib/clacky/ui2/layout_manager.rb +15 -5
- data/lib/clacky/ui2/progress_handle.rb +7 -1
- data/lib/clacky/ui2/ui_controller.rb +27 -0
- data/lib/clacky/ui_interface.rb +22 -0
- data/lib/clacky/utils/model_pricing.rb +96 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +209 -4
- data/lib/clacky/web/app.js +6 -5
- data/lib/clacky/web/i18n.js +18 -4
- data/lib/clacky/web/index.html +2 -1
- data/lib/clacky/web/sessions.js +408 -80
- data/lib/clacky/web/settings.js +213 -51
- data/lib/clacky/web/skills.js +5 -14
- data/lib/clacky/web/utils.js +57 -0
- data/lib/clacky/web/ws-dispatcher.js +136 -0
- metadata +4 -2
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -19,12 +19,17 @@ const Sessions = (() => {
|
|
|
19
19
|
const _sessions = []; // [{ id, name, status, total_tasks, total_cost }]
|
|
20
20
|
const _historyState = {}; // { [session_id]: { hasMore, oldestCreatedAt, loading, loaded } }
|
|
21
21
|
const _renderedCreatedAt = {}; // { [session_id]: Set<number> } — dedup by created_at
|
|
22
|
+
const _drafts = new Map(); // { [session_id]: composer textarea draft }
|
|
22
23
|
let _activeId = null;
|
|
23
24
|
let _hasMore = false; // unified pagination: are there older sessions to load?
|
|
24
25
|
let _loadingMore = false;
|
|
25
26
|
// Search state
|
|
26
27
|
const _filter = { q: "", date: "", type: "" }; // committed filter (applied to server)
|
|
27
28
|
let _searchOpen = false; // is the search panel visible?
|
|
29
|
+
// Active search result split when _filter.q is non-empty:
|
|
30
|
+
// { nameIds: Set<id>, contentIds: Set<id>, contentLoaded: bool }
|
|
31
|
+
let _searchSplit = null;
|
|
32
|
+
let _searchToken = 0; // monotonic counter; in-flight requests check against this
|
|
28
33
|
let _cronView = false; // are we in the cron sub-view?
|
|
29
34
|
let _cronCount = 0; // total cron sessions from server
|
|
30
35
|
// ── Cron sub-view independent pagination (commit 2) ──────────────────────
|
|
@@ -225,7 +230,7 @@ const Sessions = (() => {
|
|
|
225
230
|
|
|
226
231
|
function _restoreMessages(id) {
|
|
227
232
|
// Clear the pane and dedup state; history will be re-fetched from API.
|
|
228
|
-
|
|
233
|
+
RenderTarget.outer().innerHTML = "";
|
|
229
234
|
delete _renderedCreatedAt[id];
|
|
230
235
|
if (_historyState[id]) {
|
|
231
236
|
_historyState[id].oldestCreatedAt = null;
|
|
@@ -315,7 +320,7 @@ const Sessions = (() => {
|
|
|
315
320
|
}
|
|
316
321
|
|
|
317
322
|
function _updateEmptyHint() {
|
|
318
|
-
const messages =
|
|
323
|
+
const messages = RenderTarget.outer();
|
|
319
324
|
if (!messages) return;
|
|
320
325
|
// Check if there's any real content besides the hint itself
|
|
321
326
|
const hasReal = Array.from(messages.children).some(
|
|
@@ -339,7 +344,7 @@ const Sessions = (() => {
|
|
|
339
344
|
}
|
|
340
345
|
|
|
341
346
|
function _initEmptyHint() {
|
|
342
|
-
const messages =
|
|
347
|
+
const messages = RenderTarget.outer();
|
|
343
348
|
if (!messages) return;
|
|
344
349
|
// Re-evaluate whenever children change (append/insertBefore/innerHTML="")
|
|
345
350
|
const observer = new MutationObserver(() => _updateEmptyHint());
|
|
@@ -355,7 +360,7 @@ const Sessions = (() => {
|
|
|
355
360
|
|
|
356
361
|
function _initNewMessageBanner() {
|
|
357
362
|
const banner = $("new-message-banner");
|
|
358
|
-
const messages =
|
|
363
|
+
const messages = RenderTarget.outer();
|
|
359
364
|
if (!banner || !messages) return;
|
|
360
365
|
|
|
361
366
|
// Click to scroll to bottom
|
|
@@ -530,7 +535,8 @@ const Sessions = (() => {
|
|
|
530
535
|
}
|
|
531
536
|
|
|
532
537
|
// Compress an image File/Blob to a data URL within MAX_IMAGE_BYTES_SEND.
|
|
533
|
-
//
|
|
538
|
+
// PNG: keep as PNG to preserve alpha/transparency; scale down if too large.
|
|
539
|
+
// Other formats (JPEG/GIF/WEBP): scale down, then reduce JPEG quality until small enough.
|
|
534
540
|
// GIF is not compressible via Canvas — rendered as JPEG (LLMs only see first frame anyway).
|
|
535
541
|
function _compressImage(file) {
|
|
536
542
|
return new Promise((resolve, reject) => {
|
|
@@ -554,14 +560,32 @@ const Sessions = (() => {
|
|
|
554
560
|
const ctx = canvas.getContext("2d");
|
|
555
561
|
ctx.drawImage(img, 0, 0, width, height);
|
|
556
562
|
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
+
// PNG: keep as PNG to preserve alpha/transparency.
|
|
564
|
+
// Other formats (JPEG/GIF/WEBP): convert to JPEG (no alpha needed).
|
|
565
|
+
const isPNG = file.type === "image/png";
|
|
566
|
+
if (isPNG) {
|
|
567
|
+
let dataUrl = canvas.toDataURL("image/png");
|
|
568
|
+
// If PNG is still too large, scale down further
|
|
569
|
+
let scale = 0.9;
|
|
570
|
+
while (dataUrl.length * 0.75 > MAX_IMAGE_BYTES_SEND && scale > 0.3) {
|
|
571
|
+
const sw = Math.round(width * scale);
|
|
572
|
+
const sh = Math.round(height * scale);
|
|
573
|
+
canvas.width = sw;
|
|
574
|
+
canvas.height = sh;
|
|
575
|
+
ctx.drawImage(img, 0, 0, sw, sh);
|
|
576
|
+
dataUrl = canvas.toDataURL("image/png");
|
|
577
|
+
scale -= 0.1;
|
|
578
|
+
}
|
|
579
|
+
resolve(dataUrl);
|
|
580
|
+
} else {
|
|
581
|
+
let quality = 0.85;
|
|
582
|
+
let dataUrl = canvas.toDataURL("image/jpeg", quality);
|
|
583
|
+
while (dataUrl.length * 0.75 > MAX_IMAGE_BYTES_SEND && quality > 0.2) {
|
|
584
|
+
quality -= 0.1;
|
|
585
|
+
dataUrl = canvas.toDataURL("image/jpeg", quality);
|
|
586
|
+
}
|
|
587
|
+
resolve(dataUrl);
|
|
563
588
|
}
|
|
564
|
-
resolve(dataUrl);
|
|
565
589
|
};
|
|
566
590
|
img.src = e.target.result;
|
|
567
591
|
};
|
|
@@ -584,7 +608,7 @@ const Sessions = (() => {
|
|
|
584
608
|
|
|
585
609
|
_compressImage(file)
|
|
586
610
|
.then(dataUrl => {
|
|
587
|
-
_pendingImages.push({ dataUrl, name: displayName, mimeType: "image/jpeg", seq });
|
|
611
|
+
_pendingImages.push({ dataUrl, name: displayName, mimeType: file.type === "image/png" ? "image/png" : "image/jpeg", seq });
|
|
588
612
|
_renderAttachmentPreviews();
|
|
589
613
|
})
|
|
590
614
|
.catch(err => alert(`Image processing failed: ${err.message}`));
|
|
@@ -743,6 +767,9 @@ const Sessions = (() => {
|
|
|
743
767
|
}).join(" ");
|
|
744
768
|
bubbleHtml = badges + (bubbleHtml ? "<br>" + bubbleHtml : "");
|
|
745
769
|
}
|
|
770
|
+
if (typeof window._closeAllPhases === "function") {
|
|
771
|
+
window._closeAllPhases("incomplete");
|
|
772
|
+
}
|
|
746
773
|
Sessions.appendMsg("user", bubbleHtml, { time: new Date() });
|
|
747
774
|
|
|
748
775
|
// Merge images and files into unified files array for WS payload.
|
|
@@ -777,6 +804,7 @@ const Sessions = (() => {
|
|
|
777
804
|
|
|
778
805
|
input.value = "";
|
|
779
806
|
input.style.height = "auto";
|
|
807
|
+
_drafts.delete(Sessions.activeId);
|
|
780
808
|
setTimeout(() => { _sending = false; }, 300);
|
|
781
809
|
}
|
|
782
810
|
|
|
@@ -847,13 +875,13 @@ const Sessions = (() => {
|
|
|
847
875
|
}
|
|
848
876
|
});
|
|
849
877
|
|
|
850
|
-
// Enter key → commit search
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
}
|
|
878
|
+
// Enter key → commit search.
|
|
879
|
+
// Bound on the input directly so IME.bindEnter can attach compositionend
|
|
880
|
+
// to the input itself (Safari needs the timestamp to suppress fake Enters).
|
|
881
|
+
const searchInput = document.getElementById("session-search-q");
|
|
882
|
+
if (searchInput) {
|
|
883
|
+
IME.bindEnter(searchInput, () => Sessions.commitSearch());
|
|
884
|
+
}
|
|
857
885
|
|
|
858
886
|
// Inline ✕ button — clear the q input and re-fetch
|
|
859
887
|
document.addEventListener("click", (e) => {
|
|
@@ -908,8 +936,8 @@ const Sessions = (() => {
|
|
|
908
936
|
// but kept here in case some brand / template still renders it).
|
|
909
937
|
function _initMessageHistory() {
|
|
910
938
|
// Infinite-scroll older history when the user reaches the top.
|
|
911
|
-
|
|
912
|
-
const messages =
|
|
939
|
+
RenderTarget.outer().addEventListener("scroll", (e) => {
|
|
940
|
+
const messages = e.currentTarget;
|
|
913
941
|
if (messages.scrollTop < 80 && Sessions.activeId && Sessions.hasMoreHistory(Sessions.activeId)) {
|
|
914
942
|
Sessions.loadMoreHistory(Sessions.activeId);
|
|
915
943
|
}
|
|
@@ -963,11 +991,87 @@ const Sessions = (() => {
|
|
|
963
991
|
`<span class="tool-item-status running">…</span>` +
|
|
964
992
|
`</div>` +
|
|
965
993
|
`<div class="tool-item-details" style="display:none"></div>` +
|
|
994
|
+
`<div class="tool-item-diff" style="display:none"></div>` +
|
|
966
995
|
`<pre class="tool-item-stdout" style="display:none"></pre>`;
|
|
967
996
|
_ensureCopyDelegation();
|
|
968
997
|
return item;
|
|
969
998
|
}
|
|
970
999
|
|
|
1000
|
+
function _lineDiff(oldText, newText) {
|
|
1001
|
+
const a = String(oldText || "").split("\n");
|
|
1002
|
+
const b = String(newText || "").split("\n");
|
|
1003
|
+
const m = a.length, n = b.length;
|
|
1004
|
+
const lcs = Array.from({ length: m + 1 }, () => new Uint32Array(n + 1));
|
|
1005
|
+
for (let i = m - 1; i >= 0; i--) {
|
|
1006
|
+
for (let j = n - 1; j >= 0; j--) {
|
|
1007
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i+1][j+1] + 1 : Math.max(lcs[i+1][j], lcs[i][j+1]);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
const ops = [];
|
|
1011
|
+
let i = 0, j = 0;
|
|
1012
|
+
while (i < m && j < n) {
|
|
1013
|
+
if (a[i] === b[j]) { ops.push({ kind: "ctx", text: a[i] }); i++; j++; }
|
|
1014
|
+
else if (lcs[i+1][j] >= lcs[i][j+1]) { ops.push({ kind: "del", text: a[i] }); i++; }
|
|
1015
|
+
else { ops.push({ kind: "add", text: b[j] }); j++; }
|
|
1016
|
+
}
|
|
1017
|
+
while (i < m) { ops.push({ kind: "del", text: a[i++] }); }
|
|
1018
|
+
while (j < n) { ops.push({ kind: "add", text: b[j++] }); }
|
|
1019
|
+
return ops;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function _trimDiffContext(ops, ctxLines = 3) {
|
|
1023
|
+
const keep = new Array(ops.length).fill(false);
|
|
1024
|
+
for (let i = 0; i < ops.length; i++) {
|
|
1025
|
+
if (ops[i].kind !== "ctx") {
|
|
1026
|
+
for (let k = Math.max(0, i - ctxLines); k <= Math.min(ops.length - 1, i + ctxLines); k++) keep[k] = true;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
const out = [];
|
|
1030
|
+
let skipped = 0;
|
|
1031
|
+
for (let i = 0; i < ops.length; i++) {
|
|
1032
|
+
if (keep[i]) {
|
|
1033
|
+
if (skipped > 0) { out.push({ kind: "hunk", text: `@@ ${skipped} unchanged lines @@` }); skipped = 0; }
|
|
1034
|
+
out.push(ops[i]);
|
|
1035
|
+
} else {
|
|
1036
|
+
skipped++;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
return out;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function _renderEditWriteDiff(item, name, args) {
|
|
1043
|
+
if (!item || !args || typeof args !== "object") return;
|
|
1044
|
+
let oldText = "", newText = "";
|
|
1045
|
+
if (name === "edit") {
|
|
1046
|
+
oldText = args.old_string || args["old_string"] || "";
|
|
1047
|
+
newText = args.new_string || args["new_string"] || "";
|
|
1048
|
+
} else if (name === "write") {
|
|
1049
|
+
oldText = "";
|
|
1050
|
+
newText = args.content || args["content"] || "";
|
|
1051
|
+
} else {
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
if (!oldText && !newText) return;
|
|
1055
|
+
|
|
1056
|
+
const diffEl = item.querySelector(".tool-item-diff");
|
|
1057
|
+
if (!diffEl || diffEl.dataset.filled === "1") return;
|
|
1058
|
+
|
|
1059
|
+
const ops = _trimDiffContext(_lineDiff(oldText, newText), 3);
|
|
1060
|
+
if (!ops.length) return;
|
|
1061
|
+
|
|
1062
|
+
const MAX = 50;
|
|
1063
|
+
const truncated = ops.length > MAX;
|
|
1064
|
+
const shown = truncated ? ops.slice(0, MAX) : ops;
|
|
1065
|
+
const prefix = (k) => k === "add" ? "+" : k === "del" ? "-" : k === "hunk" ? "" : " ";
|
|
1066
|
+
let html = shown.map(o => `<div class="diff-line diff-${o.kind}">${escapeHtml(prefix(o.kind) + o.text)}</div>`).join("");
|
|
1067
|
+
if (truncated) {
|
|
1068
|
+
html += `<div class="diff-line diff-more">… ${ops.length - MAX} more lines hidden</div>`;
|
|
1069
|
+
}
|
|
1070
|
+
diffEl.innerHTML = html;
|
|
1071
|
+
diffEl.style.display = "";
|
|
1072
|
+
diffEl.dataset.filled = "1";
|
|
1073
|
+
}
|
|
1074
|
+
|
|
971
1075
|
function _toggleToolItemDetails(item) {
|
|
972
1076
|
if (!item) return;
|
|
973
1077
|
const details = item.querySelector(".tool-item-details");
|
|
@@ -1093,6 +1197,12 @@ const Sessions = (() => {
|
|
|
1093
1197
|
status.className = "tool-item-status ok";
|
|
1094
1198
|
status.textContent = "✓";
|
|
1095
1199
|
}
|
|
1200
|
+
const toolName = last.dataset.toolName || "";
|
|
1201
|
+
if (toolName === "edit" || toolName === "write") {
|
|
1202
|
+
let parsedArgs = null;
|
|
1203
|
+
try { parsedArgs = JSON.parse(last.dataset.argsJson || "null"); } catch (_) {}
|
|
1204
|
+
if (parsedArgs) _renderEditWriteDiff(last, toolName, parsedArgs);
|
|
1205
|
+
}
|
|
1096
1206
|
// Render the result string (e.g. "waiting (#4) — 128B\nstep1\nstep2…")
|
|
1097
1207
|
// into the stdout area so the user can see what actually happened.
|
|
1098
1208
|
// If the area already has streamed content (future feature), leave it.
|
|
@@ -1132,7 +1242,7 @@ const Sessions = (() => {
|
|
|
1132
1242
|
switch (ev.type) {
|
|
1133
1243
|
case "history_user_message": {
|
|
1134
1244
|
// Collapse any open tool group from the previous round
|
|
1135
|
-
if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
|
|
1245
|
+
if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; historyCtx.lastItem = null; }
|
|
1136
1246
|
const el = document.createElement("div");
|
|
1137
1247
|
el.className = "msg msg-user";
|
|
1138
1248
|
// Render image thumbnails and PDF badges (if any) followed by the text content
|
|
@@ -1186,7 +1296,7 @@ const Sessions = (() => {
|
|
|
1186
1296
|
|
|
1187
1297
|
case "assistant_message": {
|
|
1188
1298
|
// Collapse tool group before assistant reply
|
|
1189
|
-
if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
|
|
1299
|
+
if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; historyCtx.lastItem = null; }
|
|
1190
1300
|
const el = document.createElement("div");
|
|
1191
1301
|
el.className = "msg msg-assistant";
|
|
1192
1302
|
el.dataset.raw = ev.content || "";
|
|
@@ -1210,6 +1320,12 @@ const Sessions = (() => {
|
|
|
1210
1320
|
if (historyCtx.group && historyCtx.lastItem) {
|
|
1211
1321
|
const status = historyCtx.lastItem.querySelector(".tool-item-status");
|
|
1212
1322
|
if (status) { status.className = "tool-item-status ok"; status.textContent = "✓"; }
|
|
1323
|
+
const toolName = historyCtx.lastItem.dataset.toolName || "";
|
|
1324
|
+
if (toolName === "edit" || toolName === "write") {
|
|
1325
|
+
let parsedArgs = null;
|
|
1326
|
+
try { parsedArgs = JSON.parse(historyCtx.lastItem.dataset.argsJson || "null"); } catch (_) {}
|
|
1327
|
+
if (parsedArgs) _renderEditWriteDiff(historyCtx.lastItem, toolName, parsedArgs);
|
|
1328
|
+
}
|
|
1213
1329
|
const stdout = historyCtx.lastItem.querySelector(".tool-item-stdout");
|
|
1214
1330
|
if (stdout) {
|
|
1215
1331
|
const resultStr = (ev.result == null) ? "" : String(ev.result).trim();
|
|
@@ -1220,21 +1336,18 @@ const Sessions = (() => {
|
|
|
1220
1336
|
stdout.style.display = "none";
|
|
1221
1337
|
}
|
|
1222
1338
|
}
|
|
1223
|
-
historyCtx.lastItem = null;
|
|
1224
1339
|
}
|
|
1225
1340
|
break;
|
|
1226
1341
|
}
|
|
1227
1342
|
|
|
1228
1343
|
case "token_usage": {
|
|
1229
|
-
|
|
1230
|
-
if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
|
|
1231
|
-
Sessions.appendTokenUsage(ev, container);
|
|
1344
|
+
Sessions.appendTokenUsage(ev, container, historyCtx.lastItem);
|
|
1232
1345
|
break;
|
|
1233
1346
|
}
|
|
1234
1347
|
|
|
1235
1348
|
case "request_feedback": {
|
|
1236
1349
|
// Collapse any open tool group
|
|
1237
|
-
if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
|
|
1350
|
+
if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; historyCtx.lastItem = null; }
|
|
1238
1351
|
|
|
1239
1352
|
const rfQuestion = ev.question || "";
|
|
1240
1353
|
const rfContext = ev.context || "";
|
|
@@ -1302,7 +1415,7 @@ const Sessions = (() => {
|
|
|
1302
1415
|
stdout.innerHTML += lines.map(_ansiToHtml).join("");
|
|
1303
1416
|
if (stdout.style.display === "none") stdout.style.display = "";
|
|
1304
1417
|
stdout.scrollTop = stdout.scrollHeight;
|
|
1305
|
-
const messages =
|
|
1418
|
+
const messages = RenderTarget.outer();
|
|
1306
1419
|
_scrollToBottomIfNeeded(messages);
|
|
1307
1420
|
}
|
|
1308
1421
|
|
|
@@ -1313,7 +1426,7 @@ const Sessions = (() => {
|
|
|
1313
1426
|
const lines = _pendingStdoutLines;
|
|
1314
1427
|
_pendingStdoutLines = null;
|
|
1315
1428
|
|
|
1316
|
-
const messages =
|
|
1429
|
+
const messages = RenderTarget.outer();
|
|
1317
1430
|
if (!messages) return;
|
|
1318
1431
|
const items = messages.querySelectorAll(".tool-item");
|
|
1319
1432
|
if (items.length === 0) return;
|
|
@@ -1393,9 +1506,9 @@ const Sessions = (() => {
|
|
|
1393
1506
|
// Collapse any tool group still open at end of page
|
|
1394
1507
|
if (historyCtx.group) _collapseToolGroup(historyCtx.group);
|
|
1395
1508
|
|
|
1396
|
-
// Insert into
|
|
1509
|
+
// Insert into the outer message stream (history never lands inside an active phase card).
|
|
1397
1510
|
if (id === _activeId) {
|
|
1398
|
-
const messages =
|
|
1511
|
+
const messages = RenderTarget.outer();
|
|
1399
1512
|
if (prepend && messages.firstChild) {
|
|
1400
1513
|
const scrollBefore = messages.scrollHeight - messages.scrollTop;
|
|
1401
1514
|
messages.insertBefore(frag, messages.firstChild);
|
|
@@ -1542,7 +1655,7 @@ const Sessions = (() => {
|
|
|
1542
1655
|
let _copyDelegationInstalled = false;
|
|
1543
1656
|
function _ensureCopyDelegation() {
|
|
1544
1657
|
if (_copyDelegationInstalled) return;
|
|
1545
|
-
const messages =
|
|
1658
|
+
const messages = RenderTarget.outer();
|
|
1546
1659
|
if (!messages) return;
|
|
1547
1660
|
messages.addEventListener("click", (e) => {
|
|
1548
1661
|
// ── Tool item: click header to expand/collapse args details ──
|
|
@@ -1644,7 +1757,44 @@ const Sessions = (() => {
|
|
|
1644
1757
|
return btn;
|
|
1645
1758
|
}
|
|
1646
1759
|
|
|
1760
|
+
function _makeSearchHeader(text) {
|
|
1761
|
+
const div = document.createElement("div");
|
|
1762
|
+
div.className = "session-search-group";
|
|
1763
|
+
div.textContent = text;
|
|
1764
|
+
return div;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1647
1767
|
// ── Private render helper ─────────────────────────────────────────────
|
|
1768
|
+
// Escape regex metacharacters so a user query can be safely substituted
|
|
1769
|
+
// into a RegExp constructor (used to build a case-insensitive highlighter).
|
|
1770
|
+
function _escapeRegex(s) {
|
|
1771
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// Wrap occurrences of `query` inside `text` with <mark>. Both inputs are
|
|
1775
|
+
// first HTML-escaped so the resulting fragment is safe to inject.
|
|
1776
|
+
function _highlightSnippet(text, query) {
|
|
1777
|
+
const safe = escapeHtml(text || "");
|
|
1778
|
+
const q = (query || "").trim();
|
|
1779
|
+
if (!q) return safe;
|
|
1780
|
+
const re = new RegExp(_escapeRegex(escapeHtml(q)), "gi");
|
|
1781
|
+
return safe.replace(re, (m) => `<mark>${m}</mark>`);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// Re-center a snippet around its first match so the keyword is always
|
|
1785
|
+
// visible in a single-line ellipsis layout. Backend already gives us a
|
|
1786
|
+
// ±N byte window, but for ASCII-heavy lines that's still wider than the
|
|
1787
|
+
// sidebar can render on one line. We trim the head when the match sits
|
|
1788
|
+
// too far to the right, keeping ~`headRoom` chars before it.
|
|
1789
|
+
function _centerSnippet(text, query, headRoom = 16) {
|
|
1790
|
+
const t = text || "";
|
|
1791
|
+
const q = (query || "").trim();
|
|
1792
|
+
if (!q) return t;
|
|
1793
|
+
const idx = t.toLowerCase().indexOf(q.toLowerCase());
|
|
1794
|
+
if (idx <= headRoom) return t;
|
|
1795
|
+
return "…" + t.slice(idx - headRoom);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1648
1798
|
//
|
|
1649
1799
|
// Build and append a single session-item <div> into `container`.
|
|
1650
1800
|
// Used by both the general list and the coding section.
|
|
@@ -1655,6 +1805,10 @@ const Sessions = (() => {
|
|
|
1655
1805
|
if (s.pinned) el.classList.add("pinned");
|
|
1656
1806
|
|
|
1657
1807
|
const displayName = s.name || _relativeTime(s.created_at);
|
|
1808
|
+
const q = (_filter.q || "").trim();
|
|
1809
|
+
const nameHtml = (q && s._matchVia === "name" && s.name)
|
|
1810
|
+
? _highlightSnippet(displayName, q)
|
|
1811
|
+
: escapeHtml(displayName);
|
|
1658
1812
|
|
|
1659
1813
|
// Meta line — prefer relative time of last activity. Tasks count is
|
|
1660
1814
|
// only shown when > 0 to avoid visual noise on fresh sessions.
|
|
@@ -1695,10 +1849,15 @@ const Sessions = (() => {
|
|
|
1695
1849
|
? `<span class="session-dot dot-${s.status}"></span>`
|
|
1696
1850
|
: "";
|
|
1697
1851
|
|
|
1852
|
+
const snippetHtml = (s._matchVia === "content" && s.search_snippet)
|
|
1853
|
+
? `<div class="session-snippet">${_highlightSnippet(_centerSnippet(s.search_snippet, _filter.q), _filter.q)}</div>`
|
|
1854
|
+
: "";
|
|
1855
|
+
|
|
1698
1856
|
el.innerHTML = `
|
|
1699
1857
|
<div class="session-body">
|
|
1700
|
-
<div class="session-name">${dotHtml}<span class="session-name__text">${
|
|
1858
|
+
<div class="session-name">${dotHtml}<span class="session-name__text">${nameHtml}</span>${badgeHtml}${codingBadgeHtml}${pinIcon}</div>
|
|
1701
1859
|
<div class="session-meta">${metaText}</div>
|
|
1860
|
+
${snippetHtml}
|
|
1702
1861
|
</div>
|
|
1703
1862
|
<button class="session-actions-btn" title="Actions"><svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="2.5" cy="7" r="1.2" fill="currentColor"/><circle cx="7" cy="7" r="1.2" fill="currentColor"/><circle cx="11.5" cy="7" r="1.2" fill="currentColor"/></svg></button>`;
|
|
1704
1863
|
|
|
@@ -1719,6 +1878,13 @@ const Sessions = (() => {
|
|
|
1719
1878
|
}, 200);
|
|
1720
1879
|
};
|
|
1721
1880
|
|
|
1881
|
+
// Right-click context menu
|
|
1882
|
+
el.oncontextmenu = (e) => {
|
|
1883
|
+
e.preventDefault();
|
|
1884
|
+
Sessions._closeActionsMenu();
|
|
1885
|
+
_showContextMenu(e, s);
|
|
1886
|
+
};
|
|
1887
|
+
|
|
1722
1888
|
// Actions button - show menu
|
|
1723
1889
|
const actionsBtn = el.querySelector(".session-actions-btn");
|
|
1724
1890
|
actionsBtn.onclick = (e) => {
|
|
@@ -1889,6 +2055,7 @@ const Sessions = (() => {
|
|
|
1889
2055
|
}
|
|
1890
2056
|
// Clean up per-session progress state (timer + DOM + logical state)
|
|
1891
2057
|
Sessions._deleteProgressState(id);
|
|
2058
|
+
_drafts.delete(id);
|
|
1892
2059
|
},
|
|
1893
2060
|
|
|
1894
2061
|
/** Load the next page of older sessions (unified time cursor). */
|
|
@@ -1989,7 +2156,6 @@ const Sessions = (() => {
|
|
|
1989
2156
|
|
|
1990
2157
|
/** Commit current filter values and re-fetch from server. Called by Enter / Go button. */
|
|
1991
2158
|
async commitSearch() {
|
|
1992
|
-
// Read live input values into _filter
|
|
1993
2159
|
const qEl = document.getElementById("session-search-q");
|
|
1994
2160
|
const typeEl = document.getElementById("session-search-type");
|
|
1995
2161
|
const dateEl = document.getElementById("session-search-date");
|
|
@@ -1997,29 +2163,73 @@ const Sessions = (() => {
|
|
|
1997
2163
|
if (typeEl) _filter.type = typeEl.value;
|
|
1998
2164
|
if (dateEl) _filter.date = dateEl.dataset.value || "";
|
|
1999
2165
|
|
|
2000
|
-
|
|
2001
|
-
_sessions.length = 0;
|
|
2002
|
-
_hasMore = false;
|
|
2166
|
+
const token = ++_searchToken;
|
|
2003
2167
|
_loadingMore = true;
|
|
2004
|
-
Sessions.renderList();
|
|
2168
|
+
Sessions.renderList({ skipScrollToActive: true });
|
|
2169
|
+
|
|
2170
|
+
let nextSessions = [];
|
|
2171
|
+
let nextHasMore = false;
|
|
2172
|
+
let nextCronCnt = 0;
|
|
2173
|
+
let nextSplit = null;
|
|
2005
2174
|
|
|
2006
2175
|
try {
|
|
2007
|
-
const
|
|
2008
|
-
if (_filter.
|
|
2009
|
-
if (_filter.
|
|
2010
|
-
|
|
2176
|
+
const baseParams = new URLSearchParams({ limit: "20" });
|
|
2177
|
+
if (_filter.date) baseParams.set("date", _filter.date);
|
|
2178
|
+
if (_filter.type) baseParams.set("type", _filter.type);
|
|
2179
|
+
|
|
2180
|
+
if (_filter.q) {
|
|
2181
|
+
const pName = new URLSearchParams(baseParams);
|
|
2182
|
+
const pContent = new URLSearchParams(baseParams);
|
|
2183
|
+
pName.set("q", _filter.q); pName.set("q_scope", "name");
|
|
2184
|
+
pContent.set("q", _filter.q); pContent.set("q_scope", "content");
|
|
2185
|
+
pContent.set("limit", "50");
|
|
2186
|
+
|
|
2187
|
+
const [nameRes, contentRes] = await Promise.all([
|
|
2188
|
+
fetch(`/api/sessions?${pName}`),
|
|
2189
|
+
fetch(`/api/sessions?${pContent}`),
|
|
2190
|
+
]);
|
|
2191
|
+
if (token !== _searchToken) return;
|
|
2192
|
+
const nameData = nameRes.ok ? await nameRes.json() : { sessions: [] };
|
|
2193
|
+
const contentData = contentRes.ok ? await contentRes.json() : { sessions: [] };
|
|
2194
|
+
if (token !== _searchToken) return;
|
|
2195
|
+
|
|
2196
|
+
const nameIds = new Set();
|
|
2197
|
+
const contentIds = new Set();
|
|
2198
|
+
(nameData.sessions || []).forEach(s => { nameIds.add(s.id); s._matchVia = "name"; });
|
|
2199
|
+
(contentData.sessions || []).forEach(s => {
|
|
2200
|
+
if (nameIds.has(s.id)) return;
|
|
2201
|
+
contentIds.add(s.id);
|
|
2202
|
+
s._matchVia = "content";
|
|
2203
|
+
});
|
|
2011
2204
|
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2205
|
+
nextSessions = [...(nameData.sessions || [])];
|
|
2206
|
+
(contentData.sessions || []).forEach(s => { if (!nameIds.has(s.id)) nextSessions.push(s); });
|
|
2207
|
+
nextHasMore = false;
|
|
2208
|
+
nextCronCnt = nameData.cron_count || 0;
|
|
2209
|
+
nextSplit = { nameIds, contentIds, contentLoaded: contentRes.ok };
|
|
2210
|
+
} else {
|
|
2211
|
+
const res = await fetch(`/api/sessions?${baseParams}`);
|
|
2212
|
+
if (token !== _searchToken) return;
|
|
2213
|
+
if (!res.ok) return;
|
|
2214
|
+
const data = await res.json();
|
|
2215
|
+
if (token !== _searchToken) return;
|
|
2216
|
+
nextSessions = data.sessions || [];
|
|
2217
|
+
nextHasMore = !!data.has_more;
|
|
2218
|
+
nextCronCnt = data.cron_count || 0;
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
_sessions.length = 0;
|
|
2222
|
+
_sessions.push(...nextSessions);
|
|
2223
|
+
_hasMore = nextHasMore;
|
|
2224
|
+
_cronCount = nextCronCnt;
|
|
2225
|
+
_searchSplit = nextSplit;
|
|
2018
2226
|
} catch (e) {
|
|
2019
|
-
console.error("commitSearch error:", e);
|
|
2227
|
+
if (token === _searchToken) console.error("commitSearch error:", e);
|
|
2020
2228
|
} finally {
|
|
2021
|
-
|
|
2022
|
-
|
|
2229
|
+
if (token === _searchToken) {
|
|
2230
|
+
_loadingMore = false;
|
|
2231
|
+
Sessions.renderList();
|
|
2232
|
+
}
|
|
2023
2233
|
}
|
|
2024
2234
|
},
|
|
2025
2235
|
|
|
@@ -2111,6 +2321,26 @@ const Sessions = (() => {
|
|
|
2111
2321
|
}
|
|
2112
2322
|
},
|
|
2113
2323
|
|
|
2324
|
+
/** Fork a session — creates a copy with the same history and working dir. */
|
|
2325
|
+
async fork(sessionId) {
|
|
2326
|
+
try {
|
|
2327
|
+
const res = await fetch(`/api/sessions/${sessionId}/fork`, { method: "POST" });
|
|
2328
|
+
if (!res.ok) {
|
|
2329
|
+
const data = await res.json().catch(() => ({}));
|
|
2330
|
+
console.error("Fork session failed:", data.error || res.status);
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2333
|
+
const data = await res.json();
|
|
2334
|
+
if (data.session) {
|
|
2335
|
+
Sessions.add(data.session);
|
|
2336
|
+
Sessions.renderList();
|
|
2337
|
+
Sessions.select(data.session.id);
|
|
2338
|
+
}
|
|
2339
|
+
} catch (err) {
|
|
2340
|
+
console.error("Fork session error:", err);
|
|
2341
|
+
}
|
|
2342
|
+
},
|
|
2343
|
+
|
|
2114
2344
|
// ── Selection ─────────────────────────────────────────────────────────
|
|
2115
2345
|
//
|
|
2116
2346
|
// Panel switching is handled by Router — Sessions only manages state.
|
|
@@ -2138,6 +2368,12 @@ const Sessions = (() => {
|
|
|
2138
2368
|
/** Set _activeId directly (called by Router when activating a session). */
|
|
2139
2369
|
_setActiveId(id) {
|
|
2140
2370
|
_activeId = id;
|
|
2371
|
+
const input = $("user-input");
|
|
2372
|
+
if (input) {
|
|
2373
|
+
input.value = _drafts.get(id) || "";
|
|
2374
|
+
input.style.height = "auto";
|
|
2375
|
+
if (input.value) input.style.height = Math.min(input.scrollHeight, 200) + "px";
|
|
2376
|
+
}
|
|
2141
2377
|
},
|
|
2142
2378
|
|
|
2143
2379
|
/** Restore cached messages for a session into the #messages container. */
|
|
@@ -2149,9 +2385,11 @@ const Sessions = (() => {
|
|
|
2149
2385
|
* Called by Router before switching away from a session view. */
|
|
2150
2386
|
_cacheActiveAndDeselect() {
|
|
2151
2387
|
_cacheActiveMessages();
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2388
|
+
if (_activeId) {
|
|
2389
|
+
const input = $("user-input");
|
|
2390
|
+
if (input) _drafts.set(_activeId, input.value);
|
|
2391
|
+
Sessions._detachProgressUI(_activeId);
|
|
2392
|
+
}
|
|
2155
2393
|
_activeId = null;
|
|
2156
2394
|
WS.setSubscribedSession(null);
|
|
2157
2395
|
Sessions.renderList();
|
|
@@ -2211,8 +2449,29 @@ const Sessions = (() => {
|
|
|
2211
2449
|
list.innerHTML = "";
|
|
2212
2450
|
|
|
2213
2451
|
if (hasActiveFilter) {
|
|
2214
|
-
|
|
2215
|
-
|
|
2452
|
+
if (_filter.q && _searchSplit) {
|
|
2453
|
+
const { nameIds, contentIds, contentLoaded } = _searchSplit;
|
|
2454
|
+
const nameRows = visible.filter(s => nameIds.has(s.id));
|
|
2455
|
+
const contentRows = visible.filter(s => contentIds.has(s.id));
|
|
2456
|
+
|
|
2457
|
+
if (nameRows.length > 0) {
|
|
2458
|
+
list.appendChild(_makeSearchHeader(I18n.t("sessions.search.byName", { n: nameRows.length })));
|
|
2459
|
+
nameRows.forEach(s => _renderSessionItem(list, s));
|
|
2460
|
+
}
|
|
2461
|
+
if (contentLoaded) {
|
|
2462
|
+
list.appendChild(_makeSearchHeader(I18n.t("sessions.search.byContent", { n: contentRows.length })));
|
|
2463
|
+
if (contentRows.length === 0) {
|
|
2464
|
+
const empty = document.createElement("div");
|
|
2465
|
+
empty.className = "session-empty";
|
|
2466
|
+
empty.textContent = I18n.t("sessions.search.contentEmpty");
|
|
2467
|
+
list.appendChild(empty);
|
|
2468
|
+
} else {
|
|
2469
|
+
contentRows.forEach(s => _renderSessionItem(list, s));
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
} else {
|
|
2473
|
+
visible.forEach(s => _renderSessionItem(list, s));
|
|
2474
|
+
}
|
|
2216
2475
|
} else if (isCronView) {
|
|
2217
2476
|
// Cron sub-view: show only cron sessions, paginated independently via
|
|
2218
2477
|
// loadMoreCron() (the first page is fetched on entering the view).
|
|
@@ -2308,6 +2567,54 @@ const Sessions = (() => {
|
|
|
2308
2567
|
}
|
|
2309
2568
|
},
|
|
2310
2569
|
|
|
2570
|
+
/** Show right-click context menu for a session item. */
|
|
2571
|
+
_showContextMenu(e, session) {
|
|
2572
|
+
Sessions._closeContextMenu();
|
|
2573
|
+
Sessions._closeActionsMenu();
|
|
2574
|
+
|
|
2575
|
+
const iconFork = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>`;
|
|
2576
|
+
|
|
2577
|
+
const menu = document.createElement("div");
|
|
2578
|
+
menu.className = "session-context-menu";
|
|
2579
|
+
menu.innerHTML = `
|
|
2580
|
+
<div class="session-actions-menu-item" data-action="fork">
|
|
2581
|
+
<span class="session-actions-menu-icon">${iconFork}</span>
|
|
2582
|
+
<span class="session-actions-menu-label">${escapeHtml(I18n.t("sessions.actions.fork"))}</span>
|
|
2583
|
+
</div>
|
|
2584
|
+
`;
|
|
2585
|
+
|
|
2586
|
+
document.body.appendChild(menu);
|
|
2587
|
+
menu.style.position = "fixed";
|
|
2588
|
+
menu.style.top = e.clientY + "px";
|
|
2589
|
+
menu.style.left = e.clientX + "px";
|
|
2590
|
+
// Keep menu within viewport
|
|
2591
|
+
requestAnimationFrame(() => {
|
|
2592
|
+
const r = menu.getBoundingClientRect();
|
|
2593
|
+
if (r.right > window.innerWidth) menu.style.left = (window.innerWidth - r.width - 8) + "px";
|
|
2594
|
+
if (r.bottom > window.innerHeight) menu.style.top = (window.innerHeight - r.height - 8) + "px";
|
|
2595
|
+
});
|
|
2596
|
+
|
|
2597
|
+
menu.addEventListener("click", async (ev) => {
|
|
2598
|
+
const item = ev.target.closest(".session-actions-menu-item");
|
|
2599
|
+
if (!item) return;
|
|
2600
|
+
const action = item.dataset.action;
|
|
2601
|
+
Sessions._closeContextMenu();
|
|
2602
|
+
if (action === "fork") {
|
|
2603
|
+
await Sessions.fork(session.id);
|
|
2604
|
+
}
|
|
2605
|
+
});
|
|
2606
|
+
|
|
2607
|
+
setTimeout(() => {
|
|
2608
|
+
document.addEventListener("click", Sessions._closeContextMenu, { once: true });
|
|
2609
|
+
document.addEventListener("contextmenu", Sessions._closeContextMenu, { once: true });
|
|
2610
|
+
}, 0);
|
|
2611
|
+
},
|
|
2612
|
+
|
|
2613
|
+
_closeContextMenu() {
|
|
2614
|
+
const existing = document.querySelector(".session-context-menu");
|
|
2615
|
+
if (existing) existing.remove();
|
|
2616
|
+
},
|
|
2617
|
+
|
|
2311
2618
|
/** Show actions menu (pin/rename/delete) next to the actions button. */
|
|
2312
2619
|
_showActionsMenu(button, session) {
|
|
2313
2620
|
// Close any existing menu first
|
|
@@ -2315,6 +2622,7 @@ const Sessions = (() => {
|
|
|
2315
2622
|
|
|
2316
2623
|
// Lucide-style stroked icons to match the rest of the UI
|
|
2317
2624
|
const iconPin = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="transform:rotate(45deg);display:block"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>`;
|
|
2625
|
+
const iconFork = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>`;
|
|
2318
2626
|
const iconRename = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4z"/></svg>`;
|
|
2319
2627
|
const iconTrash = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>`;
|
|
2320
2628
|
|
|
@@ -2323,6 +2631,10 @@ const Sessions = (() => {
|
|
|
2323
2631
|
const menu = document.createElement("div");
|
|
2324
2632
|
menu.className = "session-actions-menu";
|
|
2325
2633
|
menu.innerHTML = `
|
|
2634
|
+
<div class="session-actions-menu-item" data-action="fork">
|
|
2635
|
+
<span class="session-actions-menu-icon">${iconFork}</span>
|
|
2636
|
+
<span class="session-actions-menu-label">${escapeHtml(I18n.t("sessions.actions.fork"))}</span>
|
|
2637
|
+
</div>
|
|
2326
2638
|
<div class="session-actions-menu-item" data-action="pin">
|
|
2327
2639
|
<span class="session-actions-menu-icon">${iconPin}</span>
|
|
2328
2640
|
<span class="session-actions-menu-label">${escapeHtml(pinLabel)}</span>
|
|
@@ -2352,7 +2664,9 @@ const Sessions = (() => {
|
|
|
2352
2664
|
const action = item.dataset.action;
|
|
2353
2665
|
Sessions._closeActionsMenu();
|
|
2354
2666
|
|
|
2355
|
-
if (action === "
|
|
2667
|
+
if (action === "fork") {
|
|
2668
|
+
await Sessions.fork(session.id);
|
|
2669
|
+
} else if (action === "pin") {
|
|
2356
2670
|
await Sessions.togglePin(session.id);
|
|
2357
2671
|
} else if (action === "rename") {
|
|
2358
2672
|
// Close sidebar on mobile so the rename dialog isn't obscured
|
|
@@ -2708,10 +3022,15 @@ const Sessions = (() => {
|
|
|
2708
3022
|
_liveToolGroup: null, // current open .tool-group DOM element
|
|
2709
3023
|
_liveLastToolItem: null, // last .tool-item added (for tool_result pairing)
|
|
2710
3024
|
|
|
3025
|
+
// Append a diff block to the message stream (for edit/write previews).
|
|
3026
|
+
appendDiff(rows, truncated, hiddenLines) {
|
|
3027
|
+
// Deprecated no-op; diff is now rendered inline within the tool-item.
|
|
3028
|
+
},
|
|
3029
|
+
|
|
2711
3030
|
// Append a tool_call as a compact item inside the live tool group.
|
|
2712
3031
|
// Creates the group if it doesn't exist yet.
|
|
2713
3032
|
appendToolCall(name, args, summary) {
|
|
2714
|
-
const messages =
|
|
3033
|
+
const messages = RenderTarget.current();
|
|
2715
3034
|
if (!Sessions._liveToolGroup) {
|
|
2716
3035
|
Sessions._liveToolGroup = _makeToolGroup();
|
|
2717
3036
|
messages.appendChild(Sessions._liveToolGroup);
|
|
@@ -2724,7 +3043,6 @@ const Sessions = (() => {
|
|
|
2724
3043
|
appendToolResult(result) {
|
|
2725
3044
|
if (Sessions._liveToolGroup && Sessions._liveLastToolItem) {
|
|
2726
3045
|
_completeLastToolItem(Sessions._liveToolGroup, result);
|
|
2727
|
-
Sessions._liveLastToolItem = null;
|
|
2728
3046
|
}
|
|
2729
3047
|
},
|
|
2730
3048
|
|
|
@@ -2737,7 +3055,7 @@ const Sessions = (() => {
|
|
|
2737
3055
|
// .tool-item visible in the DOM — that is the in-flight tool the stdout belongs to.
|
|
2738
3056
|
let toolItem = Sessions._liveLastToolItem;
|
|
2739
3057
|
if (!toolItem) {
|
|
2740
|
-
const messages =
|
|
3058
|
+
const messages = RenderTarget.current();
|
|
2741
3059
|
if (messages) {
|
|
2742
3060
|
const items = messages.querySelectorAll(".tool-item");
|
|
2743
3061
|
if (items.length > 0) toolItem = items[items.length - 1];
|
|
@@ -2755,12 +3073,13 @@ const Sessions = (() => {
|
|
|
2755
3073
|
_applyStdoutToItem(toolItem, lines);
|
|
2756
3074
|
},
|
|
2757
3075
|
|
|
2758
|
-
// Append a token usage line
|
|
2759
|
-
//
|
|
2760
|
-
//
|
|
2761
|
-
//
|
|
2762
|
-
appendTokenUsage(ev, container) {
|
|
2763
|
-
const messages = container ||
|
|
3076
|
+
// Append a token usage line. By default it attaches to the most recent
|
|
3077
|
+
// .tool-item (so it visually belongs to that tool); falls back to the
|
|
3078
|
+
// outer message list when no tool-item is available (e.g. plain
|
|
3079
|
+
// assistant turn with no tool calls).
|
|
3080
|
+
appendTokenUsage(ev, container, hostItem) {
|
|
3081
|
+
const messages = container || RenderTarget.current();
|
|
3082
|
+
const host = hostItem || Sessions._liveLastToolItem || null;
|
|
2764
3083
|
const el = document.createElement("div");
|
|
2765
3084
|
el.className = "token-usage-line";
|
|
2766
3085
|
|
|
@@ -2819,8 +3138,13 @@ const Sessions = (() => {
|
|
|
2819
3138
|
`<span class="tu-field">Total: <b>${(ev.total_tokens || 0).toLocaleString()}</b></span>` +
|
|
2820
3139
|
`</span>`;
|
|
2821
3140
|
|
|
2822
|
-
|
|
2823
|
-
if (
|
|
3141
|
+
el.classList.add(host ? "tu-attached" : "tu-standalone");
|
|
3142
|
+
if (host) {
|
|
3143
|
+
host.appendChild(el);
|
|
3144
|
+
} else {
|
|
3145
|
+
messages.appendChild(el);
|
|
3146
|
+
if (!container) _scrollToBottomIfNeeded(messages);
|
|
3147
|
+
}
|
|
2824
3148
|
},
|
|
2825
3149
|
|
|
2826
3150
|
// Collapse the live tool group (call when AI starts responding or task ends).
|
|
@@ -2836,7 +3160,7 @@ const Sessions = (() => {
|
|
|
2836
3160
|
// Starting a new assistant/user/info message: close any open tool group
|
|
2837
3161
|
if (type !== "tool") Sessions.collapseToolGroup();
|
|
2838
3162
|
|
|
2839
|
-
const messages =
|
|
3163
|
+
const messages = RenderTarget.current();
|
|
2840
3164
|
|
|
2841
3165
|
// For error messages: remove any existing error messages first to avoid duplicates
|
|
2842
3166
|
if (type === "error") {
|
|
@@ -2890,7 +3214,7 @@ const Sessions = (() => {
|
|
|
2890
3214
|
|
|
2891
3215
|
appendInfo(text, subline) {
|
|
2892
3216
|
Sessions.collapseToolGroup();
|
|
2893
|
-
const messages =
|
|
3217
|
+
const messages = RenderTarget.current();
|
|
2894
3218
|
const el = document.createElement("div");
|
|
2895
3219
|
el.className = subline ? "msg msg-info msg-info-main" : "msg msg-info";
|
|
2896
3220
|
el.textContent = text;
|
|
@@ -2908,7 +3232,7 @@ const Sessions = (() => {
|
|
|
2908
3232
|
// Called when the agent needs user input to continue.
|
|
2909
3233
|
showFeedbackRequest(question, context, options) {
|
|
2910
3234
|
Sessions.collapseToolGroup();
|
|
2911
|
-
const messages =
|
|
3235
|
+
const messages = RenderTarget.current();
|
|
2912
3236
|
const hasOptions = options && Array.isArray(options) && options.length > 0;
|
|
2913
3237
|
|
|
2914
3238
|
// Normalize bullet symbols to markdown list format so marked renders them as <ul>
|
|
@@ -3053,7 +3377,7 @@ const Sessions = (() => {
|
|
|
3053
3377
|
// Only attach if this session is currently visible
|
|
3054
3378
|
if (id !== _activeId) return;
|
|
3055
3379
|
|
|
3056
|
-
const messages =
|
|
3380
|
+
const messages = RenderTarget.outer();
|
|
3057
3381
|
if (!messages) return;
|
|
3058
3382
|
|
|
3059
3383
|
// Clean up any previous DOM/timer for this session (idempotent)
|
|
@@ -3128,7 +3452,7 @@ const Sessions = (() => {
|
|
|
3128
3452
|
existing.el.textContent = Sessions._composeProgressLine(existing.displayText, existing.startTime, existing.metadata, existing.lastChunkAt);
|
|
3129
3453
|
}
|
|
3130
3454
|
}, 250);
|
|
3131
|
-
_scrollToBottomIfNeeded(
|
|
3455
|
+
_scrollToBottomIfNeeded(RenderTarget.outer());
|
|
3132
3456
|
return;
|
|
3133
3457
|
}
|
|
3134
3458
|
|
|
@@ -4168,11 +4492,12 @@ const Sessions = (() => {
|
|
|
4168
4492
|
// Load tree for a given path
|
|
4169
4493
|
async function loadTreeForPath(dirPath, absolute = false) {
|
|
4170
4494
|
treeContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
|
|
4495
|
+
if (dirPath) { pathInput.value = dirPath; selectedPath = dirPath; }
|
|
4171
4496
|
// Auto-detect absolute mode: path is absolute and outside working directory
|
|
4172
|
-
const useAbsolute = absolute || (dirPath.startsWith("/") && (!rootDir || !dirPath.startsWith(rootDir)));
|
|
4497
|
+
const useAbsolute = absolute || (dirPath.startsWith("/") && (!rootDir || !(dirPath === rootDir || dirPath.startsWith(rootDir + "/"))));
|
|
4173
4498
|
// Convert absolute path to relative path for API
|
|
4174
4499
|
let relPath = dirPath;
|
|
4175
|
-
if (!useAbsolute && rootDir && dirPath.startsWith(rootDir)) {
|
|
4500
|
+
if (!useAbsolute && rootDir && (dirPath === rootDir || dirPath.startsWith(rootDir + "/"))) {
|
|
4176
4501
|
relPath = dirPath.substring(rootDir.length).replace(/^\/+/, "");
|
|
4177
4502
|
}
|
|
4178
4503
|
try {
|
|
@@ -4181,7 +4506,7 @@ const Sessions = (() => {
|
|
|
4181
4506
|
if (rootDir && presets.children.length === 0) {
|
|
4182
4507
|
setupPresets();
|
|
4183
4508
|
// Update pathInput to show absolute path
|
|
4184
|
-
if (rootDir !== currentDir && !currentDir.startsWith(rootDir)) {
|
|
4509
|
+
if (rootDir !== currentDir && !(currentDir === rootDir || currentDir.startsWith(rootDir + "/"))) {
|
|
4185
4510
|
pathInput.value = rootDir;
|
|
4186
4511
|
selectedPath = rootDir;
|
|
4187
4512
|
}
|
|
@@ -4274,9 +4599,9 @@ const Sessions = (() => {
|
|
|
4274
4599
|
}
|
|
4275
4600
|
|
|
4276
4601
|
// Determine if we need absolute mode (path outside working directory)
|
|
4277
|
-
const isAbsolute = parentPath.startsWith("/") && (!rootDir || !parentPath.startsWith(rootDir));
|
|
4602
|
+
const isAbsolute = parentPath.startsWith("/") && (!rootDir || !(parentPath === rootDir || parentPath.startsWith(rootDir + "/")));
|
|
4278
4603
|
let relPath = parentPath;
|
|
4279
|
-
if (!isAbsolute && rootDir && parentPath.startsWith(rootDir)) {
|
|
4604
|
+
if (!isAbsolute && rootDir && (parentPath === rootDir || parentPath.startsWith(rootDir + "/"))) {
|
|
4280
4605
|
relPath = parentPath.substring(rootDir.length).replace(/^\/+/, "");
|
|
4281
4606
|
}
|
|
4282
4607
|
|
|
@@ -4308,10 +4633,12 @@ const Sessions = (() => {
|
|
|
4308
4633
|
});
|
|
4309
4634
|
|
|
4310
4635
|
// Keyboard navigation in autocomplete
|
|
4636
|
+
const pathIme = IME.track(pathInput);
|
|
4311
4637
|
pathInput.addEventListener("keydown", (e) => {
|
|
4312
4638
|
const items = autocomplete.querySelectorAll(".dp-ac-item");
|
|
4313
4639
|
if (!items.length || autocomplete.style.display === "none") {
|
|
4314
4640
|
if (e.key === "Enter") {
|
|
4641
|
+
if (pathIme.isComposing(e)) return;
|
|
4315
4642
|
e.preventDefault();
|
|
4316
4643
|
// Navigate to typed path without closing modal
|
|
4317
4644
|
const dir = pathInput.value.trim();
|
|
@@ -4335,6 +4662,7 @@ const Sessions = (() => {
|
|
|
4335
4662
|
items.forEach((el, i) => el.classList.toggle("active", i === activeIndex));
|
|
4336
4663
|
items[activeIndex]?.scrollIntoView({ block: "nearest" });
|
|
4337
4664
|
} else if (e.key === "Enter") {
|
|
4665
|
+
if (pathIme.isComposing(e)) return;
|
|
4338
4666
|
e.preventDefault();
|
|
4339
4667
|
if (activeIndex >= 0 && items[activeIndex]) {
|
|
4340
4668
|
// Select the highlighted suggestion
|