openclacky 1.5.13 → 1.5.15

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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -0
  3. data/lib/clacky/agent/chunk_index.rb +83 -0
  4. data/lib/clacky/agent/history_navigation.rb +239 -0
  5. data/lib/clacky/agent/session_serializer.rb +53 -98
  6. data/lib/clacky/agent.rb +477 -297
  7. data/lib/clacky/agent_config.rb +5 -3
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +7 -0
  10. data/lib/clacky/brand_config.rb +27 -19
  11. data/lib/clacky/cli.rb +92 -12
  12. data/lib/clacky/cli_guidance.rb +48 -0
  13. data/lib/clacky/client.rb +2 -2
  14. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  15. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -1
  16. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
  17. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  18. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  19. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  20. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  21. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  22. data/lib/clacky/extension/api_extension.rb +18 -6
  23. data/lib/clacky/extension/verifier.rb +1 -1
  24. data/lib/clacky/json_ui_controller.rb +4 -2
  25. data/lib/clacky/media/openai_compat.rb +70 -30
  26. data/lib/clacky/message_format/bedrock.rb +6 -1
  27. data/lib/clacky/message_format/open_ai.rb +22 -5
  28. data/lib/clacky/message_format/open_ai_responses.rb +7 -3
  29. data/lib/clacky/plain_ui_controller.rb +1 -1
  30. data/lib/clacky/prompts/base.md +1 -1
  31. data/lib/clacky/providers.rb +44 -13
  32. data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
  33. data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
  34. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
  35. data/lib/clacky/search_config.rb +3 -3
  36. data/lib/clacky/server/channel/channel_manager.rb +25 -7
  37. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  38. data/lib/clacky/server/dir_picker.rb +67 -4
  39. data/lib/clacky/server/git_panel.rb +10 -2
  40. data/lib/clacky/server/http_server.rb +391 -75
  41. data/lib/clacky/server/preview.rb +351 -0
  42. data/lib/clacky/server/session_registry.rb +4 -0
  43. data/lib/clacky/server/web_ui_controller.rb +10 -5
  44. data/lib/clacky/session_manager.rb +52 -1
  45. data/lib/clacky/skill.rb +48 -0
  46. data/lib/clacky/tools/browser.rb +176 -19
  47. data/lib/clacky/tools/web_search.rb +91 -8
  48. data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
  49. data/lib/clacky/ui2/components/input_area.rb +37 -4
  50. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  51. data/lib/clacky/ui2/screen_buffer.rb +1 -0
  52. data/lib/clacky/ui2/ui_controller.rb +72 -12
  53. data/lib/clacky/ui_interface.rb +1 -1
  54. data/lib/clacky/utils/file_processor.rb +19 -7
  55. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  56. data/lib/clacky/utils/model_pricing.rb +118 -68
  57. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  58. data/lib/clacky/version.rb +1 -1
  59. data/lib/clacky/web/app.css +998 -168
  60. data/lib/clacky/web/app.js +19 -0
  61. data/lib/clacky/web/components/chat-navigator.js +489 -202
  62. data/lib/clacky/web/components/code-editor.js +191 -5
  63. data/lib/clacky/web/components/composer.js +3 -1
  64. data/lib/clacky/web/components/datepicker.js +19 -19
  65. data/lib/clacky/web/components/mentions.js +11 -14
  66. data/lib/clacky/web/components/model-picker.js +66 -23
  67. data/lib/clacky/web/components/quote-select.js +341 -0
  68. data/lib/clacky/web/core/aside.js +157 -8
  69. data/lib/clacky/web/core/ext.js +16 -0
  70. data/lib/clacky/web/features/billing/view.js +75 -21
  71. data/lib/clacky/web/features/extensions/store.js +4 -1
  72. data/lib/clacky/web/features/model-tester/store.js +37 -4
  73. data/lib/clacky/web/features/skills/store.js +18 -1
  74. data/lib/clacky/web/features/skills/view.js +128 -7
  75. data/lib/clacky/web/features/workspace/store.js +80 -7
  76. data/lib/clacky/web/features/workspace/view.js +474 -43
  77. data/lib/clacky/web/i18n.js +158 -23
  78. data/lib/clacky/web/index.html +57 -29
  79. data/lib/clacky/web/sessions.js +517 -127
  80. data/lib/clacky/web/settings.js +155 -6
  81. data/lib/clacky/web/utils.js +34 -0
  82. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  83. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  84. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  85. data/lib/clacky/web/ws-dispatcher.js +161 -4
  86. data/lib/clacky.rb +2 -0
  87. metadata +13 -1
@@ -17,10 +17,11 @@
17
17
 
