openclacky 1.5.11 → 1.5.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/cost_tracker.rb +2 -0
  5. data/lib/clacky/agent/message_compressor_helper.rb +53 -2
  6. data/lib/clacky/agent/session_serializer.rb +72 -19
  7. data/lib/clacky/agent/time_machine.rb +10 -1
  8. data/lib/clacky/agent.rb +107 -26
  9. data/lib/clacky/agent_config.rb +40 -0
  10. data/lib/clacky/agent_profile.rb +5 -5
  11. data/lib/clacky/billing/billing_record.rb +3 -0
  12. data/lib/clacky/billing/billing_store.rb +31 -5
  13. data/lib/clacky/billing/platform_billing.rb +231 -0
  14. data/lib/clacky/brand_config.rb +4 -3
  15. data/lib/clacky/cancel_flag.rb +23 -0
  16. data/lib/clacky/cli.rb +4 -2
  17. data/lib/clacky/client.rb +107 -0
  18. data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
  19. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
  20. data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
  21. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
  22. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
  23. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
  24. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  25. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
  26. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  27. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  28. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  29. data/lib/clacky/extension/packager.rb +4 -2
  30. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
  31. data/lib/clacky/extension/verifier.rb +14 -1
  32. data/lib/clacky/fanout.rb +49 -2
  33. data/lib/clacky/json_ui_controller.rb +3 -2
  34. data/lib/clacky/message_format/open_ai.rb +5 -1
  35. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  36. data/lib/clacky/message_history.rb +43 -1
  37. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  38. data/lib/clacky/plain_ui_controller.rb +15 -5
  39. data/lib/clacky/providers.rb +24 -7
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
  41. data/lib/clacky/server/channel/channel_manager.rb +93 -18
  42. data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
  43. data/lib/clacky/server/dir_picker.rb +154 -0
  44. data/lib/clacky/server/git_panel.rb +61 -9
  45. data/lib/clacky/server/http_server.rb +366 -42
  46. data/lib/clacky/server/web_ui_controller.rb +7 -5
  47. data/lib/clacky/session_manager.rb +74 -17
  48. data/lib/clacky/tools/ask_user.rb +3 -2
  49. data/lib/clacky/tools/terminal.rb +30 -3
  50. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  51. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  52. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  53. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  54. data/lib/clacky/ui2/ui_controller.rb +2 -2
  55. data/lib/clacky/ui_interface.rb +2 -2
  56. data/lib/clacky/utils/model_pricing.rb +92 -4
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +915 -142
  59. data/lib/clacky/web/components/code-editor.js +33 -1
  60. data/lib/clacky/web/components/composer.js +315 -0
  61. data/lib/clacky/web/components/custom-select.js +136 -0
  62. data/lib/clacky/web/components/mentions.js +655 -0
  63. data/lib/clacky/web/components/model-picker.js +527 -0
  64. data/lib/clacky/web/components/onboard.js +29 -55
  65. data/lib/clacky/web/core/aside.js +3 -3
  66. data/lib/clacky/web/core/ext.js +38 -0
  67. data/lib/clacky/web/features/billing/store.js +2 -6
  68. data/lib/clacky/web/features/billing/view.js +32 -16
  69. data/lib/clacky/web/features/extensions/view.js +22 -10
  70. data/lib/clacky/web/features/new-session/store.js +15 -1
  71. data/lib/clacky/web/features/new-session/view.js +80 -34
  72. data/lib/clacky/web/features/trash/view.js +31 -19
  73. data/lib/clacky/web/features/workspace/store.js +1 -0
  74. data/lib/clacky/web/features/workspace/view.js +766 -80
  75. data/lib/clacky/web/i18n.js +107 -17
  76. data/lib/clacky/web/index.html +125 -45
  77. data/lib/clacky/web/sessions.js +704 -635
  78. data/lib/clacky/web/settings.js +164 -190
  79. data/lib/clacky/web/skills.js +11 -17
  80. data/lib/clacky/web/ws-dispatcher.js +11 -33
  81. data/lib/clacky.rb +4 -0
  82. metadata +12 -1
