openclacky 1.2.10 → 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 +56 -1
- data/lib/clacky/agent/llm_caller.rb +40 -25
- data/lib/clacky/agent/memory_updater.rb +12 -0
- data/lib/clacky/agent/session_serializer.rb +1 -1
- 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/tool_registry.rb +10 -0
- data/lib/clacky/agent.rb +68 -23
- data/lib/clacky/agent_config.rb +59 -15
- data/lib/clacky/anthropic_stream_aggregator.rb +17 -1
- data/lib/clacky/bedrock_stream_aggregator.rb +17 -1
- data/lib/clacky/cli.rb +55 -0
- data/lib/clacky/client.rb +25 -3
- data/lib/clacky/default_skills/channel-manager/SKILL.md +47 -42
- data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +134 -0
- data/lib/clacky/default_skills/media-gen/SKILL.md +5 -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/message_history.rb +57 -0
- data/lib/clacky/openai_stream_aggregator.rb +30 -3
- data/lib/clacky/providers.rb +40 -12
- data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +10 -1
- data/lib/clacky/server/channel/adapters/discord/adapter.rb +8 -2
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -1
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +12 -0
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +23 -3
- data/lib/clacky/server/channel/adapters/telegram/adapter.rb +12 -2
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +5 -1
- data/lib/clacky/server/channel/channel_manager.rb +65 -4
- data/lib/clacky/server/channel/group_message_buffer.rb +53 -0
- data/lib/clacky/server/http_server.rb +190 -10
- data/lib/clacky/server/session_registry.rb +34 -14
- data/lib/clacky/server/web_ui_controller.rb +24 -1
- data/lib/clacky/session_manager.rb +120 -0
- data/lib/clacky/tools/trash_manager.rb +1 -1
- 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 +230 -7
- data/lib/clacky/web/app.js +6 -5
- data/lib/clacky/web/apple-touch-icon-180.png +0 -0
- data/lib/clacky/web/brand.js +22 -2
- data/lib/clacky/web/favicon.ico +0 -0
- data/lib/clacky/web/i18n.js +22 -4
- data/lib/clacky/web/index.html +6 -4
- data/lib/clacky/web/logo_nav_dark.png +0 -0
- data/lib/clacky/web/model-tester.js +8 -1
- data/lib/clacky/web/sessions.js +576 -120
- data/lib/clacky/web/settings.js +213 -51
- data/lib/clacky/web/skills.js +5 -14
- data/lib/clacky/web/theme.js +1 -0
- data/lib/clacky/web/utils.js +57 -0
- data/lib/clacky/web/ws-dispatcher.js +136 -0
- data/scripts/build/lib/gem.sh +9 -2
- data/scripts/build/src/install_full.sh.cc +2 -0
- data/scripts/build/src/uninstall.sh.cc +1 -1
- data/scripts/install.ps1 +19 -5
- data/scripts/install.sh +9 -2
- data/scripts/install_full.sh +11 -2
- data/scripts/install_rails_deps.sh +9 -2
- data/scripts/uninstall.sh +10 -3
- metadata +9 -2
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -19,14 +19,29 @@ 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
|
|
35
|
+
// ── Cron sub-view independent pagination (commit 2) ──────────────────────
|
|
36
|
+
// The folded cron sub-view paginates *independently* of the outer list so
|
|
37
|
+
// that "Load more" inside it never advances the outer list's cursor, and so
|
|
38
|
+
// all cron sessions can be loaded even when they're sparse across the mixed
|
|
39
|
+
// outer pages. Cron rows fetched here are pushed into the shared `_sessions`
|
|
40
|
+
// array (with dedup), so WS updates / patch / remove keep working unchanged;
|
|
41
|
+
// we only track a separate cursor + hasMore/loading flags for the sub-view.
|
|
42
|
+
let _cronBefore = null; // cursor: oldest cron created_at loaded into the sub-view
|
|
43
|
+
let _cronHasMore = false; // are there older cron sessions to load?
|
|
44
|
+
let _cronLoadingMore = false;
|
|
30
45
|
let _pendingRunTaskId = null; // session_id waiting to send "run_task" after subscribe
|
|
31
46
|
let _pendingMessage = null; // { session_id, content } — slash command to send after subscribe
|
|
32
47
|
// Buffer for tool_stdout lines that arrive before history has finished rendering.
|
|
@@ -215,7 +230,7 @@ const Sessions = (() => {
|
|
|
215
230
|
|
|
216
231
|
function _restoreMessages(id) {
|
|
217
232
|
// Clear the pane and dedup state; history will be re-fetched from API.
|
|
218
|
-
|
|
233
|
+
RenderTarget.outer().innerHTML = "";
|
|
219
234
|
delete _renderedCreatedAt[id];
|
|
220
235
|
if (_historyState[id]) {
|
|
221
236
|
_historyState[id].oldestCreatedAt = null;
|
|
@@ -305,7 +320,7 @@ const Sessions = (() => {
|
|
|
305
320
|
}
|
|
306
321
|
|
|
307
322
|
function _updateEmptyHint() {
|
|
308
|
-
const messages =
|
|
323
|
+
const messages = RenderTarget.outer();
|
|
309
324
|
if (!messages) return;
|
|
310
325
|
// Check if there's any real content besides the hint itself
|
|
311
326
|
const hasReal = Array.from(messages.children).some(
|
|
@@ -329,7 +344,7 @@ const Sessions = (() => {
|
|
|
329
344
|
}
|
|
330
345
|
|
|
331
346
|
function _initEmptyHint() {
|
|
332
|
-
const messages =
|
|
347
|
+
const messages = RenderTarget.outer();
|
|
333
348
|
if (!messages) return;
|
|
334
349
|
// Re-evaluate whenever children change (append/insertBefore/innerHTML="")
|
|
335
350
|
const observer = new MutationObserver(() => _updateEmptyHint());
|
|
@@ -345,7 +360,7 @@ const Sessions = (() => {
|
|
|
345
360
|
|
|
346
361
|
function _initNewMessageBanner() {
|
|
347
362
|
const banner = $("new-message-banner");
|
|
348
|
-
const messages =
|
|
363
|
+
const messages = RenderTarget.outer();
|
|
349
364
|
if (!banner || !messages) return;
|
|
350
365
|
|
|
351
366
|
// Click to scroll to bottom
|
|
@@ -520,7 +535,8 @@ const Sessions = (() => {
|
|
|
520
535
|
}
|
|
521
536
|
|
|
522
537
|
// Compress an image File/Blob to a data URL within MAX_IMAGE_BYTES_SEND.
|
|
523
|
-
//
|
|
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.
|
|
524
540
|
// GIF is not compressible via Canvas — rendered as JPEG (LLMs only see first frame anyway).
|
|
525
541
|
function _compressImage(file) {
|
|
526
542
|
return new Promise((resolve, reject) => {
|
|
@@ -544,14 +560,32 @@ const Sessions = (() => {
|
|
|
544
560
|
const ctx = canvas.getContext("2d");
|
|
545
561
|
ctx.drawImage(img, 0, 0, width, height);
|
|
546
562
|
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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);
|
|
553
588
|
}
|
|
554
|
-
resolve(dataUrl);
|
|
555
589
|
};
|
|
556
590
|
img.src = e.target.result;
|
|
557
591
|
};
|
|
@@ -574,7 +608,7 @@ const Sessions = (() => {
|
|
|
574
608
|
|
|
575
609
|
_compressImage(file)
|
|
576
610
|
.then(dataUrl => {
|
|
577
|
-
_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 });
|
|
578
612
|
_renderAttachmentPreviews();
|
|
579
613
|
})
|
|
580
614
|
.catch(err => alert(`Image processing failed: ${err.message}`));
|
|
@@ -733,6 +767,9 @@ const Sessions = (() => {
|
|
|
733
767
|
}).join(" ");
|
|
734
768
|
bubbleHtml = badges + (bubbleHtml ? "<br>" + bubbleHtml : "");
|
|
735
769
|
}
|
|
770
|
+
if (typeof window._closeAllPhases === "function") {
|
|
771
|
+
window._closeAllPhases("incomplete");
|
|
772
|
+
}
|
|
736
773
|
Sessions.appendMsg("user", bubbleHtml, { time: new Date() });
|
|
737
774
|
|
|
738
775
|
// Merge images and files into unified files array for WS payload.
|
|
@@ -767,6 +804,7 @@ const Sessions = (() => {
|
|
|
767
804
|
|
|
768
805
|
input.value = "";
|
|
769
806
|
input.style.height = "auto";
|
|
807
|
+
_drafts.delete(Sessions.activeId);
|
|
770
808
|
setTimeout(() => { _sending = false; }, 300);
|
|
771
809
|
}
|
|
772
810
|
|
|
@@ -837,13 +875,13 @@ const Sessions = (() => {
|
|
|
837
875
|
}
|
|
838
876
|
});
|
|
839
877
|
|
|
840
|
-
// Enter key → commit search
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
}
|
|
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
|
+
}
|
|
847
885
|
|
|
848
886
|
// Inline ✕ button — clear the q input and re-fetch
|
|
849
887
|
document.addEventListener("click", (e) => {
|
|
@@ -898,8 +936,8 @@ const Sessions = (() => {
|
|
|
898
936
|
// but kept here in case some brand / template still renders it).
|
|
899
937
|
function _initMessageHistory() {
|
|
900
938
|
// Infinite-scroll older history when the user reaches the top.
|
|
901
|
-
|
|
902
|
-
const messages =
|
|
939
|
+
RenderTarget.outer().addEventListener("scroll", (e) => {
|
|
940
|
+
const messages = e.currentTarget;
|
|
903
941
|
if (messages.scrollTop < 80 && Sessions.activeId && Sessions.hasMoreHistory(Sessions.activeId)) {
|
|
904
942
|
Sessions.loadMoreHistory(Sessions.activeId);
|
|
905
943
|
}
|
|
@@ -933,25 +971,133 @@ const Sessions = (() => {
|
|
|
933
971
|
const item = document.createElement("div");
|
|
934
972
|
item.className = "tool-item";
|
|
935
973
|
|
|
936
|
-
|
|
974
|
+
const argsJson = _formatToolArgs(args);
|
|
975
|
+
if (argsJson) item.dataset.argsJson = argsJson;
|
|
976
|
+
if (name) item.dataset.toolName = String(name);
|
|
977
|
+
|
|
937
978
|
const argSummary = summary || _summariseArgs(name, args);
|
|
938
979
|
|
|
939
|
-
// When a structured summary is available, show it as the primary label (no redundant tool name).
|
|
940
|
-
// Otherwise show the raw tool name + arg summary as before.
|
|
941
980
|
const label = summary
|
|
942
981
|
? `<span class="tool-item-name">⚙ ${escapeHtml(summary)}</span>`
|
|
943
982
|
: `<span class="tool-item-name">⚙ ${escapeHtml(name)}</span>` +
|
|
944
983
|
(argSummary ? `<span class="tool-item-arg">${escapeHtml(argSummary)}</span>` : "");
|
|
945
984
|
|
|
985
|
+
const expandable = !!argsJson;
|
|
986
|
+
const headerCls = expandable ? "tool-item-header tool-item-expandable" : "tool-item-header";
|
|
987
|
+
|
|
946
988
|
item.innerHTML =
|
|
947
|
-
`<div class="
|
|
989
|
+
`<div class="${headerCls}">` +
|
|
948
990
|
label +
|
|
949
991
|
`<span class="tool-item-status running">…</span>` +
|
|
950
992
|
`</div>` +
|
|
993
|
+
`<div class="tool-item-details" style="display:none"></div>` +
|
|
994
|
+
`<div class="tool-item-diff" style="display:none"></div>` +
|
|
951
995
|
`<pre class="tool-item-stdout" style="display:none"></pre>`;
|
|
996
|
+
_ensureCopyDelegation();
|
|
952
997
|
return item;
|
|
953
998
|
}
|
|
954
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
|
+
|
|
1075
|
+
function _toggleToolItemDetails(item) {
|
|
1076
|
+
if (!item) return;
|
|
1077
|
+
const details = item.querySelector(".tool-item-details");
|
|
1078
|
+
if (!details) return;
|
|
1079
|
+
const isHidden = details.style.display === "none";
|
|
1080
|
+
if (isHidden) {
|
|
1081
|
+
if (!details.dataset.filled) {
|
|
1082
|
+
const json = item.dataset.argsJson || "";
|
|
1083
|
+
details.textContent = json;
|
|
1084
|
+
details.dataset.filled = "1";
|
|
1085
|
+
}
|
|
1086
|
+
details.style.display = "";
|
|
1087
|
+
item.classList.add("expanded");
|
|
1088
|
+
} else {
|
|
1089
|
+
details.style.display = "none";
|
|
1090
|
+
item.classList.remove("expanded");
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Pretty-print tool args as a JSON string, or empty string if unavailable.
|
|
1095
|
+
function _formatToolArgs(args) {
|
|
1096
|
+
if (args == null) return "";
|
|
1097
|
+
if (typeof args === "string") return args;
|
|
1098
|
+
try { return JSON.stringify(args, null, 2); } catch (_) { return ""; }
|
|
1099
|
+
}
|
|
1100
|
+
|
|
955
1101
|
// Convert ANSI escape codes to HTML spans with color classes.
|
|
956
1102
|
// Handles the common SGR codes used by shell scripts (colors + reset).
|
|
957
1103
|
function _ansiToHtml(text) {
|
|
@@ -1051,6 +1197,12 @@ const Sessions = (() => {
|
|
|
1051
1197
|
status.className = "tool-item-status ok";
|
|
1052
1198
|
status.textContent = "✓";
|
|
1053
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
|
+
}
|
|
1054
1206
|
// Render the result string (e.g. "waiting (#4) — 128B\nstep1\nstep2…")
|
|
1055
1207
|
// into the stdout area so the user can see what actually happened.
|
|
1056
1208
|
// If the area already has streamed content (future feature), leave it.
|
|
@@ -1090,7 +1242,7 @@ const Sessions = (() => {
|
|
|
1090
1242
|
switch (ev.type) {
|
|
1091
1243
|
case "history_user_message": {
|
|
1092
1244
|
// Collapse any open tool group from the previous round
|
|
1093
|
-
if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
|
|
1245
|
+
if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; historyCtx.lastItem = null; }
|
|
1094
1246
|
const el = document.createElement("div");
|
|
1095
1247
|
el.className = "msg msg-user";
|
|
1096
1248
|
// Render image thumbnails and PDF badges (if any) followed by the text content
|
|
@@ -1144,7 +1296,7 @@ const Sessions = (() => {
|
|
|
1144
1296
|
|
|
1145
1297
|
case "assistant_message": {
|
|
1146
1298
|
// Collapse tool group before assistant reply
|
|
1147
|
-
if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
|
|
1299
|
+
if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; historyCtx.lastItem = null; }
|
|
1148
1300
|
const el = document.createElement("div");
|
|
1149
1301
|
el.className = "msg msg-assistant";
|
|
1150
1302
|
el.dataset.raw = ev.content || "";
|
|
@@ -1168,6 +1320,12 @@ const Sessions = (() => {
|
|
|
1168
1320
|
if (historyCtx.group && historyCtx.lastItem) {
|
|
1169
1321
|
const status = historyCtx.lastItem.querySelector(".tool-item-status");
|
|
1170
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
|
+
}
|
|
1171
1329
|
const stdout = historyCtx.lastItem.querySelector(".tool-item-stdout");
|
|
1172
1330
|
if (stdout) {
|
|
1173
1331
|
const resultStr = (ev.result == null) ? "" : String(ev.result).trim();
|
|
@@ -1178,21 +1336,18 @@ const Sessions = (() => {
|
|
|
1178
1336
|
stdout.style.display = "none";
|
|
1179
1337
|
}
|
|
1180
1338
|
}
|
|
1181
|
-
historyCtx.lastItem = null;
|
|
1182
1339
|
}
|
|
1183
1340
|
break;
|
|
1184
1341
|
}
|
|
1185
1342
|
|
|
1186
1343
|
case "token_usage": {
|
|
1187
|
-
|
|
1188
|
-
if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
|
|
1189
|
-
Sessions.appendTokenUsage(ev, container);
|
|
1344
|
+
Sessions.appendTokenUsage(ev, container, historyCtx.lastItem);
|
|
1190
1345
|
break;
|
|
1191
1346
|
}
|
|
1192
1347
|
|
|
1193
1348
|
case "request_feedback": {
|
|
1194
1349
|
// Collapse any open tool group
|
|
1195
|
-
if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; }
|
|
1350
|
+
if (historyCtx.group) { _collapseToolGroup(historyCtx.group); historyCtx.group = null; historyCtx.lastItem = null; }
|
|
1196
1351
|
|
|
1197
1352
|
const rfQuestion = ev.question || "";
|
|
1198
1353
|
const rfContext = ev.context || "";
|
|
@@ -1260,7 +1415,7 @@ const Sessions = (() => {
|
|
|
1260
1415
|
stdout.innerHTML += lines.map(_ansiToHtml).join("");
|
|
1261
1416
|
if (stdout.style.display === "none") stdout.style.display = "";
|
|
1262
1417
|
stdout.scrollTop = stdout.scrollHeight;
|
|
1263
|
-
const messages =
|
|
1418
|
+
const messages = RenderTarget.outer();
|
|
1264
1419
|
_scrollToBottomIfNeeded(messages);
|
|
1265
1420
|
}
|
|
1266
1421
|
|
|
@@ -1271,7 +1426,7 @@ const Sessions = (() => {
|
|
|
1271
1426
|
const lines = _pendingStdoutLines;
|
|
1272
1427
|
_pendingStdoutLines = null;
|
|
1273
1428
|
|
|
1274
|
-
const messages =
|
|
1429
|
+
const messages = RenderTarget.outer();
|
|
1275
1430
|
if (!messages) return;
|
|
1276
1431
|
const items = messages.querySelectorAll(".tool-item");
|
|
1277
1432
|
if (items.length === 0) return;
|
|
@@ -1351,9 +1506,9 @@ const Sessions = (() => {
|
|
|
1351
1506
|
// Collapse any tool group still open at end of page
|
|
1352
1507
|
if (historyCtx.group) _collapseToolGroup(historyCtx.group);
|
|
1353
1508
|
|
|
1354
|
-
// Insert into
|
|
1509
|
+
// Insert into the outer message stream (history never lands inside an active phase card).
|
|
1355
1510
|
if (id === _activeId) {
|
|
1356
|
-
const messages =
|
|
1511
|
+
const messages = RenderTarget.outer();
|
|
1357
1512
|
if (prepend && messages.firstChild) {
|
|
1358
1513
|
const scrollBefore = messages.scrollHeight - messages.scrollTop;
|
|
1359
1514
|
messages.insertBefore(frag, messages.firstChild);
|
|
@@ -1500,9 +1655,17 @@ const Sessions = (() => {
|
|
|
1500
1655
|
let _copyDelegationInstalled = false;
|
|
1501
1656
|
function _ensureCopyDelegation() {
|
|
1502
1657
|
if (_copyDelegationInstalled) return;
|
|
1503
|
-
const messages =
|
|
1658
|
+
const messages = RenderTarget.outer();
|
|
1504
1659
|
if (!messages) return;
|
|
1505
1660
|
messages.addEventListener("click", (e) => {
|
|
1661
|
+
// ── Tool item: click header to expand/collapse args details ──
|
|
1662
|
+
const toolHeader = e.target.closest(".tool-item-header.tool-item-expandable");
|
|
1663
|
+
if (toolHeader) {
|
|
1664
|
+
e.preventDefault();
|
|
1665
|
+
e.stopPropagation();
|
|
1666
|
+
_toggleToolItemDetails(toolHeader.closest(".tool-item"));
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1506
1669
|
// ── Code block copy button ──
|
|
1507
1670
|
const codeBtn = e.target.closest(".code-block-copy");
|
|
1508
1671
|
if (codeBtn) {
|
|
@@ -1584,16 +1747,54 @@ const Sessions = (() => {
|
|
|
1584
1747
|
}
|
|
1585
1748
|
|
|
1586
1749
|
// Build the unified load-more button.
|
|
1587
|
-
function _makeLoadMoreBtn() {
|
|
1750
|
+
function _makeLoadMoreBtn(cron = false) {
|
|
1588
1751
|
const btn = document.createElement("button");
|
|
1589
1752
|
btn.className = "btn-load-more-sessions";
|
|
1590
|
-
|
|
1591
|
-
btn.
|
|
1592
|
-
btn.
|
|
1753
|
+
const loading = cron ? _cronLoadingMore : _loadingMore;
|
|
1754
|
+
btn.disabled = loading;
|
|
1755
|
+
btn.textContent = loading ? I18n.t("sessions.loadingMore") : I18n.t("sessions.loadMore");
|
|
1756
|
+
btn.onclick = () => cron ? Sessions.loadMoreCron() : Sessions.loadMore();
|
|
1593
1757
|
return btn;
|
|
1594
1758
|
}
|
|
1595
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
|
+
|
|
1596
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
|
+
|
|
1597
1798
|
//
|
|
1598
1799
|
// Build and append a single session-item <div> into `container`.
|
|
1599
1800
|
// Used by both the general list and the coding section.
|
|
@@ -1604,6 +1805,10 @@ const Sessions = (() => {
|
|
|
1604
1805
|
if (s.pinned) el.classList.add("pinned");
|
|
1605
1806
|
|
|
1606
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);
|
|
1607
1812
|
|
|
1608
1813
|
// Meta line — prefer relative time of last activity. Tasks count is
|
|
1609
1814
|
// only shown when > 0 to avoid visual noise on fresh sessions.
|
|
@@ -1644,10 +1849,15 @@ const Sessions = (() => {
|
|
|
1644
1849
|
? `<span class="session-dot dot-${s.status}"></span>`
|
|
1645
1850
|
: "";
|
|
1646
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
|
+
|
|
1647
1856
|
el.innerHTML = `
|
|
1648
1857
|
<div class="session-body">
|
|
1649
|
-
<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>
|
|
1650
1859
|
<div class="session-meta">${metaText}</div>
|
|
1860
|
+
${snippetHtml}
|
|
1651
1861
|
</div>
|
|
1652
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>`;
|
|
1653
1863
|
|
|
@@ -1668,6 +1878,13 @@ const Sessions = (() => {
|
|
|
1668
1878
|
}, 200);
|
|
1669
1879
|
};
|
|
1670
1880
|
|
|
1881
|
+
// Right-click context menu
|
|
1882
|
+
el.oncontextmenu = (e) => {
|
|
1883
|
+
e.preventDefault();
|
|
1884
|
+
Sessions._closeActionsMenu();
|
|
1885
|
+
_showContextMenu(e, s);
|
|
1886
|
+
};
|
|
1887
|
+
|
|
1671
1888
|
// Actions button - show menu
|
|
1672
1889
|
const actionsBtn = el.querySelector(".session-actions-btn");
|
|
1673
1890
|
actionsBtn.onclick = (e) => {
|
|
@@ -1679,13 +1896,17 @@ const Sessions = (() => {
|
|
|
1679
1896
|
}
|
|
1680
1897
|
|
|
1681
1898
|
// ── Cron group entry (renders the folded "Scheduled Tasks" entry) ─────
|
|
1682
|
-
|
|
1899
|
+
// `hasRunning` mirrors a normal session's status dot: show the green
|
|
1900
|
+
// (running) dot only when any folded cron session is currently running,
|
|
1901
|
+
// and render no dot at all when the group is idle.
|
|
1902
|
+
function _renderCronGroupItem(container, count, hasRunning = false) {
|
|
1683
1903
|
const el = document.createElement("div");
|
|
1684
1904
|
el.className = "session-item cron-group-item";
|
|
1905
|
+
const dotHtml = hasRunning ? `<span class="session-dot dot-running"></span>` : "";
|
|
1685
1906
|
el.innerHTML = `
|
|
1686
1907
|
<div class="session-body">
|
|
1687
1908
|
<div class="session-name">
|
|
1688
|
-
|
|
1909
|
+
${dotHtml}
|
|
1689
1910
|
<span class="session-name__text">📋 ${I18n.t("sessions.cronGroup")} (${count})</span>
|
|
1690
1911
|
</div>
|
|
1691
1912
|
<div class="session-meta">${I18n.t("sessions.cronGroupMeta", { n: count })}</div>
|
|
@@ -1694,6 +1915,10 @@ const Sessions = (() => {
|
|
|
1694
1915
|
el.onclick = () => {
|
|
1695
1916
|
_cronView = true;
|
|
1696
1917
|
Sessions.renderList();
|
|
1918
|
+
// Method A: always (re-)load the freshest first page of cron sessions
|
|
1919
|
+
// on entering the sub-view. Independent cursor — never touches the
|
|
1920
|
+
// outer list's pagination.
|
|
1921
|
+
Sessions.loadMoreCron({ reset: true });
|
|
1697
1922
|
};
|
|
1698
1923
|
container.appendChild(el);
|
|
1699
1924
|
}
|
|
@@ -1830,6 +2055,7 @@ const Sessions = (() => {
|
|
|
1830
2055
|
}
|
|
1831
2056
|
// Clean up per-session progress state (timer + DOM + logical state)
|
|
1832
2057
|
Sessions._deleteProgressState(id);
|
|
2058
|
+
_drafts.delete(id);
|
|
1833
2059
|
},
|
|
1834
2060
|
|
|
1835
2061
|
/** Load the next page of older sessions (unified time cursor). */
|
|
@@ -1881,9 +2107,55 @@ const Sessions = (() => {
|
|
|
1881
2107
|
}
|
|
1882
2108
|
},
|
|
1883
2109
|
|
|
2110
|
+
/** Cron sub-view pagination — independent cursor, does NOT touch the outer
|
|
2111
|
+
* list's `_hasMore` / `loadMore` cursor. Fetches the next page of cron
|
|
2112
|
+
* sessions (type=cron) and pushes them into the shared `_sessions` array
|
|
2113
|
+
* (deduped), so WS patch/remove/add keep working unchanged. Only the
|
|
2114
|
+
* sub-view's own cursor + flags advance. Pass `reset:true` to start over
|
|
2115
|
+
* from the newest cron page (used on entering the sub-view). */
|
|
2116
|
+
async loadMoreCron({ reset = false } = {}) {
|
|
2117
|
+
if (_cronLoadingMore) return;
|
|
2118
|
+
if (!reset && !_cronHasMore) return;
|
|
2119
|
+
_cronLoadingMore = true;
|
|
2120
|
+
if (reset) { _cronBefore = null; _cronHasMore = false; }
|
|
2121
|
+
|
|
2122
|
+
const sidebarList = document.getElementById("sidebar-list");
|
|
2123
|
+
const savedScrollTop = sidebarList ? sidebarList.scrollTop : 0;
|
|
2124
|
+
Sessions.renderList({ skipScrollToActive: true });
|
|
2125
|
+
|
|
2126
|
+
try {
|
|
2127
|
+
const params = new URLSearchParams({ limit: "20", type: "cron" });
|
|
2128
|
+
if (_cronBefore) params.set("before", _cronBefore);
|
|
2129
|
+
|
|
2130
|
+
const res = await fetch(`/api/sessions?${params}`);
|
|
2131
|
+
if (!res.ok) return;
|
|
2132
|
+
const data = await res.json();
|
|
2133
|
+
|
|
2134
|
+
const rows = data.sessions || [];
|
|
2135
|
+
rows.forEach(s => {
|
|
2136
|
+
if (!_sessions.find(x => x.id === s.id)) _sessions.push(s);
|
|
2137
|
+
});
|
|
2138
|
+
// Advance cursor to the oldest cron created_at in THIS batch (exclude
|
|
2139
|
+
// pinned — backend returns all pinned on the first page, bypassing
|
|
2140
|
+
// pagination, so their created_at must not drag the cursor back).
|
|
2141
|
+
const oldest = rows.reduce((min, s) => {
|
|
2142
|
+
if (s.pinned || !s.created_at) return min;
|
|
2143
|
+
return (!min || s.created_at < min) ? s.created_at : min;
|
|
2144
|
+
}, null);
|
|
2145
|
+
if (oldest) _cronBefore = oldest;
|
|
2146
|
+
_cronHasMore = !!data.has_more;
|
|
2147
|
+
if (data.cron_count != null) _cronCount = data.cron_count;
|
|
2148
|
+
} catch (e) {
|
|
2149
|
+
console.error("loadMoreCron error:", e);
|
|
2150
|
+
} finally {
|
|
2151
|
+
_cronLoadingMore = false;
|
|
2152
|
+
Sessions.renderList({ skipScrollToActive: true });
|
|
2153
|
+
if (sidebarList) sidebarList.scrollTop = savedScrollTop;
|
|
2154
|
+
}
|
|
2155
|
+
},
|
|
2156
|
+
|
|
1884
2157
|
/** Commit current filter values and re-fetch from server. Called by Enter / Go button. */
|
|
1885
2158
|
async commitSearch() {
|
|
1886
|
-
// Read live input values into _filter
|
|
1887
2159
|
const qEl = document.getElementById("session-search-q");
|
|
1888
2160
|
const typeEl = document.getElementById("session-search-type");
|
|
1889
2161
|
const dateEl = document.getElementById("session-search-date");
|
|
@@ -1891,29 +2163,73 @@ const Sessions = (() => {
|
|
|
1891
2163
|
if (typeEl) _filter.type = typeEl.value;
|
|
1892
2164
|
if (dateEl) _filter.date = dateEl.dataset.value || "";
|
|
1893
2165
|
|
|
1894
|
-
|
|
1895
|
-
_sessions.length = 0;
|
|
1896
|
-
_hasMore = false;
|
|
2166
|
+
const token = ++_searchToken;
|
|
1897
2167
|
_loadingMore = true;
|
|
1898
|
-
Sessions.renderList();
|
|
2168
|
+
Sessions.renderList({ skipScrollToActive: true });
|
|
2169
|
+
|
|
2170
|
+
let nextSessions = [];
|
|
2171
|
+
let nextHasMore = false;
|
|
2172
|
+
let nextCronCnt = 0;
|
|
2173
|
+
let nextSplit = null;
|
|
1899
2174
|
|
|
1900
2175
|
try {
|
|
1901
|
-
const
|
|
1902
|
-
if (_filter.
|
|
1903
|
-
if (_filter.
|
|
1904
|
-
|
|
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
|
+
});
|
|
1905
2204
|
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
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;
|
|
1912
2226
|
} catch (e) {
|
|
1913
|
-
console.error("commitSearch error:", e);
|
|
2227
|
+
if (token === _searchToken) console.error("commitSearch error:", e);
|
|
1914
2228
|
} finally {
|
|
1915
|
-
|
|
1916
|
-
|
|
2229
|
+
if (token === _searchToken) {
|
|
2230
|
+
_loadingMore = false;
|
|
2231
|
+
Sessions.renderList();
|
|
2232
|
+
}
|
|
1917
2233
|
}
|
|
1918
2234
|
},
|
|
1919
2235
|
|
|
@@ -2005,6 +2321,26 @@ const Sessions = (() => {
|
|
|
2005
2321
|
}
|
|
2006
2322
|
},
|
|
2007
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
|
+
|
|
2008
2344
|
// ── Selection ─────────────────────────────────────────────────────────
|
|
2009
2345
|
//
|
|
2010
2346
|
// Panel switching is handled by Router — Sessions only manages state.
|
|
@@ -2032,6 +2368,12 @@ const Sessions = (() => {
|
|
|
2032
2368
|
/** Set _activeId directly (called by Router when activating a session). */
|
|
2033
2369
|
_setActiveId(id) {
|
|
2034
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
|
+
}
|
|
2035
2377
|
},
|
|
2036
2378
|
|
|
2037
2379
|
/** Restore cached messages for a session into the #messages container. */
|
|
@@ -2043,9 +2385,11 @@ const Sessions = (() => {
|
|
|
2043
2385
|
* Called by Router before switching away from a session view. */
|
|
2044
2386
|
_cacheActiveAndDeselect() {
|
|
2045
2387
|
_cacheActiveMessages();
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2388
|
+
if (_activeId) {
|
|
2389
|
+
const input = $("user-input");
|
|
2390
|
+
if (input) _drafts.set(_activeId, input.value);
|
|
2391
|
+
Sessions._detachProgressUI(_activeId);
|
|
2392
|
+
}
|
|
2049
2393
|
_activeId = null;
|
|
2050
2394
|
WS.setSubscribedSession(null);
|
|
2051
2395
|
Sessions.renderList();
|
|
@@ -2097,7 +2441,6 @@ const Sessions = (() => {
|
|
|
2097
2441
|
const hasActiveFilter = !!(_filter.q || _filter.type || _filter.date);
|
|
2098
2442
|
const isCronView = _cronView && !hasActiveFilter;
|
|
2099
2443
|
const cronSessions = visible.filter(s => s.source === "cron");
|
|
2100
|
-
const nonCronSessions = visible.filter(s => s.source !== "cron");
|
|
2101
2444
|
|
|
2102
2445
|
// Update chat-section header based on view mode
|
|
2103
2446
|
_updateChatHeader(isCronView);
|
|
@@ -2106,30 +2449,70 @@ const Sessions = (() => {
|
|
|
2106
2449
|
list.innerHTML = "";
|
|
2107
2450
|
|
|
2108
2451
|
if (hasActiveFilter) {
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
Sessions.loadMore(); // async — will call renderList() again when done
|
|
2118
|
-
return; // skip empty-state / load-more button for now
|
|
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));
|
|
2119
2460
|
}
|
|
2120
|
-
if (
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
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
|
+
}
|
|
2126
2471
|
}
|
|
2472
|
+
} else {
|
|
2473
|
+
visible.forEach(s => _renderSessionItem(list, s));
|
|
2474
|
+
}
|
|
2475
|
+
} else if (isCronView) {
|
|
2476
|
+
// Cron sub-view: show only cron sessions, paginated independently via
|
|
2477
|
+
// loadMoreCron() (the first page is fetched on entering the view).
|
|
2478
|
+
// We never call the outer loadMore() here, so the outer list's cursor
|
|
2479
|
+
// is left untouched. While the first page is in flight and nothing is
|
|
2480
|
+
// loaded yet, show a loading placeholder instead of the empty state.
|
|
2481
|
+
if (cronSessions.length === 0 && _cronLoadingMore) {
|
|
2482
|
+
list.innerHTML = `<div class="session-empty">${I18n.t("sessions.cronLoading")}</div>`;
|
|
2483
|
+
return;
|
|
2127
2484
|
}
|
|
2128
2485
|
cronSessions.forEach(s => _renderSessionItem(list, s));
|
|
2129
2486
|
} else if (_cronCount > 0) {
|
|
2130
|
-
// Normal list view: group entry
|
|
2131
|
-
|
|
2132
|
-
|
|
2487
|
+
// Normal list view: the cron group entry is a *virtual* row that
|
|
2488
|
+
// participates in the time-ordering instead of being pinned to the
|
|
2489
|
+
// top. We walk the already-sorted `visible` list and drop the group
|
|
2490
|
+
// entry at the position of the newest (first-encountered) cron
|
|
2491
|
+
// session — so it sits exactly where the latest folded cron task
|
|
2492
|
+
// would sort by created_at. Pinning is intentionally ignored for the
|
|
2493
|
+
// entry itself: a pinned cron session only takes effect *inside* the
|
|
2494
|
+
// folded sub-view, not on the outer entry.
|
|
2495
|
+
const cronHasRunning = cronSessions.some(s => s.status === "running");
|
|
2496
|
+
let cronEntryRendered = false;
|
|
2497
|
+
visible.forEach(s => {
|
|
2498
|
+
if (s.source === "cron") {
|
|
2499
|
+
// Render the group entry once, at the first (newest) cron slot;
|
|
2500
|
+
// skip every individual cron session in the flat list.
|
|
2501
|
+
if (!cronEntryRendered) {
|
|
2502
|
+
_renderCronGroupItem(list, _cronCount, cronHasRunning);
|
|
2503
|
+
cronEntryRendered = true;
|
|
2504
|
+
}
|
|
2505
|
+
return;
|
|
2506
|
+
}
|
|
2507
|
+
_renderSessionItem(list, s);
|
|
2508
|
+
});
|
|
2509
|
+
// NOTE: we intentionally do NOT append a fallback entry when no cron
|
|
2510
|
+
// session is loaded yet. The group entry is a virtual row that must
|
|
2511
|
+
// ride along with pagination: it only appears at the sort slot of the
|
|
2512
|
+
// first loaded cron session. If the newest cron lives on a later page,
|
|
2513
|
+
// the entry simply shows up once that page is loaded — rather than
|
|
2514
|
+
// being forced onto the bottom of page 1 (which would mislead the
|
|
2515
|
+
// sort position and miss the running state of an unpaged cron).
|
|
2133
2516
|
} else {
|
|
2134
2517
|
// Normal list view, no cron sessions
|
|
2135
2518
|
visible.forEach(s => _renderSessionItem(list, s));
|
|
@@ -2140,7 +2523,11 @@ const Sessions = (() => {
|
|
|
2140
2523
|
list.innerHTML = `<div class="session-empty">${I18n.t("sessions.empty")}</div>`;
|
|
2141
2524
|
}
|
|
2142
2525
|
|
|
2143
|
-
if (
|
|
2526
|
+
if (isCronView) {
|
|
2527
|
+
if (_cronHasMore) list.appendChild(_makeLoadMoreBtn(true));
|
|
2528
|
+
} else if (_hasMore) {
|
|
2529
|
+
list.appendChild(_makeLoadMoreBtn());
|
|
2530
|
+
}
|
|
2144
2531
|
|
|
2145
2532
|
// Scroll active session into view so the sidebar always shows the current session.
|
|
2146
2533
|
if (!skipScrollToActive) {
|
|
@@ -2180,6 +2567,54 @@ const Sessions = (() => {
|
|
|
2180
2567
|
}
|
|
2181
2568
|
},
|
|
2182
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
|
+
|
|
2183
2618
|
/** Show actions menu (pin/rename/delete) next to the actions button. */
|
|
2184
2619
|
_showActionsMenu(button, session) {
|
|
2185
2620
|
// Close any existing menu first
|
|
@@ -2187,6 +2622,7 @@ const Sessions = (() => {
|
|
|
2187
2622
|
|
|
2188
2623
|
// Lucide-style stroked icons to match the rest of the UI
|
|
2189
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>`;
|
|
2190
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>`;
|
|
2191
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>`;
|
|
2192
2628
|
|
|
@@ -2195,6 +2631,10 @@ const Sessions = (() => {
|
|
|
2195
2631
|
const menu = document.createElement("div");
|
|
2196
2632
|
menu.className = "session-actions-menu";
|
|
2197
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>
|
|
2198
2638
|
<div class="session-actions-menu-item" data-action="pin">
|
|
2199
2639
|
<span class="session-actions-menu-icon">${iconPin}</span>
|
|
2200
2640
|
<span class="session-actions-menu-label">${escapeHtml(pinLabel)}</span>
|
|
@@ -2224,7 +2664,9 @@ const Sessions = (() => {
|
|
|
2224
2664
|
const action = item.dataset.action;
|
|
2225
2665
|
Sessions._closeActionsMenu();
|
|
2226
2666
|
|
|
2227
|
-
if (action === "
|
|
2667
|
+
if (action === "fork") {
|
|
2668
|
+
await Sessions.fork(session.id);
|
|
2669
|
+
} else if (action === "pin") {
|
|
2228
2670
|
await Sessions.togglePin(session.id);
|
|
2229
2671
|
} else if (action === "rename") {
|
|
2230
2672
|
// Close sidebar on mobile so the rename dialog isn't obscured
|
|
@@ -2580,10 +3022,15 @@ const Sessions = (() => {
|
|
|
2580
3022
|
_liveToolGroup: null, // current open .tool-group DOM element
|
|
2581
3023
|
_liveLastToolItem: null, // last .tool-item added (for tool_result pairing)
|
|
2582
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
|
+
|
|
2583
3030
|
// Append a tool_call as a compact item inside the live tool group.
|
|
2584
3031
|
// Creates the group if it doesn't exist yet.
|
|
2585
3032
|
appendToolCall(name, args, summary) {
|
|
2586
|
-
const messages =
|
|
3033
|
+
const messages = RenderTarget.current();
|
|
2587
3034
|
if (!Sessions._liveToolGroup) {
|
|
2588
3035
|
Sessions._liveToolGroup = _makeToolGroup();
|
|
2589
3036
|
messages.appendChild(Sessions._liveToolGroup);
|
|
@@ -2596,7 +3043,6 @@ const Sessions = (() => {
|
|
|
2596
3043
|
appendToolResult(result) {
|
|
2597
3044
|
if (Sessions._liveToolGroup && Sessions._liveLastToolItem) {
|
|
2598
3045
|
_completeLastToolItem(Sessions._liveToolGroup, result);
|
|
2599
|
-
Sessions._liveLastToolItem = null;
|
|
2600
3046
|
}
|
|
2601
3047
|
},
|
|
2602
3048
|
|
|
@@ -2609,7 +3055,7 @@ const Sessions = (() => {
|
|
|
2609
3055
|
// .tool-item visible in the DOM — that is the in-flight tool the stdout belongs to.
|
|
2610
3056
|
let toolItem = Sessions._liveLastToolItem;
|
|
2611
3057
|
if (!toolItem) {
|
|
2612
|
-
const messages =
|
|
3058
|
+
const messages = RenderTarget.current();
|
|
2613
3059
|
if (messages) {
|
|
2614
3060
|
const items = messages.querySelectorAll(".tool-item");
|
|
2615
3061
|
if (items.length > 0) toolItem = items[items.length - 1];
|
|
@@ -2627,12 +3073,13 @@ const Sessions = (() => {
|
|
|
2627
3073
|
_applyStdoutToItem(toolItem, lines);
|
|
2628
3074
|
},
|
|
2629
3075
|
|
|
2630
|
-
// Append a token usage line
|
|
2631
|
-
//
|
|
2632
|
-
//
|
|
2633
|
-
//
|
|
2634
|
-
appendTokenUsage(ev, container) {
|
|
2635
|
-
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;
|
|
2636
3083
|
const el = document.createElement("div");
|
|
2637
3084
|
el.className = "token-usage-line";
|
|
2638
3085
|
|
|
@@ -2691,8 +3138,13 @@ const Sessions = (() => {
|
|
|
2691
3138
|
`<span class="tu-field">Total: <b>${(ev.total_tokens || 0).toLocaleString()}</b></span>` +
|
|
2692
3139
|
`</span>`;
|
|
2693
3140
|
|
|
2694
|
-
|
|
2695
|
-
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
|
+
}
|
|
2696
3148
|
},
|
|
2697
3149
|
|
|
2698
3150
|
// Collapse the live tool group (call when AI starts responding or task ends).
|
|
@@ -2708,7 +3160,7 @@ const Sessions = (() => {
|
|
|
2708
3160
|
// Starting a new assistant/user/info message: close any open tool group
|
|
2709
3161
|
if (type !== "tool") Sessions.collapseToolGroup();
|
|
2710
3162
|
|
|
2711
|
-
const messages =
|
|
3163
|
+
const messages = RenderTarget.current();
|
|
2712
3164
|
|
|
2713
3165
|
// For error messages: remove any existing error messages first to avoid duplicates
|
|
2714
3166
|
if (type === "error") {
|
|
@@ -2762,7 +3214,7 @@ const Sessions = (() => {
|
|
|
2762
3214
|
|
|
2763
3215
|
appendInfo(text, subline) {
|
|
2764
3216
|
Sessions.collapseToolGroup();
|
|
2765
|
-
const messages =
|
|
3217
|
+
const messages = RenderTarget.current();
|
|
2766
3218
|
const el = document.createElement("div");
|
|
2767
3219
|
el.className = subline ? "msg msg-info msg-info-main" : "msg msg-info";
|
|
2768
3220
|
el.textContent = text;
|
|
@@ -2780,7 +3232,7 @@ const Sessions = (() => {
|
|
|
2780
3232
|
// Called when the agent needs user input to continue.
|
|
2781
3233
|
showFeedbackRequest(question, context, options) {
|
|
2782
3234
|
Sessions.collapseToolGroup();
|
|
2783
|
-
const messages =
|
|
3235
|
+
const messages = RenderTarget.current();
|
|
2784
3236
|
const hasOptions = options && Array.isArray(options) && options.length > 0;
|
|
2785
3237
|
|
|
2786
3238
|
// Normalize bullet symbols to markdown list format so marked renders them as <ul>
|
|
@@ -2925,7 +3377,7 @@ const Sessions = (() => {
|
|
|
2925
3377
|
// Only attach if this session is currently visible
|
|
2926
3378
|
if (id !== _activeId) return;
|
|
2927
3379
|
|
|
2928
|
-
const messages =
|
|
3380
|
+
const messages = RenderTarget.outer();
|
|
2929
3381
|
if (!messages) return;
|
|
2930
3382
|
|
|
2931
3383
|
// Clean up any previous DOM/timer for this session (idempotent)
|
|
@@ -3000,7 +3452,7 @@ const Sessions = (() => {
|
|
|
3000
3452
|
existing.el.textContent = Sessions._composeProgressLine(existing.displayText, existing.startTime, existing.metadata, existing.lastChunkAt);
|
|
3001
3453
|
}
|
|
3002
3454
|
}, 250);
|
|
3003
|
-
_scrollToBottomIfNeeded(
|
|
3455
|
+
_scrollToBottomIfNeeded(RenderTarget.outer());
|
|
3004
3456
|
return;
|
|
3005
3457
|
}
|
|
3006
3458
|
|
|
@@ -3452,16 +3904,16 @@ const Sessions = (() => {
|
|
|
3452
3904
|
|
|
3453
3905
|
const nameLine = document.createElement("span");
|
|
3454
3906
|
nameLine.className = "sib-model-name-main";
|
|
3455
|
-
|
|
3907
|
+
// When a non-default quick-switch model is active for the current row,
|
|
3908
|
+
// show only that model's name (avoid the long "main → quick-switch"
|
|
3909
|
+
// string that gets truncated).
|
|
3910
|
+
const hasActiveOverride =
|
|
3911
|
+
m.id === currentModelId &&
|
|
3912
|
+
subInfo.current &&
|
|
3913
|
+
subInfo.current !== subInfo.cardModel;
|
|
3914
|
+
nameLine.textContent = hasActiveOverride ? subInfo.current : m.model;
|
|
3456
3915
|
left.appendChild(nameLine);
|
|
3457
3916
|
|
|
3458
|
-
if (m.id === currentModelId && subInfo.current && subInfo.current !== subInfo.cardModel) {
|
|
3459
|
-
const overrideLine = document.createElement("span");
|
|
3460
|
-
overrideLine.className = "sib-model-name-override";
|
|
3461
|
-
overrideLine.textContent = `→ ${subInfo.current}`;
|
|
3462
|
-
left.appendChild(overrideLine);
|
|
3463
|
-
}
|
|
3464
|
-
|
|
3465
3917
|
if (_nameCounts[m.model] > 1) {
|
|
3466
3918
|
left.classList.add("has-sub");
|
|
3467
3919
|
const host = (() => {
|
|
@@ -3503,7 +3955,7 @@ const Sessions = (() => {
|
|
|
3503
3955
|
const toggleBtn = document.createElement("button");
|
|
3504
3956
|
toggleBtn.type = "button";
|
|
3505
3957
|
toggleBtn.className = "sib-submodel-toggle";
|
|
3506
|
-
toggleBtn.title = "
|
|
3958
|
+
toggleBtn.title = I18n.t("sib.variant.header");
|
|
3507
3959
|
toggleBtn.setAttribute("aria-expanded", "false");
|
|
3508
3960
|
toggleBtn.innerHTML =
|
|
3509
3961
|
'<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">' +
|
|
@@ -3766,7 +4218,7 @@ const Sessions = (() => {
|
|
|
3766
4218
|
|
|
3767
4219
|
const header = document.createElement("div");
|
|
3768
4220
|
header.className = "sib-submodel-panel-header";
|
|
3769
|
-
header.textContent = "
|
|
4221
|
+
header.textContent = I18n.t("sib.variant.header");
|
|
3770
4222
|
panel.appendChild(header);
|
|
3771
4223
|
|
|
3772
4224
|
const cardDefault = subInfo.cardModel;
|
|
@@ -3788,7 +4240,7 @@ const Sessions = (() => {
|
|
|
3788
4240
|
if (name === cardDefault) {
|
|
3789
4241
|
const tag = document.createElement("span");
|
|
3790
4242
|
tag.className = "sib-submodel-default-tag";
|
|
3791
|
-
tag.textContent = "default";
|
|
4243
|
+
tag.textContent = I18n.t("sib.variant.default");
|
|
3792
4244
|
row.appendChild(tag);
|
|
3793
4245
|
}
|
|
3794
4246
|
|
|
@@ -4040,11 +4492,12 @@ const Sessions = (() => {
|
|
|
4040
4492
|
// Load tree for a given path
|
|
4041
4493
|
async function loadTreeForPath(dirPath, absolute = false) {
|
|
4042
4494
|
treeContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
|
|
4495
|
+
if (dirPath) { pathInput.value = dirPath; selectedPath = dirPath; }
|
|
4043
4496
|
// Auto-detect absolute mode: path is absolute and outside working directory
|
|
4044
|
-
const useAbsolute = absolute || (dirPath.startsWith("/") && (!rootDir || !dirPath.startsWith(rootDir)));
|
|
4497
|
+
const useAbsolute = absolute || (dirPath.startsWith("/") && (!rootDir || !(dirPath === rootDir || dirPath.startsWith(rootDir + "/"))));
|
|
4045
4498
|
// Convert absolute path to relative path for API
|
|
4046
4499
|
let relPath = dirPath;
|
|
4047
|
-
if (!useAbsolute && rootDir && dirPath.startsWith(rootDir)) {
|
|
4500
|
+
if (!useAbsolute && rootDir && (dirPath === rootDir || dirPath.startsWith(rootDir + "/"))) {
|
|
4048
4501
|
relPath = dirPath.substring(rootDir.length).replace(/^\/+/, "");
|
|
4049
4502
|
}
|
|
4050
4503
|
try {
|
|
@@ -4053,7 +4506,7 @@ const Sessions = (() => {
|
|
|
4053
4506
|
if (rootDir && presets.children.length === 0) {
|
|
4054
4507
|
setupPresets();
|
|
4055
4508
|
// Update pathInput to show absolute path
|
|
4056
|
-
if (rootDir !== currentDir && !currentDir.startsWith(rootDir)) {
|
|
4509
|
+
if (rootDir !== currentDir && !(currentDir === rootDir || currentDir.startsWith(rootDir + "/"))) {
|
|
4057
4510
|
pathInput.value = rootDir;
|
|
4058
4511
|
selectedPath = rootDir;
|
|
4059
4512
|
}
|
|
@@ -4146,9 +4599,9 @@ const Sessions = (() => {
|
|
|
4146
4599
|
}
|
|
4147
4600
|
|
|
4148
4601
|
// Determine if we need absolute mode (path outside working directory)
|
|
4149
|
-
const isAbsolute = parentPath.startsWith("/") && (!rootDir || !parentPath.startsWith(rootDir));
|
|
4602
|
+
const isAbsolute = parentPath.startsWith("/") && (!rootDir || !(parentPath === rootDir || parentPath.startsWith(rootDir + "/")));
|
|
4150
4603
|
let relPath = parentPath;
|
|
4151
|
-
if (!isAbsolute && rootDir && parentPath.startsWith(rootDir)) {
|
|
4604
|
+
if (!isAbsolute && rootDir && (parentPath === rootDir || parentPath.startsWith(rootDir + "/"))) {
|
|
4152
4605
|
relPath = parentPath.substring(rootDir.length).replace(/^\/+/, "");
|
|
4153
4606
|
}
|
|
4154
4607
|
|
|
@@ -4180,10 +4633,12 @@ const Sessions = (() => {
|
|
|
4180
4633
|
});
|
|
4181
4634
|
|
|
4182
4635
|
// Keyboard navigation in autocomplete
|
|
4636
|
+
const pathIme = IME.track(pathInput);
|
|
4183
4637
|
pathInput.addEventListener("keydown", (e) => {
|
|
4184
4638
|
const items = autocomplete.querySelectorAll(".dp-ac-item");
|
|
4185
4639
|
if (!items.length || autocomplete.style.display === "none") {
|
|
4186
4640
|
if (e.key === "Enter") {
|
|
4641
|
+
if (pathIme.isComposing(e)) return;
|
|
4187
4642
|
e.preventDefault();
|
|
4188
4643
|
// Navigate to typed path without closing modal
|
|
4189
4644
|
const dir = pathInput.value.trim();
|
|
@@ -4207,6 +4662,7 @@ const Sessions = (() => {
|
|
|
4207
4662
|
items.forEach((el, i) => el.classList.toggle("active", i === activeIndex));
|
|
4208
4663
|
items[activeIndex]?.scrollIntoView({ block: "nearest" });
|
|
4209
4664
|
} else if (e.key === "Enter") {
|
|
4665
|
+
if (pathIme.isComposing(e)) return;
|
|
4210
4666
|
e.preventDefault();
|
|
4211
4667
|
if (activeIndex >= 0 && items[activeIndex]) {
|
|
4212
4668
|
// Select the highlighted suggestion
|