18
18
  const Sessions = (() => {
19
19
  const _sessions = []; // [{ id, name, status, total_tasks, total_cost }]
20
- const _historyState = {}; // { [session_id]: { hasMore, oldestCreatedAt, loading, loaded } }
20
+ const _historyState = {}; // Per-session history window, cursors and in-flight request.
21
21
  const _renderedCreatedAt = {}; // { [session_id]: Set<number> } — dedup by created_at
22
22
  const _drafts = new Map(); // { [session_id]: composer textarea draft }
23
23
  const _attachmentDrafts = new Map(); // { [session_id]: { images: [...], files: [...] } }
24
+ const _quoteDrafts = new Map(); // { [session_id]: staged quote cards (quote-select.js) }
24
25
  let _activeId = null;
25
26
  let _hasMore = false; // unified pagination: are there older sessions to load?
26
27
  let _loadingMore = false;
@@ -37,6 +38,10 @@ const Sessions = (() => {
37
38
  // `findOrFetch`. Excluded from sidebar render and from the loadMore cursor
38
39
  // so the pagination of `_sessions` stays correct.
39
40
  const _extraSessions = [];
41
+ // Unknown session_update rows can affect folded-group counts before their
42
+ // corresponding creation update arrives. Track those adjustments separately
43
+ // so promoting a cached row into `_sessions` stays idempotent.
44
+ const _countedExtraIds = new Set();
40
45
  // Sessions that finished while the user was looking elsewhere. Holds a
41
46
  // transient "done" dot until the session is opened or DONE_DOT_TTL elapses,
42
47
  // so a completed background task is noticeable without a permanent marker.
@@ -347,11 +352,8 @@ const Sessions = (() => {
347
352
  // Clear the pane and dedup state; history will be re-fetched from API.
348
353
  RenderTarget.outer().innerHTML = "";
349
354
  delete _renderedCreatedAt[id];
350
- if (_historyState[id]) {
351
- _historyState[id].oldestCreatedAt = null;
352
- _historyState[id].hasMore = true;
353
- _historyState[id].loading = false; // reset so next fetch is not skipped
354
- }
355
+ _historyState[id]?.controller?.abort();
356
+ delete _historyState[id];
355
357
  // Reset scroll tracking when switching sessions
356
358
  _userScrolledUp = false;
357
359
  }
@@ -412,6 +414,12 @@ const Sessions = (() => {
412
414
  function _showNewMessageBanner() {
413
415
  const banner = $("new-message-banner");
414
416
  if (!banner) return;
417
+ const label = banner.querySelector("span");
418
+ if (label) {
419
+ const key = Sessions.isHistoricalWindow() ? "chat.nav.latest" : "chat.newMessageHint";
420
+ label.dataset.i18n = key;
421
+ label.textContent = I18n.t(key);
422
+ }
415
423
  banner.style.display = "block";
416
424
  }
417
425
 
@@ -548,6 +556,10 @@ const Sessions = (() => {
548
556
 
549
557
  // Click to scroll to bottom
550
558
  banner.addEventListener("click", () => {
559
+ if (Sessions.isHistoricalWindow()) {
560
+ Sessions.loadLatestHistory();
561
+ return;
562
+ }
551
563
  messages.scrollTop = messages.scrollHeight;
552
564
  _userScrolledUp = false;
553
565
  _hideNewMessageBanner();
@@ -569,7 +581,7 @@ const Sessions = (() => {
569
581
  // so a content update never moves us "away from bottom" and never trips a
570
582
  // false positive here. The 150px threshold in _isAtBottom is extra slack.
571
583
  messages.addEventListener("scroll", () => {
572
- if (_isAtBottom(messages)) {
584
+ if (_isAtBottom(messages) && !Sessions.isHistoricalWindow()) {
573
585
  _userScrolledUp = false;
574
586
  _hideNewMessageBanner();
575
587
  } else {
@@ -1112,8 +1124,18 @@ const Sessions = (() => {
1112
1124
  if (submit) submit.onclick = trySubmit;
1113
1125
  }
1114
1126
 
1115
- // Render a mention chip (file/directory/session) as a bubble badge.
1127
+ // Render a reference chip (file/directory/session/quote) as a bubble badge.
1128
+ // Quote references carry a verbatim excerpt instead of a name: the badge shows
1129
+ // it truncated to one line (.msg-quote-badge), the tooltip carries the whole
1130
+ // thing plus the provenance line.
1116
1131
  function _renderMentionBadge(chip) {
1132
+ if (chip.type === "quote") {
1133
+ const title = chip.label ? chip.label + "\n" + chip.text : chip.text;
1134
+ return `<span class="msg-mention-badge msg-quote-badge" title="${escapeHtml(title)}">` +
1135
+ `<span class="msg-mention-badge-icon">${QuoteSelect.icon(13)}</span>` +
1136
+ `<span class="msg-mention-badge-name">${escapeHtml(chip.text)}</span>` +
1137
+ `</span>`;
1138
+ }
1117
1139
  const path = chip.type === "session"
1118
1140
  ? MentionAC.icons.message
1119
1141
  : (chip.type === "directory" ? MentionAC.icons.folder : MentionAC.icons.file);
@@ -1123,12 +1145,37 @@ const Sessions = (() => {
1123
1145
  `</span>`;
1124
1146
  }
1125
1147
 
1126
- function _sendMessage() {
1148
+ function _closeInputBehaviorMenu(restoreFocus = false) {
1149
+ const menu = $("input-behavior-menu");
1150
+ const toggle = $("input-behavior-toggle");
1151
+ if (menu) menu.hidden = true;
1152
+ toggle?.setAttribute("aria-expanded", "false");
1153
+ if (restoreFocus) toggle?.focus();
1154
+ }
1155
+
1156
+ function _updateInputBehavior(status = Sessions.find(Sessions.activeId)?.status) {
1157
+ const running = status === "running";
1158
+ const control = $("input-behavior-control");
1159
+ const mode = $("input-behavior")?.value || "queue";
1160
+ if (control) control.hidden = !running;
1161
+ if (!running) _closeInputBehaviorMenu();
1162
+ document.querySelectorAll("[data-input-behavior]").forEach(button => {
1163
+ button.setAttribute("aria-checked", String(button.dataset.inputBehavior === mode));
1164
+ });
1165
+ const label = I18n.t(`chat.input.${mode}Mode`);
1166
+ $("input-behavior-toggle")?.setAttribute("aria-label", I18n.t("chat.input.behaviorTitle") + ": " + label);
1167
+ }
1168
+
1169
+ async function _sendMessage() {
1127
1170
  if (_sending) return;
1128
1171
  const input = $("user-input");
1129
- const content = Composer.text(input).trim();
1172
+ const typed = Composer.text(input).trim();
1173
+ const quotes = (typeof QuoteSelect !== "undefined") ? QuoteSelect.toReferences() : [];
1130
1174
  const mentionChips = Composer.chips(input);
1131
- if (!content && mentionChips.length === 0 && _pendingImages.length === 0 && _pendingFiles.length === 0) return;
1175
+ // Quotes travel as references, not as text in the body — the chip the user
1176
+ // staged is a UI affordance, and the sent bubble re-renders it as a badge.
1177
+ const content = typed;
1178
+ if (!content && quotes.length === 0 && mentionChips.length === 0 && _pendingImages.length === 0 && _pendingFiles.length === 0) return;
1132
1179
  if (!Sessions.activeId) return;
1133
1180
 
1134
1181
  if (!WS.ready) {
@@ -1147,10 +1194,16 @@ const Sessions = (() => {
1147
1194
  }
1148
1195
 
1149
1196
  _sending = true;
1197
+ if (Sessions.isHistoricalWindow()) {
1198
+ const id = Sessions.activeId;
1199
+ const loaded = await Sessions.loadLatestHistory();
1200
+ if (!loaded || Sessions.activeId !== id) { _sending = false; return; }
1201
+ }
1150
1202
 
1151
1203
  let bubbleHtml = content ? SkillAC.renderUserMessageHtml(content) : "";
1152
- if (mentionChips.length > 0) {
1153
- const chipBadges = mentionChips.map(_renderMentionBadge).join(" ");
1204
+ const badgeChips = quotes.concat(mentionChips);
1205
+ if (badgeChips.length > 0) {
1206
+ const chipBadges = badgeChips.map(_renderMentionBadge).join(" ");
1154
1207
  bubbleHtml = chipBadges + (bubbleHtml ? "<br>" + bubbleHtml : "");
1155
1208
  }
1156
1209
  if (_pendingImages.length > 0) {
@@ -1173,10 +1226,12 @@ const Sessions = (() => {
1173
1226
  }).join(" ");
1174
1227
  bubbleHtml = badges + (bubbleHtml ? "<br>" + bubbleHtml : "");
1175
1228
  }
1176
- if (typeof window._closeAllPhases === "function") {
1177
- window._closeAllPhases("incomplete");
1229
+ const inputBehavior = $("input-behavior")?.value || "queue";
1230
+ const queuing = inputBehavior === "steer" || inputBehavior === "queue";
1231
+ if (!queuing) {
1232
+ if (typeof window._closeAllPhases === "function") window._closeAllPhases("incomplete");
1233
+ Sessions.appendMsg("user", bubbleHtml, { time: new Date() });
1178
1234
  }
1179
- Sessions.appendMsg("user", bubbleHtml, { time: new Date() });
1180
1235
 
1181
1236
  // Merge images and files into unified files array for WS payload.
1182
1237
  _pendingImages.sort((a, b) => a.seq - b.seq);
@@ -1196,12 +1251,12 @@ const Sessions = (() => {
1196
1251
  .filter(c => c.type === "file" || c.type === "directory")
1197
1252
  .map(c => ({ name: c.name, path: c.path, reference: true }))
1198
1253
  ];
1199
- const references = mentionChips.map(c => {
1254
+ const references = quotes.concat(mentionChips.map(c => {
1200
1255
  const ref = { type: c.type, name: c.name };
1201
1256
  if (c.type === "session") ref.session_id = c.session_id;
1202
1257
  else ref.path = c.path;
1203
1258
  return ref;
1204
- });
1259
+ }));
1205
1260
  _pendingImages.length = 0;
1206
1261
  _pendingFiles.length = 0;
1207
1262
  _imageSeq = 0;
@@ -1219,8 +1274,10 @@ const Sessions = (() => {
1219
1274
  });
1220
1275
 
1221
1276
  Composer.clear(input);
1277
+ if (typeof QuoteSelect !== "undefined") QuoteSelect.clear();
1222
1278
  _drafts.delete(Sessions.activeId);
1223
1279
  _attachmentDrafts.delete(Sessions.activeId);
1280
+ _quoteDrafts.delete(Sessions.activeId);
1224
1281
  setTimeout(() => { _sending = false; }, 300);
1225
1282
  }
1226
1283
 
@@ -1245,6 +1302,40 @@ const Sessions = (() => {
1245
1302
  Composer.init($("user-input"));
1246
1303
 
1247
1304
  // Send & attach buttons
1305
+ const behaviorMenu = $("input-behavior-menu");
1306
+ const behaviorToggle = $("input-behavior-toggle");
1307
+ behaviorToggle?.addEventListener("click", () => {
1308
+ const open = behaviorMenu.hidden;
1309
+ behaviorMenu.hidden = !open;
1310
+ behaviorToggle.setAttribute("aria-expanded", String(open));
1311
+ if (open) behaviorMenu.querySelector('[aria-checked="true"]')?.focus();
1312
+ });
1313
+ behaviorMenu?.addEventListener("click", event => {
1314
+ const option = event.target.closest("[data-input-behavior]");
1315
+ if (!option || !WS.ready) return;
1316
+ $("input-behavior").value = option.dataset.inputBehavior;
1317
+ WS.send({ type: "input_behavior", value: option.dataset.inputBehavior });
1318
+ _updateInputBehavior();
1319
+ _closeInputBehaviorMenu(true);
1320
+ });
1321
+ behaviorMenu?.addEventListener("keydown", event => {
1322
+ const options = [...behaviorMenu.querySelectorAll("[data-input-behavior]")];
1323
+ const index = options.indexOf(document.activeElement);
1324
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
1325
+ event.preventDefault();
1326
+ options[(index + (event.key === "ArrowDown" ? 1 : options.length - 1)) % options.length]?.focus();
1327
+ } else if (event.key === "Escape") {
1328
+ event.preventDefault();
1329
+ event.stopPropagation();
1330
+ _closeInputBehaviorMenu(true);
1331
+ } else if (event.key === "Tab") {
1332
+ _closeInputBehaviorMenu();
1333
+ }
1334
+ });
1335
+ document.addEventListener("pointerdown", event => {
1336
+ if (!event.target.closest("#input-behavior-control")) _closeInputBehaviorMenu();
1337
+ });
1338
+ _updateInputBehavior();
1248
1339
  document.getElementById("btn-send").addEventListener("click", _sendMessage);
1249
1340
  document.getElementById("btn-attach")
1250
1341
  .addEventListener("click", () => document.getElementById("image-file-input").click());
@@ -1407,6 +1498,8 @@ const Sessions = (() => {
1407
1498
  const messages = e.currentTarget;
1408
1499
  if (messages.scrollTop < 80 && Sessions.activeId && Sessions.hasMoreHistory(Sessions.activeId)) {
1409
1500
  Sessions.loadMoreHistory(Sessions.activeId);
1501
+ } else if (_isAtBottom(messages) && Sessions.isHistoricalWindow()) {
1502
+ Sessions.loadNewerHistory();
1410
1503
  }
1411
1504
  });
1412
1505
 
@@ -1609,6 +1702,22 @@ const Sessions = (() => {
1609
1702
  return result;
1610
1703
  }
1611
1704
 
1705
+ function _renderWebSearchResults(payload) {
1706
+ const escape = (value) => String(value == null ? "" : value)
1707
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\"/g, "&quot;");
1708
+ if (payload.error) return `<div class="search-error">${escape(payload.error)}</div>`;
1709
+ const meta = [payload.count != null ? `${payload.count} results` : "", payload.provider ? `via ${payload.provider}` : ""]
1710
+ .filter(Boolean).join(" · ");
1711
+ const items = Array.isArray(payload.results) ? payload.results : [];
1712
+ return `<div class="search-results">${meta ? `<div class="search-results-meta">${escape(meta)}</div>` : ""}` +
1713
+ items.map((item, index) => {
1714
+ const title = item.title || item.url || `Result ${index + 1}`;
1715
+ const url = item.url || "";
1716
+ const snippet = item.snippet ? `<div class="search-result-snippet">${escape(item.snippet)}</div>` : "";
1717
+ return `<article class="search-result"><span class="search-result-index">${index + 1}</span><div><a href="${escape(url)}" target="_blank" rel="noopener noreferrer">${escape(title)}</a><div class="search-result-url">${escape(url)}</div>${snippet}</div></article>`;
1718
+ }).join("") + "</div>";
1719
+ }
1720
+
1612
1721
  // Produce a short one-line summary of tool arguments for the compact view.
1613
1722
  function _summariseArgs(toolName, args) {
1614
1723
  if (!args || typeof args !== "object") return String(args || "");
@@ -1671,7 +1780,7 @@ const Sessions = (() => {
1671
1780
  }
1672
1781
 
1673
1782
  // Mark a specific tool-item element as done (update status + render result).
1674
- function _completeToolItem(last, result, { collapsed = false } = {}) {
1783
+ function _completeToolItem(last, result, { collapsed = false, ui = null } = {}) {
1675
1784
  if (!last) return;
1676
1785
  const status = last.querySelector(".tool-item-status");
1677
1786
  if (status) {
@@ -1687,8 +1796,11 @@ const Sessions = (() => {
1687
1796
  const stdout = last.querySelector(".tool-item-stdout");
1688
1797
  if (stdout) {
1689
1798
  const existing = stdout.textContent.trim();
1799
+ const isWebSearch = ui && ui.type === "web_search";
1690
1800
  const resultStr = (result == null) ? "" : String(result).trim();
1691
- if (!existing && resultStr) {
1801
+ if (!existing && isWebSearch) {
1802
+ stdout.innerHTML = _renderWebSearchResults(ui);
1803
+ } else if (!existing && resultStr) {
1692
1804
  stdout.innerHTML = _ansiToHtml(resultStr);
1693
1805
  }
1694
1806
  const hasContent = !!stdout.textContent.trim();
@@ -1804,6 +1916,7 @@ const Sessions = (() => {
1804
1916
  bubbleHtml += SkillAC.renderUserMessageHtml(ev.content || "", ev.skill_command, ev.skill_command_display);
1805
1917
  el.innerHTML = bubbleHtml;
1806
1918
  if (ev.created_at) el.dataset.createdAt = ev.created_at;
1919
+ if (ev.round_id) el.dataset.roundId = ev.round_id;
1807
1920
  // Messages archived into a compressed chunk can't be edited (the backend
1808
1921
  // truncate keys off the active in-memory history). Flag them so the edit
1809
1922
  // affordance stays hidden.
@@ -1852,14 +1965,17 @@ const Sessions = (() => {
1852
1965
  }
1853
1966
  const stdout = historyCtx.lastItem.querySelector(".tool-item-stdout");
1854
1967
  if (stdout) {
1968
+ const ui = ev.ui;
1969
+ const isWebSearch = ui && ui.type === "web_search";
1855
1970
  const resultStr = (ev.result == null) ? "" : String(ev.result).trim();
1856
- if (resultStr && !stdout.textContent.trim()) {
1971
+ if (isWebSearch) {
1972
+ stdout.innerHTML = _renderWebSearchResults(ui);
1973
+ const header = historyCtx.lastItem.querySelector(".tool-item-header");
1974
+ if (header && !header.classList.contains("tool-item-expandable")) header.classList.add("tool-item-expandable");
1975
+ } else if (resultStr && !stdout.textContent.trim()) {
1857
1976
  stdout.innerHTML = _ansiToHtml(resultStr);
1858
- // Collapsed by default in history; click header to expand
1859
1977
  const header = historyCtx.lastItem.querySelector(".tool-item-header");
1860
- if (header && !header.classList.contains("tool-item-expandable")) {
1861
- header.classList.add("tool-item-expandable");
1862
- }
1978
+ if (header && !header.classList.contains("tool-item-expandable")) header.classList.add("tool-item-expandable");
1863
1979
  } else if (!resultStr && !stdout.textContent.trim()) {
1864
1980
  stdout.style.display = "none";
1865
1981
  }
@@ -1987,40 +2103,56 @@ const Sessions = (() => {
1987
2103
 
1988
2104
  // Fetch one page of history and insert into #messages or cache.
1989
2105
  // before=null means most recent page; prepend=true for scroll-up load.
1990
- async function _fetchHistory(id, before = null, prepend = false) {
1991
- const state = _historyState[id] || (_historyState[id] = { hasMore: true, oldestCreatedAt: null, loading: false });
1992
- if (state.loading) return;
2106
+ async function _fetchHistory(id, before = null, prepend = false, options = {}) {
2107
+ const state = _historyState[id] || (_historyState[id] = { hasMore: true, loading: false });
2108
+ if (state.loading && !options.replace) return false;
2109
+ state.controller?.abort();
2110
+ const controller = new AbortController();
2111
+ state.controller = controller;
2112
+ const wasHistorical = !!state.hasAfter;
2113
+ const liveRevision = state.liveRevision || 0;
2114
+ if (options.around) state.hasAfter = true;
2115
+ if (options.replace) state.replacing = true;
1993
2116
  state.loading = true;
2117
+ let succeeded = false;
1994
2118
 
1995
2119
  try {
1996
- const params = new URLSearchParams({ limit: 30 });
1997
- if (before) params.set("before", before);
2120
+ const params = new URLSearchParams({ limit: 30, window: 1 });
2121
+ if (before) params.set("before_id", before);
2122
+ if (options.around) params.set("around", options.around);
2123
+ if (options.after) params.set("after_id", options.after);
1998
2124
 
1999
- const res = await fetch(`/api/sessions/${id}/messages?${params}`);
2125
+ const res = await fetch(`/api/sessions/${id}/messages?${params}`, { signal: controller.signal });
2126
+ if (id !== _activeId || _historyState[id] !== state || state.controller !== controller) return false;
2000
2127
  if (!res.ok) {
2128
+ if (res.status === 409) window.ChatNavigator?.refresh();
2001
2129
  if (id === _activeId) {
2002
2130
  let reason = "";
2003
2131
  try { const d = await res.json(); reason = d.error || ""; } catch {}
2004
2132
  const suffix = reason ? `: ${reason}` : "";
2005
2133
  Sessions.appendMsg("info", `${I18n.t("chat.history_load_failed")} (${res.status}${suffix})`);
2006
2134
  }
2007
- return;
2135
+ return false;
2008
2136
  }
2009
2137
  const data = await res.json();
2138
+ if (id !== _activeId || _historyState[id] !== state || state.controller !== controller) return false;
2010
2139
 
2011
- state.hasMore = !!data.has_more;
2140
+ if (!options.after) {
2141
+ state.hasMore = !!data.has_more;
2142
+ state.beforeCursor = data.before_cursor;
2143
+ }
2144
+ if (!prepend) {
2145
+ state.hasAfter = !!data.has_after;
2146
+ state.afterCursor = data.after_cursor;
2147
+ }
2148
+ if (options.replace) {
2149
+ window._closeAllPhases?.("incomplete");
2150
+ Sessions.collapseToolGroup();
2151
+ RenderTarget.outer().replaceChildren();
2152
+ delete _renderedCreatedAt[id];
2153
+ }
2012
2154
 
2013
2155
  const events = data.events || [];
2014
- if (events.length === 0) return;
2015
-
2016
- // Track oldest created_at for next cursor (scroll-up pagination)
2017
- events.forEach(ev => {
2018
- if (ev.type === "history_user_message" && ev.created_at) {
2019
- if (state.oldestCreatedAt === null || ev.created_at < state.oldestCreatedAt) {
2020
- state.oldestCreatedAt = ev.created_at;
2021
- }
2022
- }
2023
- });
2024
2156
 
2025
2157
  // Pre-scan: mark each request_feedback as answered.
2026
2158
  // A feedback card is disabled as soon as any subsequent history_user_message appears
@@ -2028,7 +2160,7 @@ const Sessions = (() => {
2028
2160
  {
2029
2161
  let lastFeedbackIdx = -1;
2030
2162
  events.forEach((ev, i) => {
2031
- if (ev.type === "request_feedback") { ev._answered = false; lastFeedbackIdx = i; }
2163
+ if (ev.type === "request_feedback") { ev._answered = !!data.has_after; lastFeedbackIdx = i; }
2032
2164
  if (ev.type === "history_user_message" && lastFeedbackIdx >= 0 && i > lastFeedbackIdx) {
2033
2165
  events[lastFeedbackIdx]._answered = true;
2034
2166
  lastFeedbackIdx = -1;
@@ -2047,8 +2179,8 @@ const Sessions = (() => {
2047
2179
 
2048
2180
  events.forEach(ev => {
2049
2181
  if (ev.type === "history_user_message") {
2050
- currentCreatedAt = ev.created_at;
2051
- skipRound = currentCreatedAt && dedup.has(currentCreatedAt);
2182
+ currentCreatedAt = ev.round_id || ev.created_at;
2183
+ skipRound = currentCreatedAt && (dedup.has(currentCreatedAt) || (ev.editable !== false && dedup.has(ev.created_at)));
2052
2184
  if (!skipRound && currentCreatedAt) dedup.add(currentCreatedAt);
2053
2185
  }
2054
2186
  if (!skipRound) _renderHistoryEvent(ev, frag, historyCtx);
@@ -2075,10 +2207,16 @@ const Sessions = (() => {
2075
2207
  } else {
2076
2208
  messages.appendChild(frag);
2077
2209
  }
2078
- messages.scrollTop = messages.scrollHeight;
2210
+ if (options.around) {
2211
+ messages.scrollTop = 0;
2212
+ _userScrolledUp = true;
2213
+ } else if (!options.after) {
2214
+ messages.scrollTop = messages.scrollHeight;
2215
+ _userScrolledUp = false;
2216
+ }
2079
2217
  // Flush any tool_stdout lines that arrived via WS before this history
2080
2218
  // fetch completed (race condition on session switch).
2081
- if (!prepend) _flushPendingStdout();
2219
+ if (!prepend && !state.hasAfter) _flushPendingStdout();
2082
2220
  }
2083
2221
 
2084
2222
  // If no more history remains, insert a "beginning of conversation" marker at the top.
@@ -2094,7 +2232,7 @@ const Sessions = (() => {
2094
2232
 
2095
2233
  // Restore transient UI state based on session status after initial load
2096
2234
  // (not prepend, which is scroll-up pagination — no need to re-restore then)
2097
- if (!prepend) {
2235
+ if (!prepend && !state.hasAfter) {
2098
2236
  const session = _sessions.find(s => s.id === id);
2099
2237
  if (session) {
2100
2238
  if (session.status === "running") {
@@ -2116,13 +2254,35 @@ const Sessions = (() => {
2116
2254
  }
2117
2255
  }
2118
2256
  }
2257
+ if (state.hasAfter) _showNewMessageBanner();
2258
+ else _hideNewMessageBanner();
2259
+ window.ChatNavigator?.syncMessages();
2119
2260
  }
2261
+ succeeded = true;
2262
+ } catch (error) {
2263
+ if (error.name !== "AbortError" && id === _activeId && state.controller === controller) {
2264
+ Sessions.appendMsg("info", escapeHtml(I18n.t("chat.history_load_failed")));
2265
+ }
2266
+ return false;
2120
2267
  } finally {
2121
- state.loading = false;
2268
+ if (state.controller === controller) {
2269
+ state.loading = false;
2270
+ state.replacing = false;
2271
+ if (!succeeded) state.hasAfter = wasHistorical;
2272
+ }
2122
2273
  // After loading finishes, re-evaluate the empty-state hint in case
2123
2274
  // the session is genuinely empty (no events + no existing DOM content).
2124
- if (id === _activeId) _updateEmptyHint();
2275
+ if (id === _activeId) {
2276
+ _updateEmptyHint();
2277
+ _refreshEditButtons(RenderTarget.outer());
2278
+ }
2125
2279
  }
2280
+ // A live message may have arrived after the server took its snapshot while
2281
+ // this detached window was loading. Reconcile before resuming live output.
2282
+ if (succeeded && !state.hasAfter && (state.liveRevision || 0) !== liveRevision && id === _activeId) {
2283
+ return _fetchHistory(id, null, false, { replace: true });
2284
+ }
2285
+ return succeeded;
2126
2286
  }
2127
2287
 
2128
2288
  // ── Private helpers ───────────────────────────────────────────────────
@@ -2238,7 +2398,7 @@ const Sessions = (() => {
2238
2398
  }
2239
2399
 
2240
2400
  function _refreshEditButtons(container, status) {
2241
- const running = (status || Sessions.find(Sessions.activeId)?.status) === "running";
2401
+ const running = (status || Sessions.find(Sessions.activeId)?.status) === "running" || Sessions.isHistoricalWindow();
2242
2402
  const btns = Array.from(container.querySelectorAll(".msg-edit-btn"));
2243
2403
  btns.forEach((btn, i) => {
2244
2404
  btn.style.display = !running && i === btns.length - 1 ? "" : "none";
@@ -2256,6 +2416,7 @@ const Sessions = (() => {
2256
2416
  }
2257
2417
 
2258
2418
  function _enterEditMode(el) {
2419
+ if (Sessions.isHistoricalWindow()) return;
2259
2420
  if (_activeSessionIsRunning() || el.classList.contains("editing")) return;
2260
2421
  el.classList.add("editing");
2261
2422
 
@@ -2330,6 +2491,7 @@ const Sessions = (() => {
2330
2491
  }
2331
2492
 
2332
2493
  function _submitEdit(el, newContent) {
2494
+ if (Sessions.isHistoricalWindow()) return;
2333
2495
  if (!newContent) return;
2334
2496
  if (!Sessions.activeId) return;
2335
2497
  if (_activeSessionIsRunning()) {
@@ -2785,6 +2947,40 @@ const Sessions = (() => {
2785
2947
 
2786
2948
 
2787
2949
 
2950
+ // Compact a working directory for the status bar: keep a short leading
2951
+ // anchor (up to two segments, e.g. "/Users/leo") plus as many trailing
2952
+ // segments as fit, ellipsising the middle so the current directory name
2953
+ // stays visible. The full path lives in title/dataset, not here.
2954
+ function _compactWorkingDir(p, maxLen = 40) {
2955
+ if (!p) return p;
2956
+ if (p.length <= maxLen) return p;
2957
+
2958
+ const abs = /^[\\/]/.test(p);
2959
+ const segs = p.split(/[\\/]+/).filter((s) => s !== "");
2960
+ if (!segs.length) return p;
2961
+
2962
+ // The final segment (current directory name) must always stay intact;
2963
+ // shrink the leading anchor first when a longer tail won't fit.
2964
+ for (let headCount = Math.min(2, segs.length - 1); headCount >= 0; headCount--) {
2965
+ const headStr = headCount > 0
2966
+ ? (abs ? "/" : "") + segs.slice(0, headCount).join("/")
2967
+ : "";
2968
+ let budget = maxLen - headStr.length - 3; // reserve "/…/"
2969
+ const tail = [];
2970
+ for (let i = segs.length - 1; i >= headCount; i--) {
2971
+ const need = segs[i].length + (tail.length ? 1 : 0);
2972
+ if (need > budget) break;
2973
+ tail.unshift(segs[i]);
2974
+ budget -= need;
2975
+ }
2976
+ if (tail.length) {
2977
+ return (headStr ? headStr + "/…/" : "…/") + tail.join("/");
2978
+ }
2979
+ }
2980
+ // Final segment alone exceeds maxLen — cut it (practically never).
2981
+ return "…/" + segs[segs.length - 1].slice(-Math.max(1, maxLen - 2));
2982
+ }
2983
+
2788
2984
  // ── Public API ─────────────────────────────────────────────────────────
2789
2985
  return {
2790
2986
  get all() { return _sessions; },
@@ -2850,6 +3046,10 @@ const Sessions = (() => {
2850
3046
  _initComposer();
2851
3047
  _initSearch();
2852
3048
  _initMessageHistory();
3049
+ // Quote-on-selection: floating quote button over message selections.
3050
+ // quote-select.js is loaded after sessions.js; it's available at init
3051
+ // time because app.js (which calls Sessions.init()) loads last.
3052
+ if (typeof QuoteSelect !== "undefined") QuoteSelect.init();
2853
3053
  _ensureAgentsLoaded().then(() => Sessions.renderList());
2854
3054
  // Re-render session list (badges/labels) when the user switches language
2855
3055
  document.addEventListener("langchange", () => Sessions.renderList());
@@ -2866,6 +3066,21 @@ const Sessions = (() => {
2866
3066
  // Local deployments (localhost / 127.0.0.1 / ::1): open the file with the
2867
3067
  // OS default handler. Remote deployments: download the file.
2868
3068
  document.addEventListener("click", async (e) => {
3069
+ // Localhost dev-server URLs open in the preview panel instead of a new
3070
+ // tab (local deployments only — the proxy can't reach a remote user's
3071
+ // localhost).
3072
+ const localLink = e.target.closest("a[href]");
3073
+ if (localLink) {
3074
+ const href = localLink.getAttribute("href") || "";
3075
+ if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?([/?#]|$)/i.test(href) &&
3076
+ ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname) &&
3077
+ window.Clacky && Clacky.Preview) {
3078
+ e.preventDefault();
3079
+ Clacky.Preview.open(href);
3080
+ return;
3081
+ }
3082
+ }
3083
+
2869
3084
  const link = e.target.closest("a[href^='file://']");
2870
3085
  if (!link) return;
2871
3086
  e.preventDefault();
@@ -2878,6 +3093,12 @@ const Sessions = (() => {
2878
3093
 
2879
3094
  const hostname = window.location.hostname;
2880
3095
  const isLocal = ["localhost", "127.0.0.1", "::1"].includes(hostname);
3096
+
3097
+ // Local deployments: open the file in the Files tab of the aside
3098
+ // panel (inline preview; binary formats show an "open with app"
3099
+ // fallback page). Fall back to the OS default handler when the
3100
+ // viewer is unavailable. Remote deployments: download the file.
3101
+ if (isLocal && window.Clacky && Clacky.WorkspaceView && Clacky.WorkspaceView.openFile(filePath)) return;
2881
3102
  const action = isLocal ? "open" : "download";
2882
3103
 
2883
3104
  try {
@@ -2915,12 +3136,44 @@ const Sessions = (() => {
2915
3136
  _applyGroupStats(groups);
2916
3137
  },
2917
3138
 
2918
- /** Insert a newly created session into the local list. */
3139
+ /** Insert or refresh a newly created session in the local list. */
2919
3140
  add(session) {
2920
- if (!_sessions.find(s => s.id === session.id)) {
2921
- _sessions.push(session);
2922
- const g = _groupOf(session);
2923
- if (g) g.count++;
3141
+ if (!session || !session.id) return;
3142
+
3143
+ const existing = _sessions.find(s => s.id === session.id);
3144
+ const extraIdx = _extraSessions.findIndex(s => s.id === session.id);
3145
+ const extra = extraIdx === -1 ? null : _extraSessions[extraIdx];
3146
+ const extraWasCounted = _countedExtraIds.delete(session.id);
3147
+
3148
+ if (existing) {
3149
+ const previousGroup = _groupOf(existing);
3150
+ if (extra) {
3151
+ if (extraWasCounted) {
3152
+ const extraGroup = _groupOf(extra);
3153
+ if (extraGroup) extraGroup.count = Math.max(0, extraGroup.count - 1);
3154
+ }
3155
+ Object.assign(existing, extra);
3156
+ _extraSessions.splice(extraIdx, 1);
3157
+ }
3158
+ Object.assign(existing, session);
3159
+
3160
+ const nextGroup = _groupOf(existing);
3161
+ if (previousGroup !== nextGroup) {
3162
+ if (previousGroup) previousGroup.count = Math.max(0, previousGroup.count - 1);
3163
+ if (nextGroup) nextGroup.count++;
3164
+ }
3165
+ return;
3166
+ }
3167
+
3168
+ const inserted = extra ? { ...extra, ...session } : session;
3169
+ const previousGroup = extra && extraWasCounted ? _groupOf(extra) : null;
3170
+ if (extra) _extraSessions.splice(extraIdx, 1);
3171
+ _sessions.push(inserted);
3172
+
3173
+ const nextGroup = _groupOf(inserted);
3174
+ if (previousGroup !== nextGroup) {
3175
+ if (previousGroup) previousGroup.count = Math.max(0, previousGroup.count - 1);
3176
+ if (nextGroup) nextGroup.count++;
2924
3177
  }
2925
3178
  },
2926
3179
 
@@ -2951,8 +3204,8 @@ const Sessions = (() => {
2951
3204
  },
2952
3205
 
2953
3206
  /** Patch a single session's fields (from session_update event).
2954
- * If the session is not in the list yet (e.g. just created by another tab),
2955
- * prepend it so the sidebar shows it immediately. */
3207
+ * Unknown sessions stay in the extra cache until an explicit creation
3208
+ * update promotes them, preserving the paged sidebar boundary. */
2956
3209
  patch(id, fields) {
2957
3210
  const s = _sessions.find(s => s.id === id);
2958
3211
  const extra = _extraSessions.find(e => e.id === id);
@@ -2985,6 +3238,13 @@ const Sessions = (() => {
2985
3238
  isGroup.latestUpdatedAt = t;
2986
3239
  }
2987
3240
  }
3241
+
3242
+ if (!s) {
3243
+ if (wasGroup !== isGroup) {
3244
+ if (isGroup) _countedExtraIds.add(id);
3245
+ else _countedExtraIds.delete(id);
3246
+ }
3247
+ }
2988
3248
  },
2989
3249
 
2990
3250
  /** Flag a session as just-finished so the sidebar shows a transient dot.
@@ -3016,6 +3276,7 @@ const Sessions = (() => {
3016
3276
  // Clean up per-session progress state (timer + DOM + logical state)
3017
3277
  Sessions._deleteProgressState(id);
3018
3278
  _drafts.delete(id);
3279
+ _quoteDrafts.delete(id);
3019
3280
  },
3020
3281
 
3021
3282
  /** Clear project_id from all sessions that belong to a deleted project. */
@@ -3269,7 +3530,8 @@ const Sessions = (() => {
3269
3530
  contentRows.forEach(s => _renderSessionItem(box, s));
3270
3531
  }
3271
3532
  }
3272
- if (nameRows.length === 0 && contentRows.length === 0) {
3533
+ // Only when the content search never ran — it renders its own empty state.
3534
+ if (!contentLoaded && nameRows.length === 0 && contentRows.length === 0) {
3273
3535
  const empty = document.createElement("div");
3274
3536
  empty.className = "session-empty";
3275
3537
  empty.textContent = I18n.t("sessions.search.contentEmpty");
@@ -3437,6 +3699,7 @@ const Sessions = (() => {
3437
3699
  _pendingFiles.length = 0;
3438
3700
  }
3439
3701
  _renderAttachmentPreviews();
3702
+ if (typeof QuoteSelect !== "undefined") QuoteSelect.restore(_quoteDrafts.get(id) || []);
3440
3703
  // Reconcile todos against the authoritative server snapshot. WS
3441
3704
  // todo_update is fire-and-forget — a broadcast lost mid-complete
3442
3705
  // (e.g. a WS hiccup while completing the last task) would otherwise
@@ -3459,6 +3722,7 @@ const Sessions = (() => {
3459
3722
  const input = $("user-input");
3460
3723
  if (input) _drafts.set(_activeId, Composer.text(input));
3461
3724
  _attachmentDrafts.set(_activeId, { images: _pendingImages.splice(0), files: _pendingFiles.splice(0) });
3725
+ if (typeof QuoteSelect !== "undefined") _quoteDrafts.set(_activeId, QuoteSelect.list());
3462
3726
  _renderAttachmentPreviews();
3463
3727
  Sessions._detachProgressUI(_activeId);
3464
3728
  }
@@ -3910,7 +4174,10 @@ const Sessions = (() => {
3910
4174
  }
3911
4175
  },
3912
4176
 
4177
+ updateInputBehavior: _updateInputBehavior,
4178
+
3913
4179
  updateStatusBar(status) {
4180
+ _updateInputBehavior(status);
3914
4181
  // chat-header was removed; status text is now shown in the bottom session-info-bar (#sib-status).
3915
4182
  // Here we update controls whose availability follows the active session status.
3916
4183
  const interrupt = $("btn-interrupt");
@@ -3945,8 +4212,12 @@ const Sessions = (() => {
3945
4212
  this._lastSession = s;
3946
4213
  if (Clacky.Workspace) Clacky.Workspace.onSession(s);
3947
4214
  if (!s) {
3948
- // Hide all spans when no session
3949
- ["sib-id", "sib-status", "sib-dir", "sib-mode", "sib-model", "sib-reasoning", "sib-tasks", "sib-cost"].forEach(id => {
4215
+ // Hide all spans when no session. Fields that own a static caret (dir,
4216
+ // reasoning) keep their markup and only blank the text node.
4217
+ ["sib-id", "sib-status", "sib-mode", "sib-model", "sib-tasks", "sib-cost"].forEach(id => {
4218
+ const el = $(id); if (el) el.textContent = "";
4219
+ });
4220
+ ["sib-dir-text", "sib-reasoning-text"].forEach(id => {
3950
4221
  const el = $(id); if (el) el.textContent = "";
3951
4222
  });
3952
4223
  const sibIdEl = $("sib-id");
@@ -3987,13 +4258,20 @@ const Sessions = (() => {
3987
4258
  }
3988
4259
  }
3989
4260
 
3990
- // Working dir — show full path
4261
+ // Working dir — show a compacted path (middle-ellipsised), full path in title.
4262
+ // The caret lives in the markup, so only the inner text node is rewritten.
3991
4263
  const sibDir = $("sib-dir");
3992
- if (sibDir && s.working_dir) {
3993
- sibDir.textContent = s.working_dir;
4264
+ const sibDirText = $("sib-dir-text");
4265
+ if (sibDir && sibDirText) {
4266
+ sibDirText.textContent = s.working_dir ? _compactWorkingDir(s.working_dir) : "";
3994
4267
  sibDir.title = `${s.working_dir} (${I18n.t("sib.dir.tooltip")})`;
3995
- sibDir.dataset.workingDir = s.working_dir;
3996
- sibDir.dataset.sessionId = s.id;
4268
+ sibDir.hidden = !s.working_dir;
4269
+ if (s.working_dir) {
4270
+ sibDir.dataset.workingDir = s.working_dir;
4271
+ sibDir.dataset.sessionId = s.id;
4272
+ } else {
4273
+ delete sibDir.dataset.workingDir;
4274
+ }
3997
4275
  }
3998
4276
 
3999
4277
  // Permission mode — hide element and its separator if empty
@@ -4017,7 +4295,14 @@ const Sessions = (() => {
4017
4295
  const display = subModel
4018
4296
  ? `${subModel}`
4019
4297
  : (s.model || "");
4020
- sibModel.textContent = display;
4298
+ // Same vendor badge as the dropdown rows, so the bar reads as the
4299
+ // same object the user just picked from.
4300
+ sibModel.textContent = "";
4301
+ if (display) {
4302
+ sibModel.appendChild(ModelPicker.vendorBadge(display));
4303
+ sibModel.appendChild(document.createTextNode(display));
4304
+ sibModel.appendChild(ModelPicker.caret());
4305
+ }
4021
4306
  sibModel.dataset.sessionId = s.id;
4022
4307
  if (s.model_id) {
4023
4308
  sibModel.dataset.modelId = s.model_id;
@@ -4040,7 +4325,8 @@ const Sessions = (() => {
4040
4325
  const sibSepAfterReasoning = document.querySelector(".sib-sep-after-reasoning");
4041
4326
  if (sibReasoning) {
4042
4327
  const eff = (s.reasoning_effort || "off").toLowerCase();
4043
- sibReasoning.textContent = I18n.t(`sib.reasoning.${eff}`);
4328
+ const sibReasoningText = $("sib-reasoning-text");
4329
+ if (sibReasoningText) sibReasoningText.textContent = I18n.t(`sib.reasoning.${eff}`);
4044
4330
  sibReasoning.dataset.sessionId = s.id;
4045
4331
  sibReasoning.dataset.reasoningEffort = eff;
4046
4332
  }
@@ -4240,18 +4526,18 @@ const Sessions = (() => {
4240
4526
  // If the tool group was collapsed by an intervening info message (e.g.
4241
4527
  // "Subagent start/completed" during invoke_skill), fall back to the last
4242
4528
  // still-running .tool-item in the DOM.
4243
- appendToolResult(result) {
4529
+ appendToolResult(result, ui = null) {
4244
4530
  const messages = RenderTarget.current();
4245
4531
  const ctx = Sessions._toolGroupCtx(messages);
4246
4532
  if (ctx.group && ctx.item) {
4247
- _completeLastToolItem(ctx.group, result);
4533
+ _completeLastToolItem(ctx.group, result, { ui });
4248
4534
  return;
4249
4535
  }
4250
4536
  if (messages) {
4251
4537
  const running = messages.querySelectorAll(".tool-item-status.running");
4252
4538
  if (running.length > 0) {
4253
4539
  const item = running[running.length - 1].closest(".tool-item");
4254
- if (item) { _completeToolItem(item, result); return; }
4540
+ if (item) { _completeToolItem(item, result, { ui }); return; }
4255
4541
  }
4256
4542
  }
4257
4543
  },
@@ -4896,6 +5182,7 @@ const Sessions = (() => {
4896
5182
 
4897
5183
  /** Load the most recent page of history for a session (called on first visit). */
4898
5184
  loadHistory(id) {
5185
+ window.ChatNavigator?.setSession(id);
4899
5186
  return _fetchHistory(id, null, false);
4900
5187
  },
4901
5188
 
@@ -4903,7 +5190,41 @@ const Sessions = (() => {
4903
5190
  loadMoreHistory(id) {
4904
5191
  const state = _historyState[id];
4905
5192
  if (!state || !state.hasMore) return;
4906
- return _fetchHistory(id, state.oldestCreatedAt, true);
5193
+ return _fetchHistory(id, state.beforeCursor, true);
5194
+ },
5195
+
5196
+ isHistoricalWindow() {
5197
+ const state = _historyState[_activeId];
5198
+ return !!(state?.hasAfter || state?.replacing);
5199
+ },
5200
+
5201
+ loadNewerHistory() {
5202
+ const state = _historyState[_activeId];
5203
+ if (!state?.hasAfter || !state.afterCursor) return;
5204
+ return _fetchHistory(_activeId, null, false, { after: state.afterCursor });
5205
+ },
5206
+
5207
+ jumpToHistory(roundId) {
5208
+ return _fetchHistory(_activeId, null, false, { around: roundId, replace: true });
5209
+ },
5210
+
5211
+ async loadLatestHistory() {
5212
+ const id = _activeId;
5213
+ const loaded = await _fetchHistory(id, null, false, { replace: true });
5214
+ if (loaded && id === _activeId && Sessions.find(id)?.status === "running") {
5215
+ WS.send({ type: "subscribe", session_id: id });
5216
+ }
5217
+ return loaded;
5218
+ },
5219
+
5220
+ deferLiveHistory(event) {
5221
+ if (!Sessions.isHistoricalWindow()) return false;
5222
+ if (["history_user_message", "assistant_message", "tool_call", "tool_result"].includes(event.type)) {
5223
+ const state = _historyState[_activeId];
5224
+ state.liveRevision = (state.liveRevision || 0) + 1;
5225
+ }
5226
+ _showNewMessageBanner();
5227
+ return true;
4907
5228
  },
4908
5229
 
4909
5230
  /** Check if there is more history to load for a session. */
@@ -4960,50 +5281,74 @@ const Sessions = (() => {
4960
5281
  (function() {
4961
5282
  let _isOpen = false;
4962
5283
 
4963
- // Toggle model dropdown when clicking on model name
5284
+ function _closeModelDropdown() {
5285
+ const dropdown = $("sib-model-dropdown");
5286
+ if (dropdown) dropdown.style.display = "none";
5287
+ _isOpen = false;
5288
+ const sibModel = $("sib-model");
5289
+ if (sibModel) sibModel.classList.remove("is-open");
5290
+ ModelPicker.closeSubmodelPanel();
5291
+ }
5292
+
5293
+ // Toggle model dropdown when clicking on model name. The caret beside it is a
5294
+ // click target of its own: it opens the quick-switch panel — the same one the
5295
+ // dropdown row exposes — without going through the full catalog first.
4964
5296
  document.addEventListener("click", async (e) => {
4965
5297
  const modelEl = e.target.closest("#sib-model");
5298
+ const caretEl = e.target.closest("#sib-model .sib-caret");
4966
5299
  if (modelEl) {
4967
5300
  e.stopPropagation();
4968
5301
  if (modelEl.classList.contains("sib-model-disabled")) return;
5302
+
5303
+ if (_isOpen) {
5304
+ _closeModelDropdown();
5305
+ return;
5306
+ }
5307
+
5308
+ if (caretEl) {
5309
+ const opened = ModelPicker.toggleSubmodelPanel(modelEl, _sibSubInfo(modelEl), (name, displayName) => {
5310
+ _switchSubModel(modelEl.dataset.sessionId, name, displayName);
5311
+ });
5312
+ if (opened) {
5313
+ _isOpen = true;
5314
+ modelEl.classList.add("is-open");
5315
+ }
5316
+ return;
5317
+ }
5318
+
4969
5319
  const dropdown = $("sib-model-dropdown");
4970
5320
  if (!dropdown) return;
4971
5321
 
4972
- if (_isOpen) {
4973
- dropdown.style.display = "none";
4974
- _isOpen = false;
4975
- ModelPicker.closeSubmodelPanel();
4976
- } else {
4977
- let subOptions = [];
4978
- try { subOptions = JSON.parse(modelEl.dataset.subModelOptions || "[]"); } catch (_) {}
4979
- const subInfo = {
4980
- options: Array.isArray(subOptions) ? subOptions : [],
4981
- current: modelEl.dataset.subModel || null,
4982
- cardModel: modelEl.dataset.cardModel || null
4983
- };
4984
- await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.dataset.modelId || null, subInfo);
5322
+ await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.dataset.modelId || null, _sibSubInfo(modelEl));
4985
5323
 
4986
- // Calculate position relative to the model element (fixed positioning)
4987
- const rect = modelEl.getBoundingClientRect();
4988
- dropdown.style.left = `${rect.left + rect.width / 2}px`;
4989
- dropdown.style.top = `${rect.top - 6}px`; // 6px above the element
4990
- dropdown.style.transform = "translate(-50%, -100%)"; // Center horizontally, move up by its own height
5324
+ // Calculate position relative to the model element (fixed positioning)
5325
+ const rect = modelEl.getBoundingClientRect();
5326
+ dropdown.style.left = `${rect.left + rect.width / 2}px`;
5327
+ dropdown.style.top = `${rect.top - 6}px`; // 6px above the element
5328
+ dropdown.style.transform = "translate(-50%, -100%)"; // Center horizontally, move up by its own height
4991
5329
 
4992
- dropdown.style.display = "block";
4993
- _isOpen = true;
4994
- }
5330
+ dropdown.style.display = "block";
5331
+ _isOpen = true;
5332
+ modelEl.classList.add("is-open");
4995
5333
  return;
4996
5334
  }
4997
5335
 
4998
5336
  // Close dropdown when clicking outside
4999
5337
  if (_isOpen && !e.target.closest(".sib-model-dropdown") && !e.target.closest(".sib-submodel-panel")) {
5000
- const dropdown = $("sib-model-dropdown");
5001
- if (dropdown) dropdown.style.display = "none";
5002
- _isOpen = false;
5003
- ModelPicker.closeSubmodelPanel();
5338
+ _closeModelDropdown();
5004
5339
  }
5005
5340
  });
5006
5341
 
5342
+ function _sibSubInfo(modelEl) {
5343
+ let options = [];
5344
+ try { options = JSON.parse(modelEl.dataset.subModelOptions || "[]"); } catch (_) {}
5345
+ return {
5346
+ options: Array.isArray(options) ? options : [],
5347
+ current: modelEl.dataset.subModel || null,
5348
+ cardModel: modelEl.dataset.cardModel || null
5349
+ };
5350
+ }
5351
+
5007
5352
  // Populate dropdown with available models
5008
5353
  async function _populateModelDropdown(sessionId, currentModelId, subInfo) {
5009
5354
  subInfo = subInfo || { options: [], current: null, cardModel: null };
@@ -5049,12 +5394,7 @@ const Sessions = (() => {
5049
5394
  // Switch the session's current card. modelId is the stable runtime id,
5050
5395
  // modelName is for optimistic display.
5051
5396
  async function _switchModel(sessionId, modelId, modelName) {
5052
- const dropdown = $("sib-model-dropdown");
5053
- if (dropdown) {
5054
- dropdown.style.display = "none";
5055
- _isOpen = false;
5056
- }
5057
- ModelPicker.closeSubmodelPanel();
5397
+ _closeModelDropdown();
5058
5398
 
5059
5399
  try {
5060
5400
  const res = await fetch(`/api/sessions/${sessionId}/model`, {
@@ -5085,12 +5425,7 @@ const Sessions = (() => {
5085
5425
  // Pin (or clear) the session's sub-model. Pass modelName=null to clear.
5086
5426
  // displayName is what we optimistically show in the status bar.
5087
5427
  async function _switchSubModel(sessionId, modelName, displayName) {
5088
- const dropdown = $("sib-model-dropdown");
5089
- if (dropdown) {
5090
- dropdown.style.display = "none";
5091
- _isOpen = false;
5092
- }
5093
- ModelPicker.closeSubmodelPanel();
5428
+ _closeModelDropdown();
5094
5429
 
5095
5430
  try {
5096
5431
  const res = await fetch(`/api/sessions/${sessionId}/submodel`, {
@@ -5111,10 +5446,7 @@ const Sessions = (() => {
5111
5446
 
5112
5447
  // Navigate to media config.
5113
5448
  function _goConfigureMedia() {
5114
- const dropdown = $("sib-model-dropdown");
5115
- if (dropdown) dropdown.style.display = "none";
5116
- _isOpen = false;
5117
- ModelPicker.closeSubmodelPanel();
5449
+ _closeModelDropdown();
5118
5450
  if (typeof Router !== "undefined") Router.navigate("settings");
5119
5451
  setTimeout(() => {
5120
5452
  const sec = document.getElementById("media-section");
@@ -5163,6 +5495,7 @@ const Sessions = (() => {
5163
5495
  // When no session exists yet (e.g. the New Session modal), browse the
5164
5496
  // real filesystem via /api/dirs instead of the session-scoped files API.
5165
5497
  const sessionLess = !sessionId;
5498
+ const mobileLayout = window.matchMedia("(max-width: 768px)");
5166
5499
 
5167
5500
  let currentPath = currentDir;
5168
5501
  let selectedPath = currentDir;
@@ -5243,11 +5576,16 @@ const Sessions = (() => {
5243
5576
  row.appendChild(name);
5244
5577
  node.appendChild(row);
5245
5578
 
5246
- // Single-click: select this directory
5579
+ // Mobile uses the conventional file-picker behavior: tap to enter.
5580
+ // Desktop keeps single-click selection and double-click navigation.
5247
5581
  let clickTimer = null;
5248
5582
  row.addEventListener("click", (e) => {
5249
5583
  e.stopPropagation();
5250
5584
  if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
5585
+ if (mobileLayout.matches) {
5586
+ navigateTo(entry.absPath, true);
5587
+ return;
5588
+ }
5251
5589
  clickTimer = setTimeout(() => {
5252
5590
  clickTimer = null;
5253
5591
  listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
@@ -5259,6 +5597,7 @@ const Sessions = (() => {
5259
5597
  // Double-click: enter directory (navigate into it)
5260
5598
  row.addEventListener("dblclick", (e) => {
5261
5599
  e.stopPropagation();
5600
+ if (mobileLayout.matches) return;
5262
5601
  if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
5263
5602
  navigateTo(entry.absPath, true);
5264
5603
  });
@@ -5271,12 +5610,36 @@ const Sessions = (() => {
5271
5610
 
5272
5611
  // Create modal overlay
5273
5612
  const overlay = document.createElement("div");
5274
- overlay.className = "modal-overlay";
5613
+ overlay.className = "modal-overlay dp-overlay";
5614
+
5615
+ const cancelPicker = () => {
5616
+ overlay.remove();
5617
+ resolve(null);
5618
+ };
5275
5619
 
5276
5620
  // Create modal content — Finder-style layout
5277
5621
  const modal = document.createElement("div");
5278
5622
  modal.className = "modal-content dp-modal";
5279
5623
 
5624
+ // Mobile-only header — the desktop picker keeps its Finder-style layout.
5625
+ const mobileHeader = document.createElement("div");
5626
+ mobileHeader.className = "dp-mobile-header";
5627
+
5628
+ const mobileTitle = document.createElement("span");
5629
+ mobileTitle.className = "dp-mobile-title";
5630
+ mobileTitle.textContent = t("sessions.modal.dirpicker.title", "Select Working Directory");
5631
+
5632
+ const mobileCloseButton = document.createElement("button");
5633
+ mobileCloseButton.type = "button";
5634
+ mobileCloseButton.className = "dp-nav-btn dp-mobile-close";
5635
+ mobileCloseButton.title = t("sib.dir.cancel", "Cancel");
5636
+ mobileCloseButton.setAttribute("aria-label", mobileCloseButton.title);
5637
+ mobileCloseButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>';
5638
+ mobileCloseButton.addEventListener("click", cancelPicker);
5639
+
5640
+ mobileHeader.appendChild(mobileTitle);
5641
+ mobileHeader.appendChild(mobileCloseButton);
5642
+
5280
5643
  // Left sidebar — quick-access places (favorites + locations)
5281
5644
  const sidebar = document.createElement("div");
5282
5645
  sidebar.className = "dp-sidebar";
@@ -5368,7 +5731,7 @@ const Sessions = (() => {
5368
5731
  const cancelButton = document.createElement("button");
5369
5732
  cancelButton.className = "btn btn-secondary";
5370
5733
  cancelButton.textContent = t("sib.dir.cancel", "取消");
5371
- cancelButton.onclick = () => { overlay.remove(); resolve(null); };
5734
+ cancelButton.onclick = cancelPicker;
5372
5735
 
5373
5736
  const confirmButton = document.createElement("button");
5374
5737
  confirmButton.className = "btn btn-primary";
@@ -5381,6 +5744,7 @@ const Sessions = (() => {
5381
5744
  footer.appendChild(footerActions);
5382
5745
  main.appendChild(footer);
5383
5746
 
5747
+ modal.appendChild(mobileHeader);
5384
5748
  modal.appendChild(sidebar);
5385
5749
  modal.appendChild(main);
5386
5750
 
@@ -5416,14 +5780,29 @@ const Sessions = (() => {
5416
5780
  };
5417
5781
 
5418
5782
  addSection("sib.dir.favorites", "Quick access", quickAccess);
5419
- if (directoryFavorites.length) {
5783
+ // Favorites pointing at a quick-access place would render the same folder twice
5784
+ const placePaths = new Set(sidebarPlaces.map(place => comparablePath(place.path)));
5785
+ const userFavorites = directoryFavorites.filter(path => !placePaths.has(comparablePath(path)));
5786
+ if (userFavorites.length) {
5420
5787
  const label = document.createElement("div");
5421
5788
  label.className = "dp-sidebar-label";
5422
5789
  label.textContent = t("sib.dir.userFavorites", "My favorites");
5423
5790
  sidebar.appendChild(label);
5424
- directoryFavorites.forEach(path => sidebar.appendChild(buildFavoriteSidebarItem(path)));
5791
+ userFavorites.forEach(path => sidebar.appendChild(buildFavoriteSidebarItem(path)));
5425
5792
  }
5426
5793
  addSection("sib.dir.drives", "Drives", drives);
5794
+ refreshSidebarActive();
5795
+ }
5796
+
5797
+ // Highlight the sidebar entry that matches the directory being browsed
5798
+ function refreshSidebarActive() {
5799
+ const active = currentPathLoaded ? comparablePath(currentPath) : "";
5800
+ sidebar.querySelectorAll("[data-path]").forEach(el => {
5801
+ const isActive = el.dataset.path === active;
5802
+ el.classList.toggle("active", isActive);
5803
+ if (isActive) el.setAttribute("aria-current", "true");
5804
+ else el.removeAttribute("aria-current");
5805
+ });
5427
5806
  }
5428
5807
 
5429
5808
  function buildSidebarItem(place) {
@@ -5431,6 +5810,7 @@ const Sessions = (() => {
5431
5810
  item.type = "button";
5432
5811
  item.className = "dp-sidebar-item";
5433
5812
  item.title = place.path;
5813
+ item.dataset.path = comparablePath(place.path);
5434
5814
 
5435
5815
  const icon = document.createElement("span");
5436
5816
  icon.className = "dp-sidebar-icon";
@@ -5460,6 +5840,7 @@ const Sessions = (() => {
5460
5840
  const openButton = document.createElement("button");
5461
5841
  openButton.type = "button";
5462
5842
  openButton.className = "dp-sidebar-item dp-sidebar-favorite-open";
5843
+ openButton.dataset.path = comparablePath(path);
5463
5844
 
5464
5845
  const icon = document.createElement("span");
5465
5846
  icon.className = "dp-sidebar-icon";
@@ -5743,6 +6124,7 @@ const Sessions = (() => {
5743
6124
  currentPathLoaded = true;
5744
6125
  refreshBreadcrumb(effectivePath);
5745
6126
  refreshFavoriteButton();
6127
+ refreshSidebarActive();
5746
6128
  listContainer.innerHTML = "";
5747
6129
  if (dirs.length === 0) {
5748
6130
  listContainer.innerHTML = `<div class="dp-empty">${t("sib.dir.empty", "空目录")}</div>`;
@@ -5948,7 +6330,9 @@ const Sessions = (() => {
5948
6330
  const sessionId = dirEl.dataset.sessionId;
5949
6331
  const currentDir = dirEl.dataset.workingDir || dirEl.textContent;
5950
6332
 
6333
+ dirEl.classList.add("is-open");
5951
6334
  const newDir = await showDirectoryPicker(currentDir, sessionId);
6335
+ dirEl.classList.remove("is-open");
5952
6336
  if (newDir && newDir !== currentDir) {
5953
6337
  _changeWorkingDirectory(sessionId, newDir);
5954
6338
  }
@@ -6108,8 +6492,9 @@ const Sessions = (() => {
6108
6492
 
6109
6493
  // Update UI optimistically (will be confirmed by session_update broadcast)
6110
6494
  const sibDir = $("sib-dir");
6111
- if (sibDir) {
6112
- sibDir.textContent = newDir;
6495
+ const sibDirText = $("sib-dir-text");
6496
+ if (sibDir && sibDirText) {
6497
+ sibDirText.textContent = newDir;
6113
6498
  sibDir.title = `${newDir} (${I18n.t("sib.dir.tooltip")})`;
6114
6499
  sibDir.dataset.workingDir = newDir;
6115
6500
  }
@@ -6143,6 +6528,7 @@ const Sessions = (() => {
6143
6528
  if (_isOpen) {
6144
6529
  dropdown.style.display = "none";
6145
6530
  _isOpen = false;
6531
+ el.classList.remove("is-open");
6146
6532
  return;
6147
6533
  }
6148
6534
 
@@ -6154,12 +6540,15 @@ const Sessions = (() => {
6154
6540
  dropdown.style.transform = "translate(-50%, -100%)";
6155
6541
  dropdown.style.display = "block";
6156
6542
  _isOpen = true;
6543
+ el.classList.add("is-open");
6157
6544
  return;
6158
6545
  }
6159
6546
 
6160
6547
  if (_isOpen && !e.target.closest("#sib-reasoning-dropdown")) {
6161
6548
  const dropdown = $("sib-reasoning-dropdown");
6162
6549
  if (dropdown) dropdown.style.display = "none";
6550
+ const el = $("sib-reasoning");
6551
+ if (el) el.classList.remove("is-open");
6163
6552
  _isOpen = false;
6164
6553
  }
6165
6554
  });
@@ -6200,6 +6589,8 @@ const Sessions = (() => {
6200
6589
  dropdown.style.display = "none";
6201
6590
  _isOpen = false;
6202
6591
  }
6592
+ const sibReasoningEl = $("sib-reasoning");
6593
+ if (sibReasoningEl) sibReasoningEl.classList.remove("is-open");
6203
6594
 
6204
6595
  try {
6205
6596
  const res = await fetch(`/api/sessions/${sessionId}/reasoning_effort`, {
@@ -6210,11 +6601,10 @@ const Sessions = (() => {
6210
6601
  const data = await res.json();
6211
6602
  if (!res.ok) throw new Error(data.error || "Unknown error");
6212
6603
 
6213
- const el = $("sib-reasoning");
6214
- if (el) {
6215
- el.textContent = I18n.t(`sib.reasoning.${level}`);
6216
- el.dataset.reasoningEffort = level;
6217
- }
6604
+ const textEl = $("sib-reasoning-text");
6605
+ if (textEl) textEl.textContent = I18n.t(`sib.reasoning.${level}`);
6606
+ const sibReasoningEl = $("sib-reasoning");
6607
+ if (sibReasoningEl) sibReasoningEl.dataset.reasoningEffort = level;
6218
6608
  } catch (e) {
6219
6609
  console.error("Failed to switch reasoning effort:", e);
6220
6610
  alert("Failed to switch reasoning effort: " + e.message);