@@ -26,6 +26,7 @@ const Sessions = (() => {
26
26
  let _loadingMore = false;
27
27
  // Search state
28
28
  const _filter = { q: "", date: "", type: "" }; // committed filter (applied to the search overlay)
29
+ let _searchTypeSelect = null;
29
30
  let _searchOpen = false; // is the command-palette search overlay visible?
30
31
  // Search results live in their own list, rendered into the overlay's
31
32
  // #session-search-results — they NEVER replace the sidebar session list.
@@ -428,12 +429,41 @@ const Sessions = (() => {
428
429
 
429
430
  const _EMPTY_HINT_ID = "chat-empty-hint";
430
431
 
432
+ let _agentName = "";
433
+ let _agentNamePromise = null;
434
+
435
+ function _loadAgentName() {
436
+ if (_agentName) return Promise.resolve(_agentName);
437
+ if (_agentNamePromise) return _agentNamePromise;
438
+ _agentNamePromise = fetch("/api/profile")
439
+ .then(r => (r.ok ? r.json() : {}))
440
+ .then(d => {
441
+ _agentName = (d.soul && d.soul.name) || "";
442
+ return _agentName;
443
+ })
444
+ .catch(() => {
445
+ _agentName = "";
446
+ return "";
447
+ })
448
+ .finally(() => { _agentNamePromise = null; });
449
+ return _agentNamePromise;
450
+ }
451
+
452
+ function _applyAgentName(name) {
453
+ if (!name) return;
454
+ const el = document.querySelector("#" + _EMPTY_HINT_ID + " .chat-empty-title");
455
+ if (el) el.textContent = I18n.t("chat.empty.title.named", { name });
456
+ }
457
+
431
458
  function _buildEmptyHintHtml() {
432
- const title = I18n.t("chat.empty.title");
459
+ const title = _agentName
460
+ ? I18n.t("chat.empty.title.named", { name: _agentName })
461
+ : I18n.t("chat.empty.title");
433
462
  const subtitle = I18n.t("chat.empty.subtitle");
434
463
  const tip1 = I18n.t("chat.empty.tip1");
435
464
  const tip2 = I18n.t("chat.empty.tip2");
436
465
  const tip3 = I18n.t("chat.empty.tip3");
466
+ _loadAgentName().then(_applyAgentName);
437
467
  return `
438
468
  <div class="chat-empty-icon" aria-hidden="true">
439
469
  <svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
@@ -931,7 +961,7 @@ const Sessions = (() => {
931
961
  `${answered ? " disabled" : ""}>${escapeHtml(opt)}${rec}</button>`;
932
962
  });
933
963
  html += `</div>`;
934
- if (q.allow_free_text && q.options.length > 0) {
964
+ if (q.allow_free_text) {
935
965
  html += `<input type="text" class="feedback-free-text" data-question-index="${qi}"` +
936
966
  ` placeholder="${escapeHtml(I18n.t("chat.feedback_other"))}"${answered ? " disabled" : ""}>`;
937
967
  }
@@ -984,7 +1014,7 @@ const Sessions = (() => {
984
1014
  card.classList.add("feedback-card--submitted");
985
1015
 
986
1016
  const input = $("user-input");
987
- if (input) input.value = lines.join("\n");
1017
+ if (input) Composer.setText(input, lines.join("\n"));
988
1018
  _sendMessage();
989
1019
  return true;
990
1020
  }
@@ -1016,6 +1046,16 @@ const Sessions = (() => {
1016
1046
  if (free) free.focus();
1017
1047
  };
1018
1048
 
1049
+ // Enter and the submit button must agree: a stepped card never sends a
1050
+ // partial answer, it jumps back to the first unanswered question instead.
1051
+ const trySubmit = () => {
1052
+ if (stepped) {
1053
+ const blank = list.findIndex((_, i) => _feedbackAnswerFor(card, list, i).length === 0);
1054
+ if (blank !== -1) { showStep(blank); return; }
1055
+ }
1056
+ _submitFeedbackCard(card, list);
1057
+ };
1058
+
1019
1059
  card.querySelectorAll(".feedback-option-btn").forEach(btn => {
1020
1060
  btn.onclick = () => {
1021
1061
  const qi = Number(btn.dataset.questionIndex);
@@ -1024,7 +1064,7 @@ const Sessions = (() => {
1024
1064
  card.querySelectorAll(".feedback-option-btn").forEach(b => { b.disabled = true; });
1025
1065
  card.classList.add("feedback-card--submitted");
1026
1066
  const input = $("user-input");
1027
- if (input) input.value = list[qi].options[Number(btn.dataset.optionIndex)];
1067
+ if (input) Composer.setText(input, list[qi].options[Number(btn.dataset.optionIndex)]);
1028
1068
  _sendMessage();
1029
1069
  return;
1030
1070
  }
@@ -1054,11 +1094,13 @@ const Sessions = (() => {
1054
1094
  dot.onclick = () => showStep(Number(dot.dataset.step));
1055
1095
  });
1056
1096
 
1097
+ // IME.track so confirming a composition candidate is not read as Enter.
1057
1098
  card.querySelectorAll(".feedback-free-text").forEach(input => {
1099
+ const ime = IME.track(input);
1058
1100
  input.onkeydown = (e) => {
1059
- if (e.key !== "Enter") return;
1101
+ if (e.key !== "Enter" || ime.isComposing(e)) return;
1060
1102
  e.preventDefault();
1061
- if (step === list.length - 1) _submitFeedbackCard(card, list);
1103
+ if (step === list.length - 1) trySubmit();
1062
1104
  else showStep(step + 1);
1063
1105
  };
1064
1106
  });
@@ -1067,23 +1109,26 @@ const Sessions = (() => {
1067
1109
  }
1068
1110
 
1069
1111
  const submit = card.querySelector(".feedback-submit-btn");
1070
- if (submit) {
1071
- submit.onclick = () => {
1072
- if (stepped) {
1073
- // Don't silently drop a question the user skipped past — take them back to it.
1074
- const blank = list.findIndex((_, i) => _feedbackAnswerFor(card, list, i).length === 0);
1075
- if (blank !== -1) { showStep(blank); return; }
1076
- }
1077
- _submitFeedbackCard(card, list);
1078
- };
1079
- }
1112
+ if (submit) submit.onclick = trySubmit;
1113
+ }
1114
+
1115
+ // Render a mention chip (file/directory/session) as a bubble badge.
1116
+ function _renderMentionBadge(chip) {
1117
+ const path = chip.type === "session"
1118
+ ? MentionAC.icons.message
1119
+ : (chip.type === "directory" ? MentionAC.icons.folder : MentionAC.icons.file);
1120
+ return `<span class="msg-mention-badge">` +
1121
+ `<span class="msg-mention-badge-icon">${MentionAC.svgHtml(path, 13)}</span>` +
1122
+ `<span class="msg-mention-badge-name">${escapeHtml(chip.name)}</span>` +
1123
+ `</span>`;
1080
1124
  }
1081
1125
 
1082
1126
  function _sendMessage() {
1083
1127
  if (_sending) return;
1084
1128
  const input = $("user-input");
1085
- const content = input.value.trim();
1086
- if (!content && _pendingImages.length === 0 && _pendingFiles.length === 0) return;
1129
+ const content = Composer.text(input).trim();
1130
+ const mentionChips = Composer.chips(input);
1131
+ if (!content && mentionChips.length === 0 && _pendingImages.length === 0 && _pendingFiles.length === 0) return;
1087
1132
  if (!Sessions.activeId) return;
1088
1133
 
1089
1134
  if (!WS.ready) {
@@ -1104,6 +1149,10 @@ const Sessions = (() => {
1104
1149
  _sending = true;
1105
1150
 
1106
1151
  let bubbleHtml = content ? SkillAC.renderUserMessageHtml(content) : "";
1152
+ if (mentionChips.length > 0) {
1153
+ const chipBadges = mentionChips.map(_renderMentionBadge).join(" ");
1154
+ bubbleHtml = chipBadges + (bubbleHtml ? "<br>" + bubbleHtml : "");
1155
+ }
1107
1156
  if (_pendingImages.length > 0) {
1108
1157
  const thumbs = _pendingImages
1109
1158
  .map(img => `<img src="${img.dataUrl}" alt="${escapeHtml(img.name)}" class="msg-image-thumb">`)
@@ -1142,14 +1191,23 @@ const Sessions = (() => {
1142
1191
  name: f.name,
1143
1192
  path: f.path,
1144
1193
  mime_type: f.mime_type
1145
- }))
1194
+ })),
1195
+ ...mentionChips
1196
+ .filter(c => c.type === "file" || c.type === "directory")
1197
+ .map(c => ({ name: c.name, path: c.path, reference: true }))
1146
1198
  ];
1199
+ const references = mentionChips.map(c => {
1200
+ const ref = { type: c.type, name: c.name };
1201
+ if (c.type === "session") ref.session_id = c.session_id;
1202
+ else ref.path = c.path;
1203
+ return ref;
1204
+ });
1147
1205
  _pendingImages.length = 0;
1148
1206
  _pendingFiles.length = 0;
1149
1207
  _imageSeq = 0;
1150
1208
  _renderAttachmentPreviews();
1151
1209
 
1152
- WS.send({ type: "message", session_id: Sessions.activeId, content, files, lang: I18n.lang() });
1210
+ WS.send({ type: "message", session_id: Sessions.activeId, content, files, references, lang: I18n.lang() });
1153
1211
 
1154
1212
  // Disable any pending feedback cards — user has replied (either by clicking
1155
1213
  // an option button or by typing directly). The backend has already consumed
@@ -1160,8 +1218,7 @@ const Sessions = (() => {
1160
1218
  card.classList.add("feedback-card--submitted");
1161
1219
  });
1162
1220
 
1163
- input.value = "";
1164
- input.style.height = "auto";
1221
+ Composer.clear(input);
1165
1222
  _drafts.delete(Sessions.activeId);
1166
1223
  _attachmentDrafts.delete(Sessions.activeId);
1167
1224
  setTimeout(() => { _sending = false; }, 300);
@@ -1171,6 +1228,22 @@ const Sessions = (() => {
1171
1228
  // Wires up the send button, attach button, file picker, drag-drop, paste,
1172
1229
  // and IME composition tracking. All targets are static in index.html.
1173
1230
  function _initComposer() {
1231
+ // @ mention autocomplete — registered before Composer/SkillAC so its
1232
+ // keydown stopImmediatePropagation wins over chip deletion / Enter-send.
1233
+ if (typeof MentionAC !== "undefined") {
1234
+ MentionAC.attach({
1235
+ input: "user-input",
1236
+ menu: "mention-autocomplete",
1237
+ getSessionId: () => Sessions.activeId,
1238
+ getWorkingDir: () => {
1239
+ const s = Sessions.find(Sessions.activeId);
1240
+ return s ? (s.working_dir || "") : "";
1241
+ },
1242
+ });
1243
+ }
1244
+ // contenteditable composer: chip deletion, plain-text paste, Shift+Enter.
1245
+ Composer.init($("user-input"));
1246
+
1174
1247
  // Send & attach buttons
1175
1248
  document.getElementById("btn-send").addEventListener("click", _sendMessage);
1176
1249
  document.getElementById("btn-attach")
@@ -1215,7 +1288,7 @@ const Sessions = (() => {
1215
1288
  //
1216
1289
  // All search-related interactions. The search UI lives in the sessions
1217
1290
  // sidebar: a magnifier toggle button, the search panel (q input, type
1218
- // <select>, date <input>), inline ✕ clear, and "clear all filters" button.
1291
+ // dropdown, date <input>), inline ✕ clear, and "clear all filters" button.
1219
1292
  //
1220
1293
  // Everything uses event delegation because some elements (e.g. the clear
1221
1294
  // buttons) are re-rendered as filter state changes.
@@ -1229,6 +1302,17 @@ const Sessions = (() => {
1229
1302
  const cmdbarKbd = document.querySelector("#header-cmdbar .cmdbar-kbd");
1230
1303
  if (cmdbarKbd) cmdbarKbd.textContent = _sessionSearchShortcutLabel();
1231
1304
 
1305
+ const typeWrap = document.getElementById("session-search-type");
1306
+ if (typeWrap && window.CustomSelect) {
1307
+ _searchTypeSelect = window.CustomSelect.init({
1308
+ trigger: typeWrap.querySelector(".custom-select-trigger"),
1309
+ dropdown: typeWrap.querySelector(".custom-select-dropdown"),
1310
+ anchor: typeWrap,
1311
+ portal: true,
1312
+ onSelect: () => Sessions.commitSearch()
1313
+ });
1314
+ }
1315
+
1232
1316
  // Open the palette: top cmdbar button (or the keyboard shortcut, bound below).
1233
1317
  document.addEventListener("click", (e) => {
1234
1318
  if (e.target && e.target.closest("#header-cmdbar")) {
@@ -1281,9 +1365,8 @@ const Sessions = (() => {
1281
1365
  // "Clear all filters" button — resets type + date and re-fetches once
1282
1366
  document.addEventListener("click", (e) => {
1283
1367
  if (e.target && e.target.id === "btn-search-clear-all") {
1284
- const typeEl = document.getElementById("session-search-type");
1285
1368
  const dateEl = document.getElementById("session-search-date");
1286
- if (typeEl) typeEl.value = "";
1369
+ if (_searchTypeSelect) _searchTypeSelect.setValue("");
1287
1370
  if (dateEl) DatePicker.clear(dateEl);
1288
1371
  Sessions.commitSearch();
1289
1372
  }
@@ -1300,12 +1383,7 @@ const Sessions = (() => {
1300
1383
  }
1301
1384
  });
1302
1385
 
1303
- // Type <select> and date picker — commit immediately on change
1304
- document.addEventListener("change", (e) => {
1305
- if (e.target && e.target.id === "session-search-type") {
1306
- Sessions.commitSearch();
1307
- }
1308
- });
1386
+ // Date picker — commit immediately on change
1309
1387
  document.addEventListener("datepicker:change", (e) => {
1310
1388
  if (e.target && e.target.id === "session-search-date") {
1311
1389
  Sessions.commitSearch();
@@ -1639,20 +1717,11 @@ const Sessions = (() => {
1639
1717
  if (n > 1) group.classList.remove("expanded");
1640
1718
  }
1641
1719
 
1642
- // Anchor for a replayed subagent card: inside the tool item that spawned it
1643
- // when there is one, mirroring the live _phaseHost() nesting.
1720
+ // Anchor for a replayed subagent card: the outer container, mirroring the
1721
+ // live _phaseHost() which now sits phase cards at the top level rather than
1722
+ // nested inside the spawning tool item.
1644
1723
  function _historyPhaseHost(historyCtx, container) {
1645
- const item = historyCtx.lastItem;
1646
- if (!item) return container;
1647
-
1648
- let nest = item.querySelector(":scope > .tool-item-phases");
1649
- if (!nest) {
1650
- nest = document.createElement("div");
1651
- nest.className = "tool-item-phases";
1652
- const stdout = item.querySelector(":scope > .tool-item-stdout");
1653
- item.insertBefore(nest, stdout || null);
1654
- }
1655
- return nest;
1724
+ return container;
1656
1725
  }
1657
1726
 
1658
1727
  // Render a single history event into a target container.
@@ -1688,6 +1757,10 @@ const Sessions = (() => {
1688
1757
  el.className = "msg msg-user";
1689
1758
  // Render image thumbnails and PDF badges (if any) followed by the text content
1690
1759
  let bubbleHtml = "";
1760
+ if (Array.isArray(ev.references) && ev.references.length > 0) {
1761
+ bubbleHtml += ev.references.map(_renderMentionBadge).join(" ");
1762
+ if (ev.content || (Array.isArray(ev.images) && ev.images.length > 0)) bubbleHtml += "<br>";
1763
+ }
1691
1764
  if (Array.isArray(ev.images) && ev.images.length > 0) {
1692
1765
  bubbleHtml += ev.images.map(src => {
1693
1766
  if (src && src.startsWith("pdf:")) {
@@ -2143,6 +2216,7 @@ const Sessions = (() => {
2143
2216
  editBtn.innerHTML = EDIT_SVG;
2144
2217
  editBtn.addEventListener("click", async (e) => {
2145
2218
  e.stopPropagation();
2219
+ if (_activeSessionIsRunning()) return;
2146
2220
  const ok = await Modal.confirmOnce(
2147
2221
  "clacky-edit-warn-dismissed",
2148
2222
  I18n.t("chat.edit.warn"),
@@ -2159,11 +2233,20 @@ const Sessions = (() => {
2159
2233
  wrap.appendChild(bar);
2160
2234
  }
2161
2235
 
2162
- function _refreshEditButtons(container) {
2236
+ function _activeSessionIsRunning() {
2237
+ return Sessions.find(Sessions.activeId)?.status === "running";
2238
+ }
2239
+
2240
+ function _refreshEditButtons(container, status) {
2241
+ const running = (status || Sessions.find(Sessions.activeId)?.status) === "running";
2163
2242
  const btns = Array.from(container.querySelectorAll(".msg-edit-btn"));
2164
2243
  btns.forEach((btn, i) => {
2165
- btn.style.display = i === btns.length - 1 ? "" : "none";
2244
+ btn.style.display = !running && i === btns.length - 1 ? "" : "none";
2166
2245
  });
2246
+
2247
+ if (running) {
2248
+ container.querySelectorAll(".msg-user.editing").forEach(el => _exitEditMode(el));
2249
+ }
2167
2250
  }
2168
2251
 
2169
2252
  function _extractUserBubbleText(el) {
@@ -2173,7 +2256,7 @@ const Sessions = (() => {
2173
2256
  }
2174
2257
 
2175
2258
  function _enterEditMode(el) {
2176
- if (el.classList.contains("editing")) return;
2259
+ if (_activeSessionIsRunning() || el.classList.contains("editing")) return;
2177
2260
  el.classList.add("editing");
2178
2261
 
2179
2262
  const originalHtml = el.dataset.originalHtml || "";
@@ -2249,6 +2332,10 @@ const Sessions = (() => {
2249
2332
  function _submitEdit(el, newContent) {
2250
2333
  if (!newContent) return;
2251
2334
  if (!Sessions.activeId) return;
2335
+ if (_activeSessionIsRunning()) {
2336
+ _exitEditMode(el);
2337
+ return;
2338
+ }
2252
2339
 
2253
2340
  const createdAt = el.dataset.createdAt || null;
2254
2341
 
@@ -2529,8 +2616,16 @@ const Sessions = (() => {
2529
2616
  // of the raw glyph.
2530
2617
  const CRON_NAME_PREFIXES = ["\u23F0 ", "\u25B6 "];
2531
2618
  const cronPrefix = CRON_NAME_PREFIXES.find(p => s.name && s.name.startsWith(p)) || "";
2532
- const displayName = (cronPrefix ? s.name.slice(cronPrefix.length) : s.name)
2619
+ const storedName = (cronPrefix ? s.name.slice(cronPrefix.length) : s.name)
2533
2620
  || _relativeTime(s.created_at);
2621
+ // Built-in setup sessions ship with fixed English names; localise their
2622
+ // titles so they follow the UI language (existing sessions included).
2623
+ const BUILTIN_SETUP_NAMES = {
2624
+ "Onboard": "sessions.setup.name",
2625
+ "🌐 Browser Setup": "sessions.setup.browser.name",
2626
+ };
2627
+ const nameKey = s.source === "setup" ? BUILTIN_SETUP_NAMES[storedName] : null;
2628
+ const displayName = nameKey ? I18n.t(nameKey) : storedName;
2534
2629
  const cronIcon = cronPrefix
2535
2630
  ? `<svg class="session-cron-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13" r="8"/><path d="M5 3 2 6"/><path d="m22 6-3-3"/><path d="M6.38 18.7 4 21"/><path d="M17.64 18.67 20 21"/><path d="m9 13 2 2 4-4"/></svg>`
2536
2631
  : "";
@@ -2958,11 +3053,12 @@ const Sessions = (() => {
2958
3053
  Sessions.renderList();
2959
3054
 
2960
3055
  try {
2961
- // Cursor: oldest activity time of non-pinned, non-grouped sessions.
2962
- // Grouped sessions are excluded from the normal list pagination and
2963
- // have their own independent cursor in each sub-view.
3056
+ // Cursor: oldest activity time of non-pinned, non-grouped, non-project
3057
+ // sessions. Project sessions sit in _sessions but render in their own
3058
+ // section; counting them here would drag the cursor back to a stale
3059
+ // timestamp and skip a whole page of regular sessions.
2964
3060
  const oldest = _sessions.reduce((min, s) => {
2965
- if (s.pinned || _groupOf(s)) return min;
3061
+ if (s.pinned || s.project_id || _groupOf(s)) return min;
2966
3062
  const t = s.updated_at || s.created_at;
2967
3063
  if (!t) return min;
2968
3064
  return (!min || t < min) ? t : min;
@@ -3048,10 +3144,9 @@ const Sessions = (() => {
3048
3144
  * search overlay. Never touches the sidebar session list (_sessions). */
3049
3145
  async commitSearch() {
3050
3146
  const qEl = document.getElementById("session-search-q");
3051
- const typeEl = document.getElementById("session-search-type");
3052
3147
  const dateEl = document.getElementById("session-search-date");
3053
3148
  if (qEl) _filter.q = qEl.value.trim();
3054
- if (typeEl) _filter.type = typeEl.value;
3149
+ if (_searchTypeSelect) _filter.type = _searchTypeSelect.value;
3055
3150
  if (dateEl) _filter.date = dateEl.dataset.value || "";
3056
3151
 
3057
3152
  const token = ++_searchToken;
@@ -3127,6 +3222,7 @@ const Sessions = (() => {
3127
3222
  const el = document.getElementById(ids[key]);
3128
3223
  if (el) {
3129
3224
  if (key === "date") DatePicker.clear(el);
3225
+ else if (key === "type" && _searchTypeSelect) _searchTypeSelect.setValue("");
3130
3226
  else el.value = "";
3131
3227
  }
3132
3228
  await Sessions.commitSearch();
@@ -3213,10 +3309,9 @@ const Sessions = (() => {
3213
3309
  // Reset inputs + filter state so the next open starts clean.
3214
3310
  const qEl = document.getElementById("session-search-q");
3215
3311
  const dEl = document.getElementById("session-search-date");
3216
- const tEl = document.getElementById("session-search-type");
3217
3312
  if (qEl) qEl.value = "";
3218
3313
  if (dEl) DatePicker.clear(dEl);
3219
- if (tEl) tEl.value = "";
3314
+ if (_searchTypeSelect) _searchTypeSelect.setValue("");
3220
3315
  const qClear = document.getElementById("btn-search-q-clear");
3221
3316
  if (qClear) qClear.hidden = true;
3222
3317
  _filter.q = _filter.date = _filter.type = "";
@@ -3330,9 +3425,7 @@ const Sessions = (() => {
3330
3425
  }
3331
3426
  const input = $("user-input");
3332
3427
  if (input) {
3333
- input.value = _drafts.get(id) || "";
3334
- input.style.height = "auto";
3335
- if (input.value) input.style.height = Math.min(input.scrollHeight, 200) + "px";
3428
+ Composer.setText(input, _drafts.get(id) || "");
3336
3429
  }
3337
3430
  const draft = _attachmentDrafts.get(id);
3338
3431
  if (draft) {
@@ -3364,7 +3457,7 @@ const Sessions = (() => {
3364
3457
  _cacheActiveMessages();
3365
3458
  if (_activeId) {
3366
3459
  const input = $("user-input");
3367
- if (input) _drafts.set(_activeId, input.value);
3460
+ if (input) _drafts.set(_activeId, Composer.text(input));
3368
3461
  _attachmentDrafts.set(_activeId, { images: _pendingImages.splice(0), files: _pendingFiles.splice(0) });
3369
3462
  _renderAttachmentPreviews();
3370
3463
  Sessions._detachProgressUI(_activeId);
@@ -3427,7 +3520,11 @@ const Sessions = (() => {
3427
3520
  return !t || t < latest;
3428
3521
  };
3429
3522
  const renderGroup = source => {
3430
- const rows = _sessions.filter(s => s.source === source && !s.project_id);
3523
+ // Scan _extraSessions too: a session created by the backend (e.g. a
3524
+ // scheduler run) only reaches the client as a session_update, which
3525
+ // patch() stores there — it would otherwise never light the dot.
3526
+ const inGroup = s => s.source === source && !s.project_id;
3527
+ const rows = _sessions.filter(inGroup).concat(_extraSessions.filter(inGroup));
3431
3528
  const dotStatus = ["running", "awaiting_feedback"].find(st => rows.some(s => s.status === st))
3432
3529
  || (rows.some(s => _recentlyDone.has(s.id)) ? "done" : null);
3433
3530
  _renderGroupItem(list, source, _groups.get(source).count, dotStatus);
@@ -3562,7 +3659,7 @@ const Sessions = (() => {
3562
3659
  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>`;
3563
3660
  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>`;
3564
3661
  const iconMoveToProject = `<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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><path d="M8 13h8"/><path d="m13 10 3 3-3 3"/></svg>`;
3565
- const iconChevronRight = `<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>`;
3662
+ const iconChevronRight = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>`;
3566
3663
  const iconFolderPlus = `<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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><line x1="12" y1="11" x2="12" y2="17"/><line x1="9" y1="14" x2="15" y2="14"/></svg>`;
3567
3664
  const iconRemoveFromProject = `<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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><path d="M5 13h8"/><path d="m8 10 -3 3 3 3"/></svg>`;
3568
3665
 
@@ -3587,8 +3684,9 @@ const Sessions = (() => {
3587
3684
  submenuItemsHtml = projects.map(p => {
3588
3685
  const colorAttr = p.color ? ` style="color:${escapeHtml(p.color)}"` : "";
3589
3686
  const projectIcon = Projects.getIconSvg(p.icon || "folder", 14);
3590
- const check = session.project_id === p.id ? `<span class="session-actions-submenu-check">\u2713</span>` : "";
3591
- return `<div class="session-actions-submenu-item" data-project-id="${escapeHtml(String(p.id))}">
3687
+ const isSelected = session.project_id === p.id;
3688
+ const check = isSelected ? `<span class="session-actions-submenu-check">\u2713</span>` : "";
3689
+ return `<div class="session-actions-submenu-item${isSelected ? " selected" : ""}" data-project-id="${escapeHtml(String(p.id))}">
3592
3690
  <span class="session-actions-submenu-icon"${colorAttr}>${projectIcon}</span>
3593
3691
  <span class="session-actions-submenu-label">${escapeHtml(p.name)}</span>
3594
3692
  ${check}
@@ -3814,9 +3912,10 @@ const Sessions = (() => {
3814
3912
 
3815
3913
  updateStatusBar(status) {
3816
3914
  // chat-header was removed; status text is now shown in the bottom session-info-bar (#sib-status).
3817
- // Here we only update the interrupt button visibility.
3915
+ // Here we update controls whose availability follows the active session status.
3818
3916
  const interrupt = $("btn-interrupt");
3819
3917
  if (interrupt) interrupt.style.display = status === "running" ? "" : "none";
3918
+ _refreshEditButtons(RenderTarget.outer(), status);
3820
3919
 
3821
3920
  // Swap input placeholder so the user knows they can still send extra
3822
3921
  // info while the agent is working.
@@ -3827,7 +3926,7 @@ const Sessions = (() => {
3827
3926
  ? (mobile ? "chat.input.placeholderRunningMobile" : "chat.input.placeholderRunning")
3828
3927
  : (mobile ? "chat.input.placeholderMobile" : "chat.input.placeholder");
3829
3928
  inp.setAttribute("data-i18n-placeholder", key);
3830
- inp.setAttribute("placeholder", I18n.t(key));
3929
+ Composer.setPlaceholder(inp, I18n.t(key));
3831
3930
  }
3832
3931
  },
3833
3932
 
@@ -4092,9 +4191,32 @@ const Sessions = (() => {
4092
4191
 
4093
4192
  // ── Message helpers ────────────────────────────────────────────────────
4094
4193
 
4095
- // Live tool group state (one active group per session at a time)
4096
- _liveToolGroup: null, // current open .tool-group DOM element
4097
- _liveLastToolItem: null, // last .tool-item added (for tool_result pairing)
4194
+ // Live tool group state. For the outer stream this is a single pointer
4195
+ // pair; for a phase body (concurrent fan-out) the pointers live on the
4196
+ // body element itself, so N parallel subagents never share one group.
4197
+ _liveToolGroup: null, // outer stream's open .tool-group DOM element
4198
+ _liveLastToolItem: null, // outer stream's last .tool-item (for tool_result pairing)
4199
+
4200
+ // Resolve the tool-group pointers for a render container. The outer
4201
+ // #messages node keeps its pointers on Sessions; any other container
4202
+ // (a phase body) keeps its own on the element, isolating concurrent runs.
4203
+ _toolGroupCtx(container) {
4204
+ const outer = RenderTarget.outer();
4205
+ if (!container || container === outer) {
4206
+ return {
4207
+ get group() { return Sessions._liveToolGroup; },
4208
+ set group(v) { Sessions._liveToolGroup = v; },
4209
+ get item() { return Sessions._liveLastToolItem; },
4210
+ set item(v) { Sessions._liveLastToolItem = v; },
4211
+ };
4212
+ }
4213
+ return {
4214
+ get group() { return container._liveToolGroup || null; },
4215
+ set group(v) { container._liveToolGroup = v; },
4216
+ get item() { return container._liveLastToolItem || null; },
4217
+ set item(v) { container._liveLastToolItem = v; },
4218
+ };
4219
+ },
4098
4220
 
4099
4221
  // Append a diff block to the message stream (for edit/write previews).
4100
4222
  appendDiff(rows, truncated, hiddenLines) {
@@ -4105,24 +4227,26 @@ const Sessions = (() => {
4105
4227
  // Creates the group if it doesn't exist yet.
4106
4228
  appendToolCall(name, args, summary) {
4107
4229
  const messages = RenderTarget.current();
4108
- if (!Sessions._liveToolGroup) {
4109
- Sessions._liveToolGroup = _makeToolGroup();
4110
- messages.appendChild(Sessions._liveToolGroup);
4230
+ const ctx = Sessions._toolGroupCtx(messages);
4231
+ if (!ctx.group) {
4232
+ ctx.group = _makeToolGroup();
4233
+ messages.appendChild(ctx.group);
4111
4234
  }
4112
- Sessions._liveLastToolItem = _addToolCallToGroup(Sessions._liveToolGroup, name, args, summary);
4235
+ ctx.item = _addToolCallToGroup(ctx.group, name, args, summary);
4113
4236
  _scrollToBottomIfNeeded(messages);
4114
4237
  },
4115
4238
 
4116
4239
  // Update the last tool-item with a result status tick.
4117
- // If _liveToolGroup was collapsed by an intervening info message (e.g.
4240
+ // If the tool group was collapsed by an intervening info message (e.g.
4118
4241
  // "Subagent start/completed" during invoke_skill), fall back to the last
4119
4242
  // still-running .tool-item in the DOM.
4120
4243
  appendToolResult(result) {
4121
- if (Sessions._liveToolGroup && Sessions._liveLastToolItem) {
4122
- _completeLastToolItem(Sessions._liveToolGroup, result);
4244
+ const messages = RenderTarget.current();
4245
+ const ctx = Sessions._toolGroupCtx(messages);
4246
+ if (ctx.group && ctx.item) {
4247
+ _completeLastToolItem(ctx.group, result);
4123
4248
  return;
4124
4249
  }
4125
- const messages = RenderTarget.current();
4126
4250
  if (messages) {
4127
4251
  const running = messages.querySelectorAll(".tool-item-status.running");
4128
4252
  if (running.length > 0) {
@@ -4136,12 +4260,13 @@ const Sessions = (() => {
4136
4260
  // Shows the stdout area automatically on first content.
4137
4261
  appendToolStdout(lines) {
4138
4262
  // Resolve the target tool-item.
4139
- // After a session switch, _liveLastToolItem is null because the messages pane
4140
- // was wiped and re-rendered from history. In that case fall back to the last
4141
- // .tool-item visible in the DOM — that is the in-flight tool the stdout belongs to.
4142
- let toolItem = Sessions._liveLastToolItem;
4263
+ // After a session switch, the live pointer is null because the messages
4264
+ // pane was wiped and re-rendered from history. In that case fall back to
4265
+ // the last .tool-item visible in the DOM — the in-flight tool the stdout
4266
+ // belongs to.
4267
+ const messages = RenderTarget.current();
4268
+ let toolItem = Sessions._toolGroupCtx(messages).item;
4143
4269
  if (!toolItem) {
4144
- const messages = RenderTarget.current();
4145
4270
  if (messages) {
4146
4271
  const items = messages.querySelectorAll(".tool-item");
4147
4272
  if (items.length > 0) toolItem = items[items.length - 1];
@@ -4165,7 +4290,7 @@ const Sessions = (() => {
4165
4290
  // assistant turn with no tool calls).
4166
4291
  appendTokenUsage(ev, container, hostItem) {
4167
4292
  const messages = container || RenderTarget.current();
4168
- const host = hostItem || Sessions._liveLastToolItem || null;
4293
+ const host = hostItem || Sessions._toolGroupCtx(messages).item || null;
4169
4294
  const el = document.createElement("div");
4170
4295
  el.className = "token-usage-line";
4171
4296
 
@@ -4233,12 +4358,15 @@ const Sessions = (() => {
4233
4358
  }
4234
4359
  },
4235
4360
 
4236
- // Collapse the live tool group (call when AI starts responding or task ends).
4361
+ // Collapse the live tool group for the current render container (call when
4362
+ // AI starts responding or task ends). Inside a pinned phase body this
4363
+ // collapses that body's group; otherwise the outer stream's.
4237
4364
  collapseToolGroup() {
4238
- if (Sessions._liveToolGroup) {
4239
- _collapseToolGroup(Sessions._liveToolGroup);
4240
- Sessions._liveToolGroup = null;
4241
- Sessions._liveLastToolItem = null;
4365
+ const ctx = Sessions._toolGroupCtx(RenderTarget.current());
4366
+ if (ctx.group) {
4367
+ _collapseToolGroup(ctx.group);
4368
+ ctx.group = null;
4369
+ ctx.item = null;
4242
4370
  }
4243
4371
  },
4244
4372
 
@@ -4803,8 +4931,8 @@ const Sessions = (() => {
4803
4931
  },
4804
4932
 
4805
4933
  /** Register a message (optionally with attachments) to send after subscribe is confirmed. */
4806
- setPendingMessage(sessionId, content, display = null, files = null) {
4807
- _pendingMessage = { session_id: sessionId, content, display, files };
4934
+ setPendingMessage(sessionId, content, display = null, files = null, references = null) {
4935
+ _pendingMessage = { session_id: sessionId, content, display, files, references };
4808
4936
  },
4809
4937
 
4810
4938
  /** Consume and return the pending message (clears it). */
@@ -4831,42 +4959,6 @@ const Sessions = (() => {
4831
4959
  // ── Session Info Bar Model Switcher ───────────────────────────────────────
4832
4960
  (function() {
4833
4961
  let _isOpen = false;
4834
- // Cache of the most recent benchmark results, keyed by model_id. Kept at
4835
- // closure scope so the numbers survive closing & reopening the dropdown —
4836
- // the user shouldn't have to re-run the test just to peek at results. We
4837
- // intentionally do NOT persist this to disk: latency is a point-in-time
4838
- // measurement, and yesterday's numbers are misleading.
4839
- let _benchCache = {}; // { [model_id]: { ttft_ms, ok, error, ts } }
4840
- let _benchInFlight = false; // prevent double-click spam
4841
-
4842
- // Price-ratio badges for the submodel rows. Ratios come from
4843
- // GET /api/model_prices (backed by Clacky::ModelPricing), so the local
4844
- // pricing table stays the single source of truth - no hardcoded JS prices.
4845
- const _priceCache = new Map(); // model name -> { in, out, label } | null
4846
-
4847
- function _fmtRatio(ratio) {
4848
- if (ratio >= 10) return Math.round(ratio) + "x";
4849
- const s = ratio >= 1 ? ratio.toFixed(1) : ratio.toFixed(2);
4850
- return s.replace(/\.?0+$/, "") + "x";
4851
- }
4852
-
4853
- // Fetches ratios for names not yet cached. Rows simply stay blank when
4854
- // the request fails (offline, unknown model).
4855
- async function _loadPriceRatios(names) {
4856
- const missing = Array.from(new Set(names.filter(Boolean).filter((n) => !_priceCache.has(n))));
4857
- if (!missing.length) return;
4858
- try {
4859
- const res = await fetch("/api/model_prices?models=" + encodeURIComponent(missing.join(",")));
4860
- if (!res.ok) return;
4861
- const data = await res.json();
4862
- missing.forEach((n) => {
4863
- const p = data && data.prices && data.prices[n];
4864
- _priceCache.set(n, p ? { in: p.in, out: p.out, label: _fmtRatio(p.ratio) } : null);
4865
- });
4866
- } catch (e) {
4867
- /* network error - leave prices hidden */
4868
- }
4869
- }
4870
4962
 
4871
4963
  // Toggle model dropdown when clicking on model name
4872
4964
  document.addEventListener("click", async (e) => {
@@ -4880,7 +4972,7 @@ const Sessions = (() => {
4880
4972
  if (_isOpen) {
4881
4973
  dropdown.style.display = "none";
4882
4974
  _isOpen = false;
4883
- _closeSubmodelPanel();
4975
+ ModelPicker.closeSubmodelPanel();
4884
4976
  } else {
4885
4977
  let subOptions = [];
4886
4978
  try { subOptions = JSON.parse(modelEl.dataset.subModelOptions || "[]"); } catch (_) {}
@@ -4890,13 +4982,13 @@ const Sessions = (() => {
4890
4982
  cardModel: modelEl.dataset.cardModel || null
4891
4983
  };
4892
4984
  await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.dataset.modelId || null, subInfo);
4893
-
4985
+
4894
4986
  // Calculate position relative to the model element (fixed positioning)
4895
4987
  const rect = modelEl.getBoundingClientRect();
4896
4988
  dropdown.style.left = `${rect.left + rect.width / 2}px`;
4897
4989
  dropdown.style.top = `${rect.top - 6}px`; // 6px above the element
4898
4990
  dropdown.style.transform = "translate(-50%, -100%)"; // Center horizontally, move up by its own height
4899
-
4991
+
4900
4992
  dropdown.style.display = "block";
4901
4993
  _isOpen = true;
4902
4994
  }
@@ -4908,7 +5000,7 @@ const Sessions = (() => {
4908
5000
  const dropdown = $("sib-model-dropdown");
4909
5001
  if (dropdown) dropdown.style.display = "none";
4910
5002
  _isOpen = false;
4911
- _closeSubmodelPanel();
5003
+ ModelPicker.closeSubmodelPanel();
4912
5004
  }
4913
5005
  });
4914
5006
 
@@ -4919,306 +5011,41 @@ const Sessions = (() => {
4919
5011
  if (!dropdown) return;
4920
5012
 
4921
5013
  try {
4922
- console.log("[Model Switcher] Fetching /api/config...");
4923
5014
  const res = await fetch(`/api/config?session_id=${encodeURIComponent(sessionId)}`);
4924
5015
  const data = await res.json();
4925
- console.log("[Model Switcher] Received data:", data);
4926
5016
  const models = data.models || [];
4927
5017
  const mediaCaps = data.media_capabilities || {};
4928
- console.log("[Model Switcher] Models count:", models.length);
4929
5018
 
4930
5019
  if (models.length === 0) {
4931
5020
  dropdown.innerHTML = '<div style="padding:0.75rem;text-align:center;color:var(--color-text-secondary);font-size:0.6875rem;">No models configured</div>';
4932
5021
  return;
4933
5022
  }
4934
5023
 
4935
- dropdown.innerHTML = "";
4936
-
4937
- // ── Benchmark floating button (top-right of dropdown) ──────────────
4938
- // Tiny ⚡ button pinned to the dropdown's top-right corner. Runs one
4939
- // concurrent request per model and back-fills each row's latency cell.
4940
- // We deliberately avoid a full-width banner — it ate visual space that
4941
- // the model list needs, and most users open the dropdown to SWITCH,
4942
- // not to benchmark. The floating button is discoverable but unobtrusive.
4943
- const bench = document.createElement("div");
4944
- bench.className = "sib-model-bench";
4945
- const btnLabel = (typeof I18n !== "undefined") ? I18n.t("sib.bench.btn") : "Benchmark";
4946
- const btnTooltip = (typeof I18n !== "undefined") ? I18n.t("sib.bench.tooltip") : "Test response latency for every configured model";
4947
- bench.innerHTML = `
4948
- <button type="button" class="sib-bench-btn" title="${btnTooltip}">⚡ <span class="sib-bench-label">${btnLabel}</span></button>
4949
- <span class="sib-bench-hint"></span>
4950
- `;
4951
- dropdown.appendChild(bench);
4952
-
4953
- const benchBtn = bench.querySelector(".sib-bench-btn");
4954
- const benchLabel = bench.querySelector(".sib-bench-label");
4955
- const benchHint = bench.querySelector(".sib-bench-hint");
4956
- benchBtn.addEventListener("click", (ev) => {
4957
- ev.stopPropagation();
4958
- _runBenchmark(sessionId, dropdown, benchBtn, benchLabel, benchHint);
5024
+ ModelPicker.populate(dropdown, {
5025
+ models,
5026
+ currentId: currentModelId,
5027
+ mediaCaps,
5028
+ subInfo,
5029
+ onSelect: (m) => _switchModel(sessionId, m.id, m.model),
5030
+ onSwitchSubModel: (name, displayName) => _switchSubModel(sessionId, name, displayName),
5031
+ onBenchmark: () => _benchmarkSession(sessionId),
5032
+ onConfigureMedia: _goConfigureMedia,
4959
5033
  });
4960
-
4961
- // ── Model rows ─────────────────────────────────────────────────────
4962
- const _nameCounts = models.reduce((acc, m) => {
4963
- acc[m.model] = (acc[m.model] || 0) + 1;
4964
- return acc;
4965
- }, {});
4966
-
4967
- models.forEach(m => {
4968
- console.log("[Model Switcher] Adding model:", m.model, "id:", m.id, "current:", currentModelId);
4969
- const opt = document.createElement("div");
4970
- opt.className = "sib-model-option";
4971
- opt.dataset.modelId = m.id;
4972
- if (m.id === currentModelId) opt.classList.add("current");
4973
-
4974
- const left = document.createElement("span");
4975
- left.className = "sib-model-name";
4976
-
4977
- const nameLine = document.createElement("span");
4978
- nameLine.className = "sib-model-name-main";
4979
- // When a non-default quick-switch model is active for the current row,
4980
- // show only that model's name (avoid the long "main → quick-switch"
4981
- // string that gets truncated).
4982
- const hasActiveOverride =
4983
- m.id === currentModelId &&
4984
- subInfo.current &&
4985
- subInfo.current !== subInfo.cardModel;
4986
- nameLine.textContent = hasActiveOverride ? subInfo.current : m.model;
4987
- left.appendChild(nameLine);
4988
-
4989
- // Vision status for the active model only — follows whichever model is
4990
- // currently in effect (mediaCaps is computed for that same model).
4991
- if (m.id === currentModelId && mediaCaps.vision) {
4992
- const ok = !!mediaCaps.vision.configured;
4993
- const vis = document.createElement("span");
4994
- vis.className = "sib-model-vision " + (ok ? "is-ok" : "is-missing");
4995
- vis.textContent = ok ? I18n.t("sib.vision.ok") : I18n.t("sib.vision.missing");
4996
- vis.title = ok ? I18n.t("sib.vision.okTip") : I18n.t("sib.vision.missingTip");
4997
- left.appendChild(vis);
4998
- }
4999
-
5000
- // Distinguish rows that would otherwise look identical: user-typed
5001
- // remark first (most explicit), then host/key when names collide.
5002
- const remark = (m.remark || "").trim();
5003
- if (remark || _nameCounts[m.model] > 1) {
5004
- left.classList.add("has-sub");
5005
- const host = (() => {
5006
- try { return new URL(m.base_url).host; } catch { return m.base_url || ""; }
5007
- })();
5008
- const subBits = [remark, host, m.api_key_masked].filter(Boolean);
5009
- if (subBits.length) {
5010
- const subLine = document.createElement("span");
5011
- subLine.className = "sib-model-name-sub";
5012
- subLine.textContent = subBits.join(" · ");
5013
- left.appendChild(subLine);
5014
- opt.title = `${m.model} · ${subBits.join(" · ")}`;
5015
- }
5016
- }
5017
-
5018
- opt.appendChild(left);
5019
-
5020
- const right = document.createElement("span");
5021
- right.className = "sib-model-right";
5022
-
5023
- if (m.type === "default") {
5024
- const badge = document.createElement("span");
5025
- badge.className = `model-badge ${m.type}`;
5026
- badge.textContent = m.type;
5027
- right.appendChild(badge);
5028
- }
5029
-
5030
- const lat = document.createElement("span");
5031
- lat.className = "sib-model-latency";
5032
- _fillLatencyCell(lat, _benchCache[m.id]);
5033
- right.appendChild(lat);
5034
-
5035
- const hasSubModels =
5036
- m.id === currentModelId &&
5037
- subInfo.options &&
5038
- subInfo.options.length > 1;
5039
-
5040
- if (hasSubModels) {
5041
- const toggleBtn = document.createElement("button");
5042
- toggleBtn.type = "button";
5043
- toggleBtn.className = "sib-submodel-toggle";
5044
- toggleBtn.title = I18n.t("sib.variant.header");
5045
- toggleBtn.setAttribute("aria-expanded", "false");
5046
- toggleBtn.innerHTML =
5047
- '<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">' +
5048
- '<path d="M6 3.5L10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' +
5049
- '</svg>';
5050
- right.appendChild(toggleBtn);
5051
-
5052
- toggleBtn.addEventListener("click", (ev) => {
5053
- ev.stopPropagation();
5054
- _toggleSubmodelPanel(opt, toggleBtn, sessionId, subInfo);
5055
- });
5056
- }
5057
-
5058
- opt.appendChild(right);
5059
-
5060
- opt.addEventListener("click", () => _switchModel(sessionId, m.id, m.model));
5061
- dropdown.appendChild(opt);
5062
- });
5063
-
5064
- _appendGenerationFooter(dropdown, mediaCaps);
5065
- console.log("[Model Switcher] Dropdown populated, children count:", dropdown.children.length);
5066
5034
  } catch (e) {
5067
5035
  console.error("Failed to load models:", e);
5068
5036
  dropdown.innerHTML = '<div style="padding:0.75rem;text-align:center;color:var(--color-error);font-size:0.6875rem;">Error loading models</div>';
5069
5037
  }
5070
5038
  }
5071
5039
 
5072
- // Footer for image/video/audio generation. These come only from dedicated
5073
- // sidecar models (the chat model can't generate media). We always show all
5074
- // three kinds — configured ones highlighted, the rest dimmed — plus a single
5075
- // Settings entry so users can add or change a generation model.
5076
- function _appendGenerationFooter(dropdown, mediaCaps) {
5077
- const kinds = ["image", "video", "audio"];
5078
- const footer = document.createElement("div");
5079
- footer.className = "sib-gen-footer";
5080
-
5081
- const list = document.createElement("span");
5082
- list.className = "sib-gen-list";
5083
- kinds.forEach(k => {
5084
- const cap = mediaCaps[k] || {};
5085
- const ok = !!cap.configured;
5086
- const chip = document.createElement("span");
5087
- chip.className = "sib-gen-chip " + (ok ? "is-ok" : "is-off");
5088
- chip.textContent = (ok ? "✓ " : "") + I18n.t(`sib.gen.kind.${k}`);
5089
- chip.title = ok
5090
- ? I18n.t("sib.gen.okTip", { model: cap.model || "" })
5091
- : I18n.t("sib.gen.offTip");
5092
- list.appendChild(chip);
5093
- });
5094
- footer.appendChild(list);
5095
-
5096
- const configBtn = document.createElement("button");
5097
- configBtn.type = "button";
5098
- configBtn.className = "sib-gen-config";
5099
- configBtn.textContent = I18n.t("sib.gen.config");
5100
- configBtn.title = I18n.t("sib.gen.offTip");
5101
- configBtn.addEventListener("click", (ev) => {
5102
- ev.stopPropagation();
5103
- _goConfigureMedia();
5104
- });
5105
- footer.appendChild(configBtn);
5106
-
5107
- dropdown.appendChild(footer);
5108
- }
5109
-
5110
- function _goConfigureMedia() {
5111
- const dropdown = $("sib-model-dropdown");
5112
- if (dropdown) dropdown.style.display = "none";
5113
- _isOpen = false;
5114
- _closeSubmodelPanel();
5115
- if (typeof Router !== "undefined") Router.navigate("settings");
5116
- setTimeout(() => {
5117
- const sec = document.getElementById("media-section");
5118
- if (sec) sec.scrollIntoView({ behavior: "smooth", block: "start" });
5119
- }, 200);
5120
- }
5121
-
5122
- // Render one latency cell based on a cached result.
5123
- // undefined → empty slot (never tested / in-flight starts from here)
5124
- // { ok:true } → "812ms" in green/amber/red per threshold
5125
- // { ok:false } → "✕" with error in tooltip
5126
- // { pending:true } → "…" spinner-ish marker
5127
- function _fillLatencyCell(el, entry) {
5128
- el.className = "sib-model-latency";
5129
- el.textContent = "";
5130
- el.removeAttribute("title");
5131
- if (!entry) return;
5132
- if (entry.pending) {
5133
- el.textContent = "…";
5134
- el.classList.add("is-pending");
5135
- return;
5136
- }
5137
- if (!entry.ok) {
5138
- el.textContent = "✕";
5139
- el.classList.add("is-err");
5140
- el.title = entry.error || "failed";
5141
- return;
5142
- }
5143
- const ms = entry.ttft_ms;
5144
- // Same thresholds as the sib-signal status bar — keep them aligned so
5145
- // "3 bars in the status bar" ≈ "green number in the picker".
5146
- // We measure full non-streaming response time (not real TTFT), so ≤60s is
5147
- // normal, ≤120s is slow, beyond is bad. ≤2s still gets the "feels instant"
5148
- // green treatment like the 4-bar signal.
5149
- let cls = "is-bad";
5150
- if (ms <= 2000) cls = "is-ok";
5151
- else if (ms <= 60000) cls = "is-ok";
5152
- else if (ms <= 120000) cls = "is-warn";
5153
- el.classList.add(cls);
5154
- el.textContent = ms >= 1000 ? (ms / 1000).toFixed(1) + "s" : ms + "ms";
5155
- if (typeof I18n !== "undefined") {
5156
- el.title = I18n.t("sib.bench.latencyTooltip", {
5157
- ttft: el.textContent,
5158
- time: new Date(entry.ts).toLocaleTimeString(),
5159
- });
5160
- } else {
5161
- el.title = `TTFT ${el.textContent} · tested ${new Date(entry.ts).toLocaleTimeString()}`;
5162
- }
5163
- }
5164
-
5165
- async function _runBenchmark(sessionId, dropdown, btn, label, hint) {
5166
- if (_benchInFlight) return;
5167
- _benchInFlight = true;
5168
- btn.disabled = true;
5169
- const origLabel = label.textContent;
5170
- const _t = (key, vars) => (typeof I18n !== "undefined") ? I18n.t(key, vars) : key;
5171
- label.textContent = _t("sib.bench.running");
5172
- hint.textContent = "";
5173
-
5174
- // Mark every row as pending so the user sees instant feedback instead of
5175
- // a silent button. _fillLatencyCell handles the visual treatment.
5176
- dropdown.querySelectorAll(".sib-model-option").forEach(opt => {
5177
- const id = opt.dataset.modelId;
5178
- if (!id) return;
5179
- _benchCache[id] = { pending: true };
5180
- _fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[id]);
5181
- });
5182
-
5183
- const t0 = performance.now();
5184
- try {
5185
- const res = await fetch(`/api/sessions/${sessionId}/benchmark`, { method: "POST" });
5186
- const data = await res.json();
5187
- if (!res.ok || !data.ok) throw new Error(data.error || "benchmark failed");
5188
-
5189
- const now = Date.now();
5190
- (data.results || []).forEach(r => {
5191
- _benchCache[r.model_id] = {
5192
- ok: !!r.ok,
5193
- ttft_ms: r.ttft_ms,
5194
- error: r.error,
5195
- ts: now,
5196
- };
5197
- const opt = dropdown.querySelector(`.sib-model-option[data-model-id="${CSS.escape(r.model_id)}"]`);
5198
- if (opt) _fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[r.model_id]);
5199
- });
5200
-
5201
- const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
5202
- hint.textContent = _t("sib.bench.done", { t: elapsed });
5203
- } catch (e) {
5204
- console.error("Benchmark failed:", e);
5205
- hint.textContent = _t("sib.bench.failed", { msg: e.message });
5206
- // Clear pending markers so rows don't stay stuck on "…"
5207
- dropdown.querySelectorAll(".sib-model-option").forEach(opt => {
5208
- const id = opt.dataset.modelId;
5209
- if (id && _benchCache[id]?.pending) {
5210
- _benchCache[id] = undefined;
5211
- _fillLatencyCell(opt.querySelector(".sib-model-latency"), undefined);
5212
- }
5213
- });
5214
- } finally {
5215
- _benchInFlight = false;
5216
- btn.disabled = false;
5217
- label.textContent = origLabel;
5218
- }
5040
+ // Fetch benchmark results for a session. The benchmark UI (pending markers,
5041
+ // latency cells) lives in ModelPicker; this only supplies the request.
5042
+ async function _benchmarkSession(sessionId) {
5043
+ const res = await fetch(`/api/sessions/${sessionId}/benchmark`, { method: "POST" });
5044
+ const data = await res.json();
5045
+ if (!res.ok || !data.ok) throw new Error(data.error || "benchmark failed");
5046
+ return data.results;
5219
5047
  }
5220
5048
 
5221
- // Switch session model via API
5222
5049
  // Switch the session's current card. modelId is the stable runtime id,
5223
5050
  // modelName is for optimistic display.
5224
5051
  async function _switchModel(sessionId, modelId, modelName) {
@@ -5227,7 +5054,7 @@ const Sessions = (() => {
5227
5054
  dropdown.style.display = "none";
5228
5055
  _isOpen = false;
5229
5056
  }
5230
- _closeSubmodelPanel();
5057
+ ModelPicker.closeSubmodelPanel();
5231
5058
 
5232
5059
  try {
5233
5060
  const res = await fetch(`/api/sessions/${sessionId}/model`, {
@@ -5246,8 +5073,7 @@ const Sessions = (() => {
5246
5073
  // backend emits inside this same request. Don't touch sibModel here:
5247
5074
  // the broadcast typically arrives BEFORE this fetch resolves (WS frame
5248
5075
  // vs HTTP response on separate TCP streams), so writing here would
5249
- // overwrite the already-correct value with an incomplete one (this
5250
- // function only knows the card name, not whether a sub-model is pinned).
5076
+ // overwrite the already-correct value with an incomplete one.
5251
5077
 
5252
5078
  console.log(`Switched session ${sessionId} to model ${modelName} (${modelId})`);
5253
5079
  } catch (e) {
@@ -5264,7 +5090,7 @@ const Sessions = (() => {
5264
5090
  dropdown.style.display = "none";
5265
5091
  _isOpen = false;
5266
5092
  }
5267
- _closeSubmodelPanel();
5093
+ ModelPicker.closeSubmodelPanel();
5268
5094
 
5269
5095
  try {
5270
5096
  const res = await fetch(`/api/sessions/${sessionId}/submodel`, {
@@ -5276,145 +5102,24 @@ const Sessions = (() => {
5276
5102
  if (!res.ok) throw new Error(data.error || "Unknown error");
5277
5103
 
5278
5104
  // The status bar is updated by the session_update broadcast that the
5279
- // backend emits inside this same request. Don't touch sibModel here:
5280
- // the broadcast typically arrives BEFORE this fetch resolves, so any
5281
- // write here would race with (and often overwrite) the correct value.
5105
+ // backend emits inside this same request. Don't touch sibModel here.
5282
5106
  } catch (e) {
5283
5107
  console.error("Failed to switch sub-model:", e);
5284
5108
  alert("Failed to switch sub-model: " + e.message);
5285
5109
  }
5286
5110
  }
5287
5111
 
5288
- let _activeSubmodelAnchor = null;
5289
-
5290
- function _closeSubmodelPanel() {
5291
- const panel = $("sib-submodel-panel");
5292
- if (panel) panel.style.display = "none";
5293
- if (_activeSubmodelAnchor) {
5294
- const btn = _activeSubmodelAnchor.querySelector(".sib-submodel-toggle");
5295
- if (btn) btn.setAttribute("aria-expanded", "false");
5296
- _activeSubmodelAnchor.classList.remove("submodel-open");
5297
- _activeSubmodelAnchor = null;
5298
- }
5299
- }
5300
-
5301
- function _toggleSubmodelPanel(anchorRow, btn, sessionId, subInfo) {
5302
- const panel = $("sib-submodel-panel");
5112
+ // Navigate to media config.
5113
+ function _goConfigureMedia() {
5303
5114
  const dropdown = $("sib-model-dropdown");
5304
- if (!panel || !dropdown) return;
5305
-
5306
- if (panel.parentElement !== document.body) {
5307
- document.body.appendChild(panel);
5308
- }
5309
-
5310
- const isOpen = panel.style.display !== "none" && _activeSubmodelAnchor === anchorRow;
5311
- if (isOpen) {
5312
- _closeSubmodelPanel();
5313
- return;
5314
- }
5315
-
5316
- _renderSubmodelPanel(panel, sessionId, subInfo);
5317
-
5318
- // Reset any prior position so measurements are accurate.
5319
- panel.style.left = "0px";
5320
- panel.style.top = "0px";
5321
- panel.style.display = "block";
5322
- panel.style.visibility = "hidden";
5323
-
5324
- const dropRect = dropdown.getBoundingClientRect();
5325
- const btnRect = btn.getBoundingClientRect();
5326
- const panelRect = panel.getBoundingClientRect();
5327
- const gap = 6;
5328
- const margin = 8;
5329
- const vw = window.innerWidth;
5330
- const vh = window.innerHeight;
5331
-
5332
- // Prefer right of dropdown; flip to left if we'd overflow viewport.
5333
- let left = dropRect.right + gap;
5334
- if (left + panelRect.width > vw - margin) {
5335
- left = dropRect.left - panelRect.width - gap;
5336
- }
5337
- // If still off-screen on the left, clamp inside viewport.
5338
- if (left < margin) left = margin;
5339
-
5340
- // Vertically align to the chevron button, but clamp inside viewport.
5341
- let top = btnRect.top - 6;
5342
- if (top + panelRect.height > vh - margin) {
5343
- top = vh - margin - panelRect.height;
5344
- }
5345
- if (top < margin) top = margin;
5346
-
5347
- panel.style.left = `${left}px`;
5348
- panel.style.top = `${top}px`;
5349
- panel.style.visibility = "";
5350
-
5351
- _activeSubmodelAnchor = anchorRow;
5352
- anchorRow.classList.add("submodel-open");
5353
- btn.setAttribute("aria-expanded", "true");
5354
- }
5355
-
5356
- function _renderSubmodelPanel(panel, sessionId, subInfo) {
5357
- panel.innerHTML = "";
5358
-
5359
- const header = document.createElement("div");
5360
- header.className = "sib-submodel-panel-header";
5361
- header.textContent = I18n.t("sib.variant.header");
5362
- panel.appendChild(header);
5363
-
5364
- const cardDefault = subInfo.cardModel;
5365
- subInfo.options.forEach(name => {
5366
- const row = document.createElement("div");
5367
- row.className = "sib-submodel-row";
5368
- row.dataset.subModel = name;
5369
-
5370
- const isActive = subInfo.current
5371
- ? name === subInfo.current
5372
- : name === cardDefault;
5373
- if (isActive) row.classList.add("current");
5374
-
5375
- const nameEl = document.createElement("span");
5376
- nameEl.className = "sib-submodel-row-name";
5377
- nameEl.textContent = name;
5378
- row.appendChild(nameEl);
5379
-
5380
- const rightBox = document.createElement("span");
5381
- rightBox.style.cssText = "display:inline-flex;align-items:center;gap:0.375rem;flex-shrink:0;";
5382
-
5383
- const priceEl = document.createElement("span");
5384
- priceEl.className = "sib-model-price";
5385
- rightBox.appendChild(priceEl);
5386
-
5387
- if (name === cardDefault) {
5388
- const tag = document.createElement("span");
5389
- tag.className = "sib-submodel-default-tag";
5390
- tag.textContent = I18n.t("sib.variant.default");
5391
- rightBox.appendChild(tag);
5392
- }
5393
- row.appendChild(rightBox);
5394
-
5395
- row.addEventListener("click", (ev) => {
5396
- ev.stopPropagation();
5397
- const passName = (name === cardDefault) ? null : name;
5398
- _switchSubModel(sessionId, passName, name);
5399
- });
5400
- panel.appendChild(row);
5401
- });
5402
-
5403
- _fillSubmodelPrices(panel);
5404
- }
5405
-
5406
- // Ratio text fills in asynchronously once /api/model_prices responds
5407
- // (cached afterwards, so re-opening the panel shows prices instantly).
5408
- async function _fillSubmodelPrices(panel) {
5409
- const rows = Array.from(panel.querySelectorAll(".sib-submodel-row"));
5410
- await _loadPriceRatios(rows.map((r) => r.dataset.subModel));
5411
- rows.forEach((row) => {
5412
- const price = _priceCache.get(row.dataset.subModel);
5413
- const el = row.querySelector(".sib-model-price");
5414
- if (!price || !el || el.textContent) return;
5415
- el.textContent = price.label;
5416
- el.title = I18n.t("sib.price.tip", { in: price.in, out: price.out });
5417
- });
5115
+ if (dropdown) dropdown.style.display = "none";
5116
+ _isOpen = false;
5117
+ ModelPicker.closeSubmodelPanel();
5118
+ if (typeof Router !== "undefined") Router.navigate("settings");
5119
+ setTimeout(() => {
5120
+ const sec = document.getElementById("media-section");
5121
+ if (sec) sec.scrollIntoView({ behavior: "smooth", block: "start" });
5122
+ }, 200);
5418
5123
  }
5419
5124
  })();
5420
5125
 
@@ -5424,6 +5129,30 @@ const Sessions = (() => {
5424
5129
  // ── Tree-based directory picker ─────────────────────────────────────────
5425
5130
  const ICON_FOLDER_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
5426
5131
  const ICON_CARET_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
5132
+ const ICON_DRIVE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="12" x2="2" y2="12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" y1="16" x2="6.01" y2="16"/><line x1="10" y1="16" x2="10.01" y2="16"/></svg>';
5133
+ const ICON_STAR_SVG = '<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" stroke-linejoin="round"><path d="M12 2.6L14.84 8.35L21.18 9.27L16.59 13.74L17.67 20.05L12 17.07L6.33 20.05L7.41 13.74L2.82 9.27L9.16 8.35L12 2.6Z"/></svg>';
5134
+ const DIR_FAVORITES_STORAGE_KEY = "clacky-directory-favorites";
5135
+
5136
+ function normalizeFavoritePath(path) {
5137
+ return (path || "").trim().replaceAll("\\", "/").replace(/\/+$/, "") || "/";
5138
+ }
5139
+
5140
+ function loadDirectoryFavorites() {
5141
+ try {
5142
+ const paths = JSON.parse(localStorage.getItem(DIR_FAVORITES_STORAGE_KEY) || "[]");
5143
+ if (!Array.isArray(paths)) return [];
5144
+ return [...new Set(paths.filter(path => typeof path === "string" && path.trim()).map(normalizeFavoritePath))];
5145
+ } catch (_e) {
5146
+ return [];
5147
+ }
5148
+ }
5149
+
5150
+ function saveDirectoryFavorites(paths) {
5151
+ try {
5152
+ localStorage.setItem(DIR_FAVORITES_STORAGE_KEY, JSON.stringify(paths));
5153
+ } catch (_e) {}
5154
+ }
5155
+
5427
5156
  function showDirectoryPicker(currentDir, sessionId) {
5428
5157
  return new Promise((resolve) => {
5429
5158
  const t = (key, fallback) => {
@@ -5435,11 +5164,17 @@ const Sessions = (() => {
5435
5164
  // real filesystem via /api/dirs instead of the session-scoped files API.
5436
5165
  const sessionLess = !sessionId;
5437
5166
 
5167
+ let currentPath = currentDir;
5438
5168
  let selectedPath = currentDir;
5169
+ let currentHasChildDirs = false;
5439
5170
  let rootDir = ""; // absolute path of the session's working directory
5440
5171
  let homeDir = ""; // user home, used as a quick preset
5441
5172
  let defaultDir = ""; // default workspace from agent_config (or fallback)
5442
5173
  let showHidden = false;
5174
+ let placesLoaded = false;
5175
+ let currentPathLoaded = false;
5176
+ let sidebarPlaces = [];
5177
+ let directoryFavorites = loadDirectoryFavorites();
5443
5178
 
5444
5179
  // Fetch directory entries from API, returns dirs with absolute paths
5445
5180
  async function fetchDirs(relPath, absolute = false) {
@@ -5453,9 +5188,14 @@ const Sessions = (() => {
5453
5188
  rootDir = data.root || rootDir;
5454
5189
  homeDir = data.home || homeDir;
5455
5190
  defaultDir = data.default || defaultDir;
5191
+ if (!placesLoaded && data.places) {
5192
+ placesLoaded = true;
5193
+ sidebarPlaces = data.places;
5194
+ renderSidebar();
5195
+ }
5456
5196
  const dirs = (data.entries || []).filter(e => e.type === "dir");
5457
5197
  dirs.forEach(d => { d.absPath = d.path; d.absolute = true; });
5458
- return dirs;
5198
+ return { dirs, resolvedPath: data.path || data.root || relPath, exact: data.exact };
5459
5199
  }
5460
5200
  let url = `/api/sessions/${encodeURIComponent(sessionId)}/files?path=${encodeURIComponent(relPath || "")}`;
5461
5201
  if (absolute) url += "&absolute=true";
@@ -5467,6 +5207,11 @@ const Sessions = (() => {
5467
5207
  if (!absolute) rootDir = data.root || rootDir;
5468
5208
  homeDir = data.home || homeDir;
5469
5209
  defaultDir = data.default || defaultDir;
5210
+ if (!placesLoaded && data.places) {
5211
+ placesLoaded = true;
5212
+ sidebarPlaces = data.places;
5213
+ renderSidebar();
5214
+ }
5470
5215
  const dirs = (data.entries || []).filter(e => e.type === "dir");
5471
5216
  // Convert relative paths to absolute
5472
5217
  dirs.forEach(d => {
@@ -5474,7 +5219,8 @@ const Sessions = (() => {
5474
5219
  const cleanPath = d.path.replace(/^\/+/, "");
5475
5220
  d.absPath = absolute ? ("/" + cleanPath) : (rootDir.replace(/\/+$/, "") + "/" + cleanPath);
5476
5221
  d.absolute = absolute; // Store absolute flag for child expansion
5477
- }); return dirs;
5222
+ });
5223
+ return { dirs, resolvedPath: data.path || data.root || relPath, exact: true };
5478
5224
  }
5479
5225
 
5480
5226
  // Build a flat list row for a directory entry (Finder-style)
@@ -5507,7 +5253,6 @@ const Sessions = (() => {
5507
5253
  listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
5508
5254
  row.classList.add("selected");
5509
5255
  selectedPath = entry.absPath;
5510
- refreshBreadcrumb(entry.absPath);
5511
5256
  }, 200);
5512
5257
  });
5513
5258
 
@@ -5532,35 +5277,56 @@ const Sessions = (() => {
5532
5277
  const modal = document.createElement("div");
5533
5278
  modal.className = "modal-content dp-modal";
5534
5279
 
5280
+ // Left sidebar — quick-access places (favorites + locations)
5281
+ const sidebar = document.createElement("div");
5282
+ sidebar.className = "dp-sidebar";
5283
+ sidebar.innerHTML = `<div class="dp-sidebar-label">${t("sib.dir.favorites", "快捷访问")}</div>`;
5284
+
5285
+ // Main column — toolbar + list + footer
5286
+ const main = document.createElement("div");
5287
+ main.className = "dp-main";
5288
+
5535
5289
  // ── Toolbar ──────────────────────────────────────────────────────
5536
5290
  const toolbar = document.createElement("div");
5537
5291
  toolbar.className = "dp-toolbar";
5538
5292
 
5539
- // Back / Forward buttons
5293
+ // Back / Forward / Refresh buttons
5540
5294
  const backBtn = document.createElement("button");
5541
5295
  backBtn.className = "dp-nav-btn";
5542
5296
  backBtn.title = t("sib.dir.back", "后退");
5543
- backBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>`;
5297
+ backBtn.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" stroke-linejoin="round"><path d="M22 12H2"/><path d="m9 4-7 8 7 8"/></svg>`;
5544
5298
  backBtn.disabled = true;
5545
5299
 
5546
5300
  const fwdBtn = document.createElement("button");
5547
5301
  fwdBtn.className = "dp-nav-btn";
5548
5302
  fwdBtn.title = t("sib.dir.forward", "前进");
5549
- fwdBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`;
5303
+ fwdBtn.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" stroke-linejoin="round"><path d="M2 12h20"/><path d="m15 4 7 8-7 8"/></svg>`;
5550
5304
  fwdBtn.disabled = true;
5551
5305
 
5306
+ const refreshBtn = document.createElement("button");
5307
+ refreshBtn.className = "dp-nav-btn";
5308
+ refreshBtn.title = t("sib.dir.refresh", "刷新");
5309
+ refreshBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path transform="translate(3.5 3.5) scale(0.0166015625)" d="M914.17946 324.34283C854.308387 324.325508 750.895846 324.317788 750.895846 324.317788 732.045471 324.317788 716.764213 339.599801 716.764213 358.451121 716.764213 377.30244 732.045471 392.584453 750.895846 392.584453L955.787864 392.584453C993.448095 392.584453 1024 362.040424 1024 324.368908L1024 119.466667C1024 100.615347 1008.718742 85.333333 989.868367 85.333333 971.017993 85.333333 955.736735 100.615347 955.736735 119.466667L955.736735 256.497996C933.314348 217.628194 905.827487 181.795372 873.995034 149.961328 778.623011 54.584531 649.577119 0 511.974435 0 229.218763 0 0 229.230209 0 512 0 794.769791 229.218763 1024 511.974435 1024 794.730125 1024 1023.948888 794.769791 1023.948888 512 1023.948888 493.148681 1008.66763 477.866667 989.817256 477.866667 970.966881 477.866667 955.685623 493.148681 955.685623 512 955.685623 757.067153 757.029358 955.733333 511.974435 955.733333 266.91953 955.733333 68.263265 757.067153 68.263265 512 68.263265 266.932847 266.91953 68.266667 511.974435 68.266667 631.286484 68.266667 743.028524 115.531923 825.725634 198.233152 862.329644 234.839003 892.298522 277.528256 914.17946 324.34283L914.17946 324.34283Z" stroke="currentColor" stroke-width="35" stroke-linejoin="round"/></svg>`;
5310
+
5552
5311
  const navBtns = document.createElement("div");
5553
5312
  navBtns.className = "dp-nav-btns";
5554
5313
  navBtns.appendChild(backBtn);
5555
5314
  navBtns.appendChild(fwdBtn);
5315
+ navBtns.appendChild(refreshBtn);
5556
5316
 
5557
5317
  // Breadcrumb
5558
5318
  const breadcrumb = document.createElement("div");
5559
5319
  breadcrumb.className = "dp-breadcrumb";
5560
5320
 
5321
+ const favoriteBtn = document.createElement("button");
5322
+ favoriteBtn.className = "dp-nav-btn dp-favorite-btn";
5323
+ favoriteBtn.innerHTML = ICON_STAR_SVG;
5324
+ favoriteBtn.disabled = true;
5325
+
5561
5326
  toolbar.appendChild(navBtns);
5562
5327
  toolbar.appendChild(breadcrumb);
5563
- modal.appendChild(toolbar);
5328
+ toolbar.appendChild(favoriteBtn);
5329
+ main.appendChild(toolbar);
5564
5330
 
5565
5331
  // ── Column header ─────────────────────────────────────────────────
5566
5332
  const colHeader = document.createElement("div");
@@ -5580,13 +5346,13 @@ const Sessions = (() => {
5580
5346
  hiddenToggle.appendChild(hiddenLabelText);
5581
5347
  colHeader.appendChild(colName);
5582
5348
  colHeader.appendChild(hiddenToggle);
5583
- modal.appendChild(colHeader);
5349
+ main.appendChild(colHeader);
5584
5350
 
5585
5351
  // ── List container ────────────────────────────────────────────────
5586
5352
  const listContainer = document.createElement("div");
5587
5353
  listContainer.className = "dp-list";
5588
5354
  listContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
5589
- modal.appendChild(listContainer);
5355
+ main.appendChild(listContainer);
5590
5356
 
5591
5357
  // ── Footer ────────────────────────────────────────────────────────
5592
5358
  const footer = document.createElement("div");
@@ -5613,7 +5379,10 @@ const Sessions = (() => {
5613
5379
  footerActions.appendChild(confirmButton);
5614
5380
  footer.appendChild(newFolderBtn);
5615
5381
  footer.appendChild(footerActions);
5616
- modal.appendChild(footer);
5382
+ main.appendChild(footer);
5383
+
5384
+ modal.appendChild(sidebar);
5385
+ modal.appendChild(main);
5617
5386
 
5618
5387
  overlay.appendChild(modal);
5619
5388
  document.body.appendChild(overlay);
@@ -5621,71 +5390,320 @@ const Sessions = (() => {
5621
5390
  // ── History stack for back/fwd ────────────────────────────────────
5622
5391
  const history = [];
5623
5392
  let historyIndex = -1;
5393
+ let breadcrumbMenu = null;
5394
+ let breadcrumbMenuAnchor = null;
5395
+ let breadcrumbMenuRequest = 0;
5624
5396
 
5625
5397
  function refreshNavBtns() {
5626
5398
  backBtn.disabled = historyIndex <= 0;
5627
5399
  fwdBtn.disabled = historyIndex >= history.length - 1;
5628
5400
  }
5629
5401
 
5630
- // Build breadcrumb from an absolute path
5402
+ // Render the sidebar quick-access favorites + drives
5403
+ function renderSidebar() {
5404
+ sidebar.innerHTML = "";
5405
+
5406
+ const quickAccess = sidebarPlaces.filter(p => p.kind !== "drive");
5407
+ const drives = sidebarPlaces.filter(p => p.kind === "drive");
5408
+
5409
+ const addSection = (labelKey, labelFallback, items) => {
5410
+ if (!items.length) return;
5411
+ const label = document.createElement("div");
5412
+ label.className = "dp-sidebar-label";
5413
+ label.textContent = t(labelKey, labelFallback);
5414
+ sidebar.appendChild(label);
5415
+ items.forEach(place => sidebar.appendChild(buildSidebarItem(place)));
5416
+ };
5417
+
5418
+ addSection("sib.dir.favorites", "Quick access", quickAccess);
5419
+ if (directoryFavorites.length) {
5420
+ const label = document.createElement("div");
5421
+ label.className = "dp-sidebar-label";
5422
+ label.textContent = t("sib.dir.userFavorites", "My favorites");
5423
+ sidebar.appendChild(label);
5424
+ directoryFavorites.forEach(path => sidebar.appendChild(buildFavoriteSidebarItem(path)));
5425
+ }
5426
+ addSection("sib.dir.drives", "Drives", drives);
5427
+ }
5428
+
5429
+ function buildSidebarItem(place) {
5430
+ const item = document.createElement("button");
5431
+ item.type = "button";
5432
+ item.className = "dp-sidebar-item";
5433
+ item.title = place.path;
5434
+
5435
+ const icon = document.createElement("span");
5436
+ icon.className = "dp-sidebar-icon";
5437
+ icon.innerHTML = place.kind === "drive" ? ICON_DRIVE_SVG : ICON_FOLDER_SVG;
5438
+
5439
+ const name = document.createElement("span");
5440
+ name.className = "dp-sidebar-name";
5441
+
5442
+ let label = place.label;
5443
+ if (!label && place.kind === "drive" && place.letter) {
5444
+ label = I18n.t("sib.dir.places.drive", { letter: place.letter });
5445
+ }
5446
+ if (!label) label = t("sib.dir.places." + place.id, place.path);
5447
+ name.textContent = label;
5448
+
5449
+ item.appendChild(icon);
5450
+ item.appendChild(name);
5451
+ item.addEventListener("click", () => navigateTo(place.path, true));
5452
+ return item;
5453
+ }
5454
+
5455
+ function buildFavoriteSidebarItem(path) {
5456
+ const item = document.createElement("div");
5457
+ item.className = "dp-sidebar-favorite";
5458
+ item.title = path;
5459
+
5460
+ const openButton = document.createElement("button");
5461
+ openButton.type = "button";
5462
+ openButton.className = "dp-sidebar-item dp-sidebar-favorite-open";
5463
+
5464
+ const icon = document.createElement("span");
5465
+ icon.className = "dp-sidebar-icon";
5466
+ icon.innerHTML = ICON_FOLDER_SVG;
5467
+
5468
+ const name = document.createElement("span");
5469
+ name.className = "dp-sidebar-name";
5470
+ name.textContent = path === "/" ? "/" : path.split("/").filter(Boolean).pop() || path;
5471
+
5472
+ const removeButton = document.createElement("button");
5473
+ removeButton.type = "button";
5474
+ removeButton.className = "dp-sidebar-favorite-remove";
5475
+ removeButton.title = t("sib.dir.removeFavorite", "Remove from favorites");
5476
+ removeButton.setAttribute("aria-label", removeButton.title);
5477
+ removeButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>';
5478
+
5479
+ openButton.appendChild(icon);
5480
+ openButton.appendChild(name);
5481
+ openButton.addEventListener("click", () => navigateTo(path, true));
5482
+ removeButton.addEventListener("click", (e) => {
5483
+ e.stopPropagation();
5484
+ directoryFavorites = directoryFavorites.filter(favorite => favorite !== path);
5485
+ saveDirectoryFavorites(directoryFavorites);
5486
+ renderSidebar();
5487
+ refreshFavoriteButton();
5488
+ });
5489
+
5490
+ item.appendChild(openButton);
5491
+ item.appendChild(removeButton);
5492
+ return item;
5493
+ }
5494
+
5495
+ function refreshFavoriteButton() {
5496
+ const path = normalizeFavoritePath(currentPath);
5497
+ const isFavorite = directoryFavorites.includes(path);
5498
+ favoriteBtn.disabled = !currentPathLoaded;
5499
+ favoriteBtn.classList.toggle("active", isFavorite);
5500
+ favoriteBtn.setAttribute("aria-pressed", isFavorite ? "true" : "false");
5501
+ favoriteBtn.title = t(
5502
+ isFavorite ? "sib.dir.removeFavorite" : "sib.dir.addFavorite",
5503
+ isFavorite ? "Remove from favorites" : "Add to favorites"
5504
+ );
5505
+ favoriteBtn.setAttribute("aria-label", favoriteBtn.title);
5506
+ }
5507
+
5508
+ function closeBreadcrumbMenu() {
5509
+ breadcrumbMenuRequest++;
5510
+ breadcrumbMenu?.remove();
5511
+ breadcrumbMenuAnchor?.classList.remove("open");
5512
+ breadcrumbMenu = null;
5513
+ breadcrumbMenuAnchor = null;
5514
+ }
5515
+
5516
+ function positionBreadcrumbMenu(menu, anchor) {
5517
+ const rect = anchor.getBoundingClientRect();
5518
+ const maxLeft = Math.max(8, window.innerWidth - menu.offsetWidth - 8);
5519
+ menu.style.left = `${Math.min(rect.left, maxLeft)}px`;
5520
+ menu.style.top = `${rect.bottom + 4}px`;
5521
+ }
5522
+
5523
+ async function toggleBreadcrumbMenu(anchor, parentPath) {
5524
+ if (breadcrumbMenuAnchor === anchor) {
5525
+ closeBreadcrumbMenu();
5526
+ return;
5527
+ }
5528
+
5529
+ closeBreadcrumbMenu();
5530
+ const requestId = breadcrumbMenuRequest;
5531
+ const menu = document.createElement("div");
5532
+ menu.className = "dp-bc-menu";
5533
+ menu.innerHTML = `<div class="dp-bc-menu-state">${t("sib.dir.loading", "Loading…")}</div>`;
5534
+ overlay.appendChild(menu);
5535
+ breadcrumbMenu = menu;
5536
+ breadcrumbMenuAnchor = anchor;
5537
+ anchor.classList.add("open");
5538
+ positionBreadcrumbMenu(menu, anchor);
5539
+
5540
+ try {
5541
+ const { dirs } = await fetchDirs(parentPath, true);
5542
+ if (requestId !== breadcrumbMenuRequest || breadcrumbMenu !== menu) return;
5543
+ menu.innerHTML = "";
5544
+ if (dirs.length === 0) {
5545
+ menu.innerHTML = `<div class="dp-bc-menu-state">${t("sib.dir.empty", "Empty directory")}</div>`;
5546
+ } else {
5547
+ dirs.forEach(entry => {
5548
+ const item = document.createElement("button");
5549
+ item.type = "button";
5550
+ item.className = "dp-bc-menu-item";
5551
+ item.innerHTML = `<span class="dp-bc-menu-icon">${ICON_FOLDER_SVG}</span>`;
5552
+ const label = document.createElement("span");
5553
+ label.className = "dp-bc-menu-name";
5554
+ label.textContent = entry.name;
5555
+ item.appendChild(label);
5556
+ item.addEventListener("click", (e) => {
5557
+ e.stopPropagation();
5558
+ closeBreadcrumbMenu();
5559
+ navigateTo(entry.absPath, true);
5560
+ });
5561
+ menu.appendChild(item);
5562
+ });
5563
+ }
5564
+ positionBreadcrumbMenu(menu, anchor);
5565
+ } catch (err) {
5566
+ if (requestId !== breadcrumbMenuRequest || breadcrumbMenu !== menu) return;
5567
+ console.error("dir picker breadcrumb load failed:", err);
5568
+ menu.innerHTML = `<div class="dp-bc-menu-state dp-bc-menu-error">${t("sib.dir.loadError", "Failed to load")}</div>`;
5569
+ positionBreadcrumbMenu(menu, anchor);
5570
+ }
5571
+ }
5572
+
5573
+ function appendBreadcrumbPart(label, targetPath, current = false, showArrow = true) {
5574
+ const seg = document.createElement("button");
5575
+ seg.type = "button";
5576
+ seg.className = "dp-bc-seg";
5577
+ if (current) seg.classList.add("dp-bc-current");
5578
+ seg.textContent = label;
5579
+ seg.title = targetPath;
5580
+ seg.addEventListener("click", (e) => {
5581
+ e.stopPropagation();
5582
+ navigateTo(targetPath, true);
5583
+ });
5584
+
5585
+ breadcrumb.appendChild(seg);
5586
+ if (showArrow) {
5587
+ const arrow = document.createElement("button");
5588
+ arrow.type = "button";
5589
+ arrow.className = "dp-bc-sep";
5590
+ arrow.title = targetPath;
5591
+ arrow.innerHTML = ICON_CARET_SVG;
5592
+ arrow.addEventListener("click", (e) => {
5593
+ e.stopPropagation();
5594
+ toggleBreadcrumbMenu(arrow, targetPath);
5595
+ });
5596
+ breadcrumb.appendChild(arrow);
5597
+ }
5598
+ }
5599
+
5600
+ // Build a Windows-style breadcrumb from an absolute path.
5631
5601
  function refreshBreadcrumb(absPath) {
5602
+ closeBreadcrumbMenu();
5603
+ breadcrumb.classList.remove("editing");
5632
5604
  breadcrumb.innerHTML = "";
5633
5605
  if (!absPath) return;
5634
- const parts = absPath.replace(/\/+$/, "").split("/");
5635
- // parts[0] is "" for absolute paths starting with /
5636
- parts.forEach((part, i) => {
5637
- if (i === 0 && part === "") {
5638
- // Root "/"
5639
- const seg = document.createElement("span");
5640
- seg.className = "dp-bc-seg";
5641
- seg.textContent = "/";
5642
- seg.addEventListener("click", () => navigateTo("/", true));
5643
- breadcrumb.appendChild(seg);
5644
- return;
5606
+
5607
+ const normalized = absPath.replaceAll("\\", "/").replace(/\/+$/, "") || "/";
5608
+ const parts = normalized.split("/").filter(Boolean);
5609
+ let accumulated = "";
5610
+
5611
+ if (normalized.startsWith("/")) {
5612
+ const isCurrent = parts.length === 0;
5613
+ appendBreadcrumbPart("/", "/", isCurrent, !isCurrent || currentHasChildDirs);
5614
+ } else if (parts.length > 0) {
5615
+ accumulated = parts.shift();
5616
+ const isCurrent = parts.length === 0;
5617
+ appendBreadcrumbPart(accumulated, `${accumulated}/`, isCurrent, !isCurrent || currentHasChildDirs);
5618
+ }
5619
+
5620
+ parts.forEach((part, index) => {
5621
+ accumulated = normalized.startsWith("/")
5622
+ ? `${accumulated}/${part}`
5623
+ : `${accumulated}/${part}`;
5624
+ const targetPath = normalized.startsWith("/") ? accumulated || "/" : accumulated;
5625
+ const isCurrent = index === parts.length - 1;
5626
+ appendBreadcrumbPart(part, targetPath, isCurrent, !isCurrent || currentHasChildDirs);
5627
+ });
5628
+
5629
+ requestAnimationFrame(() => { breadcrumb.scrollLeft = breadcrumb.scrollWidth; });
5630
+ }
5631
+
5632
+ function enterAddressMode(value = currentPath) {
5633
+ closeBreadcrumbMenu();
5634
+ breadcrumb.classList.add("editing");
5635
+ breadcrumb.innerHTML = "";
5636
+
5637
+ const input = document.createElement("input");
5638
+ input.type = "text";
5639
+ input.className = "dp-address-input";
5640
+ input.value = value || "";
5641
+ input.placeholder = t("sib.dir.inputPlaceholder", "Type or select a directory path");
5642
+ input.setAttribute("aria-label", input.placeholder);
5643
+ input.spellcheck = false;
5644
+ input.autocomplete = "off";
5645
+ input.setAttribute("autocapitalize", "off");
5646
+ breadcrumb.appendChild(input);
5647
+
5648
+ let submitting = false;
5649
+ const cancel = () => {
5650
+ if (!breadcrumb.contains(input)) return;
5651
+ refreshBreadcrumb(currentPath);
5652
+ if (listContainer.querySelector(".dp-error")) navigateTo(currentPath, true, false);
5653
+ };
5654
+ IME.bindEnter(input, async () => {
5655
+ const path = input.value.trim();
5656
+ if (!path || submitting) return;
5657
+ submitting = true;
5658
+ input.disabled = true;
5659
+ const navigated = await navigateTo(path, true, true, true);
5660
+ if (!navigated) {
5661
+ enterAddressMode(path);
5662
+ const retryInput = breadcrumb.querySelector(".dp-address-input");
5663
+ retryInput?.classList.add("invalid");
5645
5664
  }
5646
- if (!part) return;
5647
- const sep = document.createElement("span");
5648
- sep.className = "dp-bc-sep";
5649
- sep.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`;
5650
- const seg = document.createElement("span");
5651
- seg.className = "dp-bc-seg";
5652
- seg.textContent = part;
5653
- const targetPath = parts.slice(0, i + 1).join("/") || "/";
5654
- seg.addEventListener("click", () => navigateTo(targetPath, true));
5655
- // Last segment = current dir, styled differently
5656
- if (i === parts.length - 1) {
5657
- seg.classList.add("dp-bc-current");
5658
- // Add folder icon before last segment
5659
- const folderIcon = document.createElement("span");
5660
- folderIcon.className = "dp-bc-folder-icon";
5661
- folderIcon.innerHTML = ICON_FOLDER_SVG;
5662
- breadcrumb.appendChild(sep);
5663
- breadcrumb.appendChild(folderIcon);
5664
- } else {
5665
- breadcrumb.appendChild(sep);
5665
+ });
5666
+ input.addEventListener("keydown", (e) => {
5667
+ e.stopPropagation();
5668
+ if (e.key === "Escape") {
5669
+ e.preventDefault();
5670
+ cancel();
5666
5671
  }
5667
- breadcrumb.appendChild(seg);
5668
5672
  });
5669
- // Always scroll to show the rightmost (current) segment
5670
- requestAnimationFrame(() => { breadcrumb.scrollLeft = breadcrumb.scrollWidth; });
5673
+ input.addEventListener("click", (e) => e.stopPropagation());
5674
+ input.addEventListener("blur", () => {
5675
+ if (!submitting) cancel();
5676
+ });
5677
+
5678
+ requestAnimationFrame(() => {
5679
+ input.focus();
5680
+ input.select();
5681
+ });
5671
5682
  }
5672
5683
 
5673
- // Core navigation function
5674
- async function navigateTo(dirPath, absolute = true, pushHistory = true) {
5675
- listContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
5676
- listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
5684
+ breadcrumb.addEventListener("click", (e) => {
5685
+ if (e.target === breadcrumb) enterAddressMode();
5686
+ });
5677
5687
 
5678
- // Push to history
5679
- if (pushHistory) {
5680
- // Trim forward history
5681
- history.splice(historyIndex + 1);
5682
- history.push(dirPath);
5683
- historyIndex = history.length - 1;
5684
- refreshNavBtns();
5688
+ function comparablePath(path) {
5689
+ let normalized = (path || "").trim().replaceAll("\\", "/");
5690
+ if (normalized === "~" || normalized.startsWith("~/")) {
5691
+ normalized = `${homeDir}${normalized.slice(1)}`;
5685
5692
  }
5693
+ return normalized.replace(/\/+$/, "") || "/";
5694
+ }
5686
5695
 
5687
- selectedPath = dirPath;
5688
- refreshBreadcrumb(dirPath);
5696
+ // Core navigation function
5697
+ async function navigateTo(dirPath, absolute = true, pushHistory = true, requireExact = false) {
5698
+ closeBreadcrumbMenu();
5699
+ if (pushHistory && historyIndex >= 0
5700
+ && comparablePath(currentPath) === comparablePath(dirPath)) {
5701
+ return true;
5702
+ }
5703
+
5704
+ favoriteBtn.disabled = true;
5705
+ listContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
5706
+ listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
5689
5707
 
5690
5708
  const useAbsolute = absolute || sessionLess || dirPath.startsWith("/");
5691
5709
  let relPath = dirPath;
@@ -5694,16 +5712,37 @@ const Sessions = (() => {
5694
5712
  }
5695
5713
 
5696
5714
  try {
5697
- const dirs = await fetchDirs(relPath, useAbsolute);
5698
- // After first relative load, rootDir is now known — refresh breadcrumb + selectedPath
5715
+ const { dirs, resolvedPath, exact } = await fetchDirs(relPath, useAbsolute);
5716
+ const exactMatch = exact === undefined
5717
+ ? comparablePath(resolvedPath) === comparablePath(dirPath)
5718
+ : exact;
5719
+ if (requireExact && !exactMatch) {
5720
+ throw new Error("Directory not found");
5721
+ }
5722
+
5723
+ let effectivePath = useAbsolute && resolvedPath ? resolvedPath : dirPath;
5724
+ // After first relative load, rootDir is now known.
5699
5725
  if (rootDir && dirPath === "") {
5700
- const effectivePath = currentDir && (currentDir === rootDir || currentDir.startsWith(rootDir + "/"))
5726
+ effectivePath = currentDir && (currentDir === rootDir || currentDir.startsWith(rootDir + "/"))
5701
5727
  ? currentDir
5702
5728
  : rootDir;
5703
- selectedPath = effectivePath;
5704
- refreshBreadcrumb(effectivePath);
5705
- history[historyIndex] = effectivePath;
5706
5729
  }
5730
+
5731
+ const isNewHistoryEntry = historyIndex < 0
5732
+ || comparablePath(history[historyIndex]) !== comparablePath(effectivePath);
5733
+ if (pushHistory && isNewHistoryEntry) {
5734
+ history.splice(historyIndex + 1);
5735
+ history.push(effectivePath);
5736
+ historyIndex = history.length - 1;
5737
+ }
5738
+ refreshNavBtns();
5739
+
5740
+ currentPath = effectivePath;
5741
+ selectedPath = effectivePath;
5742
+ currentHasChildDirs = dirs.length > 0;
5743
+ currentPathLoaded = true;
5744
+ refreshBreadcrumb(effectivePath);
5745
+ refreshFavoriteButton();
5707
5746
  listContainer.innerHTML = "";
5708
5747
  if (dirs.length === 0) {
5709
5748
  listContainer.innerHTML = `<div class="dp-empty">${t("sib.dir.empty", "空目录")}</div>`;
@@ -5712,37 +5751,63 @@ const Sessions = (() => {
5712
5751
  dirs.forEach(d => frag.appendChild(buildDirNode(d, 0)));
5713
5752
  listContainer.appendChild(frag);
5714
5753
  }
5754
+ return true;
5715
5755
  } catch (err) {
5716
5756
  console.error("dir picker load failed:", err);
5757
+ refreshFavoriteButton();
5717
5758
  listContainer.innerHTML = `<div class="dp-error">${t("sib.dir.loadError", "加载失败")}</div>`;
5759
+ return false;
5718
5760
  }
5719
5761
  }
5720
5762
 
5721
5763
  // Back / Forward
5722
- backBtn.addEventListener("click", () => {
5764
+ backBtn.addEventListener("click", async () => {
5723
5765
  if (historyIndex <= 0) return;
5724
- historyIndex--;
5725
- refreshNavBtns();
5726
- navigateTo(history[historyIndex], true, false);
5766
+ const targetIndex = historyIndex - 1;
5767
+ if (await navigateTo(history[targetIndex], true, false)) {
5768
+ historyIndex = targetIndex;
5769
+ refreshNavBtns();
5770
+ }
5727
5771
  });
5728
- fwdBtn.addEventListener("click", () => {
5772
+ fwdBtn.addEventListener("click", async () => {
5729
5773
  if (historyIndex >= history.length - 1) return;
5730
- historyIndex++;
5731
- refreshNavBtns();
5732
- navigateTo(history[historyIndex], true, false);
5774
+ const targetIndex = historyIndex + 1;
5775
+ if (await navigateTo(history[targetIndex], true, false)) {
5776
+ historyIndex = targetIndex;
5777
+ refreshNavBtns();
5778
+ }
5779
+ });
5780
+ refreshBtn.addEventListener("click", async () => {
5781
+ refreshBtn.disabled = true;
5782
+ try {
5783
+ await navigateTo(currentPath || currentDir, true, false);
5784
+ } finally {
5785
+ refreshBtn.disabled = false;
5786
+ }
5787
+ });
5788
+ favoriteBtn.addEventListener("click", () => {
5789
+ const path = normalizeFavoritePath(currentPath);
5790
+ if (directoryFavorites.includes(path)) {
5791
+ directoryFavorites = directoryFavorites.filter(favorite => favorite !== path);
5792
+ } else {
5793
+ directoryFavorites.push(path);
5794
+ }
5795
+ saveDirectoryFavorites(directoryFavorites);
5796
+ renderSidebar();
5797
+ refreshFavoriteButton();
5733
5798
  });
5734
5799
 
5735
5800
  // Hidden files toggle
5736
5801
  hiddenCheckbox.addEventListener("change", () => {
5737
5802
  showHidden = hiddenCheckbox.checked;
5738
- navigateTo(selectedPath || currentDir, true);
5803
+ navigateTo(currentPath || currentDir, true, false);
5739
5804
  });
5740
5805
 
5741
5806
  // New folder — inserts an editable placeholder row at the top of the list
5742
5807
  newFolderBtn.addEventListener("click", () => beginCreateFolder());
5743
5808
 
5744
5809
  function beginCreateFolder() {
5745
- const parentDir = (selectedPath || "").trim();
5810
+ const parentDir = (currentPath || "").trim();
5746
5811
  if (!parentDir.startsWith("/")) {
5747
5812
  alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", "invalid parent path"));
5748
5813
  return;
@@ -5799,7 +5864,8 @@ const Sessions = (() => {
5799
5864
  listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
5800
5865
  realNode._dpRow.classList.add("selected");
5801
5866
  selectedPath = data.path;
5802
- refreshBreadcrumb(data.path);
5867
+ currentHasChildDirs = true;
5868
+ refreshBreadcrumb(currentPath);
5803
5869
  } catch (err) {
5804
5870
  alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", err.message || String(err)));
5805
5871
  placeholder.remove();
@@ -5848,26 +5914,29 @@ const Sessions = (() => {
5848
5914
  const startDir = currentDir || "/";
5849
5915
  navigateTo(startDir, true);
5850
5916
  } else {
5851
- // First load relative root to get rootDir from API, then optionally
5852
- // re-navigate to the actual currentDir.
5853
- navigateTo("", false).then ? navigateTo("", false) : navigateTo("", false);
5854
- if (currentDir) {
5855
- // After rootDir is known, breadcrumb + selectedPath already set by navigateTo.
5856
- // If currentDir differs from rootDir, do a second navigate.
5857
- setTimeout(() => {
5858
- if (currentDir && currentDir !== rootDir) {
5859
- navigateTo(currentDir, currentDir.startsWith("/"));
5860
- }
5861
- }, 100);
5862
- }
5917
+ // Load the relative root first so rootDir is known, then navigate to a
5918
+ // deeper current directory without racing two initial requests.
5919
+ navigateTo("", false).then((loaded) => {
5920
+ if (loaded && currentDir && currentDir !== rootDir) {
5921
+ navigateTo(currentDir, currentDir.startsWith("/"));
5922
+ }
5923
+ });
5863
5924
  }
5864
5925
 
5865
5926
  overlay.addEventListener("keydown", (e) => {
5866
- if (e.key === "Escape") cancelButton.click();
5927
+ if (e.key !== "Escape") return;
5928
+ if (breadcrumbMenu) {
5929
+ e.preventDefault();
5930
+ closeBreadcrumbMenu();
5931
+ return;
5932
+ }
5933
+ cancelButton.click();
5867
5934
  });
5868
5935
  overlay.addEventListener("click", (e) => {
5936
+ if (breadcrumbMenu && !breadcrumbMenu.contains(e.target)) closeBreadcrumbMenu();
5869
5937
  if (e.target === overlay) cancelButton.click();
5870
5938
  });
5939
+ breadcrumb.addEventListener("scroll", closeBreadcrumbMenu);
5871
5940
  });
5872
5941
  }
5873
5942