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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +1 -1
  3. data/.clacky/skills/gem-release/scripts/release.sh +4 -1
  4. data/CHANGELOG.md +56 -1
  5. data/lib/clacky/agent/llm_caller.rb +40 -25
  6. data/lib/clacky/agent/memory_updater.rb +12 -0
  7. data/lib/clacky/agent/session_serializer.rb +1 -1
  8. data/lib/clacky/agent/skill_auto_creator.rb +7 -4
  9. data/lib/clacky/agent/skill_evolution.rb +23 -5
  10. data/lib/clacky/agent/skill_manager.rb +86 -1
  11. data/lib/clacky/agent/skill_reflector.rb +18 -23
  12. data/lib/clacky/agent/tool_registry.rb +10 -0
  13. data/lib/clacky/agent.rb +68 -23
  14. data/lib/clacky/agent_config.rb +59 -15
  15. data/lib/clacky/anthropic_stream_aggregator.rb +17 -1
  16. data/lib/clacky/bedrock_stream_aggregator.rb +17 -1
  17. data/lib/clacky/cli.rb +55 -0
  18. data/lib/clacky/client.rb +25 -3
  19. data/lib/clacky/default_skills/channel-manager/SKILL.md +47 -42
  20. data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +134 -0
  21. data/lib/clacky/default_skills/media-gen/SKILL.md +5 -0
  22. data/lib/clacky/default_skills/persist-memory/SKILL.md +4 -3
  23. data/lib/clacky/default_skills/search-skills/SKILL.md +61 -0
  24. data/lib/clacky/idle_compression_timer.rb +1 -1
  25. data/lib/clacky/message_format/open_ai.rb +7 -1
  26. data/lib/clacky/message_history.rb +57 -0
  27. data/lib/clacky/openai_stream_aggregator.rb +30 -3
  28. data/lib/clacky/providers.rb +40 -12
  29. data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +10 -1
  30. data/lib/clacky/server/channel/adapters/discord/adapter.rb +8 -2
  31. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -1
  32. data/lib/clacky/server/channel/adapters/feishu/bot.rb +12 -0
  33. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +23 -3
  34. data/lib/clacky/server/channel/adapters/telegram/adapter.rb +12 -2
  35. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +5 -1
  36. data/lib/clacky/server/channel/channel_manager.rb +65 -4
  37. data/lib/clacky/server/channel/group_message_buffer.rb +53 -0
  38. data/lib/clacky/server/http_server.rb +190 -10
  39. data/lib/clacky/server/session_registry.rb +34 -14
  40. data/lib/clacky/server/web_ui_controller.rb +24 -1
  41. data/lib/clacky/session_manager.rb +120 -0
  42. data/lib/clacky/tools/trash_manager.rb +1 -1
  43. data/lib/clacky/tools/web_search.rb +59 -8
  44. data/lib/clacky/ui2/layout_manager.rb +15 -5
  45. data/lib/clacky/ui2/progress_handle.rb +7 -1
  46. data/lib/clacky/ui2/ui_controller.rb +27 -0
  47. data/lib/clacky/ui_interface.rb +22 -0
  48. data/lib/clacky/utils/model_pricing.rb +96 -0
  49. data/lib/clacky/version.rb +1 -1
  50. data/lib/clacky/web/app.css +230 -7
  51. data/lib/clacky/web/app.js +6 -5
  52. data/lib/clacky/web/apple-touch-icon-180.png +0 -0
  53. data/lib/clacky/web/brand.js +22 -2
  54. data/lib/clacky/web/favicon.ico +0 -0
  55. data/lib/clacky/web/i18n.js +22 -4
  56. data/lib/clacky/web/index.html +6 -4
  57. data/lib/clacky/web/logo_nav_dark.png +0 -0
  58. data/lib/clacky/web/model-tester.js +8 -1
  59. data/lib/clacky/web/sessions.js +576 -120
  60. data/lib/clacky/web/settings.js +213 -51
  61. data/lib/clacky/web/skills.js +5 -14
  62. data/lib/clacky/web/theme.js +1 -0
  63. data/lib/clacky/web/utils.js +57 -0
  64. data/lib/clacky/web/ws-dispatcher.js +136 -0
  65. data/scripts/build/lib/gem.sh +9 -2
  66. data/scripts/build/src/install_full.sh.cc +2 -0
  67. data/scripts/build/src/uninstall.sh.cc +1 -1
  68. data/scripts/install.ps1 +19 -5
  69. data/scripts/install.sh +9 -2
  70. data/scripts/install_full.sh +11 -2
  71. data/scripts/install_rails_deps.sh +9 -2
  72. data/scripts/uninstall.sh +10 -3
  73. metadata +9 -2
@@ -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
- $("messages").innerHTML = "";
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 = $("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 = $("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 = $("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
- // Strategy: scale down to MAX_IMAGE_LONG_EDGE, then reduce JPEG quality until small enough.
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
- // Try decreasing quality until under limit
548
- let quality = 0.85;
549
- let dataUrl = canvas.toDataURL("image/jpeg", quality);
550
- while (dataUrl.length * 0.75 > MAX_IMAGE_BYTES_SEND && quality > 0.2) {
551
- quality -= 0.1;
552
- dataUrl = canvas.toDataURL("image/jpeg", quality);
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 (fires whichever input has focus)
841
- document.addEventListener("keydown", (e) => {
842
- if (e.key === "Enter" && e.target && e.target.id === "session-search-q") {
843
- e.preventDefault();
844
- Sessions.commitSearch();
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
- document.getElementById("messages").addEventListener("scroll", () => {
902
- const messages = document.getElementById("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
- // Use backend-provided summary when available, fall back to client-side summarise
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="tool-item-header">` +
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
- // Collapse any open tool group before rendering the token line
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 = $("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 = $("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 #messages (only renders if this session is currently active)
1509
+ // Insert into the outer message stream (history never lands inside an active phase card).
1355
1510
  if (id === _activeId) {
1356
- const messages = $("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 = $("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
- btn.disabled = _loadingMore;
1591
- btn.textContent = _loadingMore ? I18n.t("sessions.loadingMore") : I18n.t("sessions.loadMore");
1592
- btn.onclick = () => Sessions.loadMore();
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">${escapeHtml(displayName)}</span>${badgeHtml}${codingBadgeHtml}${pinIcon}</div>
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
- function _renderCronGroupItem(container, count) {
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
- <span class="session-dot dot-idle" style="display:inline-block;opacity:0.6"></span>
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
- // Clear list and reload from server with new filters
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 params = new URLSearchParams({ limit: "20" });
1902
- if (_filter.q) params.set("q", _filter.q);
1903
- if (_filter.date) params.set("date", _filter.date);
1904
- if (_filter.type) params.set("type", _filter.type);
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
- const res = await fetch(`/api/sessions?${params}`);
1907
- if (!res.ok) return;
1908
- const data = await res.json();
1909
- _sessions.push(...(data.sessions || []));
1910
- _hasMore = !!data.has_more;
1911
- _cronCount = data.cron_count || 0;
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
- _loadingMore = false;
1916
- Sessions.renderList();
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
- // Detach progress UI (DOM + timer) but preserve the logical state
2047
- // so it can be restored when the user switches back to this session.
2048
- if (_activeId) Sessions._detachProgressUI(_activeId);
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
- // Filter active: show all matching results flat, no group entry
2110
- visible.forEach(s => _renderSessionItem(list, s));
2111
- } else if (isCronView) {
2112
- // Cron sub-view: show only cron sessions.
2113
- // If none are loaded yet, auto-load more pages until we find them.
2114
- if (cronSessions.length === 0) {
2115
- if (_hasMore && !_loadingMore) {
2116
- list.innerHTML = `<div class="session-empty">${I18n.t("sessions.cronLoading")}</div>`;
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 (_loadingMore) {
2121
- // A loadMore() call is already in flight (its own renderList call
2122
- // reached us). Keep the loading indicator so the user never sees
2123
- // the "no sessions" empty state during the gap.
2124
- list.innerHTML = `<div class="session-empty">${I18n.t("sessions.cronLoading")}</div>`;
2125
- return;
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 (uses total count, not just loaded) + non-cron sessions
2131
- _renderCronGroupItem(list, _cronCount);
2132
- nonCronSessions.forEach(s => _renderSessionItem(list, s));
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 (_hasMore) list.appendChild(_makeLoadMoreBtn());
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 === "pin") {
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 = $("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 = $("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 directly to the message list.
2631
- // Server guarantees this event arrives AFTER assistant_message, so no buffering needed.
2632
- // Format mirrors CLI:
2633
- // [Tokens] | +409 | [*] | Input: 69,977 (cache: 69,566 read, 410 write) | Output: 101 | Total: 70,078 | Cost: $0.02392
2634
- appendTokenUsage(ev, container) {
2635
- const messages = container || $("messages");
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
- messages.appendChild(el);
2695
- if (!container) _scrollToBottomIfNeeded(messages); // only auto-scroll for live events
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 = $("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 = $("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 = $("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 = $("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($("messages"));
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
- nameLine.textContent = m.model;
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 = "Switch sub-model";
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 = "Sub-model";
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