openclacky 1.5.12 → 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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/message_compressor_helper.rb +52 -1
  5. data/lib/clacky/agent/session_serializer.rb +35 -2
  6. data/lib/clacky/agent/time_machine.rb +9 -1
  7. data/lib/clacky/agent.rb +11 -7
  8. data/lib/clacky/agent_config.rb +40 -0
  9. data/lib/clacky/agent_profile.rb +5 -5
  10. data/lib/clacky/billing/platform_billing.rb +84 -1
  11. data/lib/clacky/brand_config.rb +4 -3
  12. data/lib/clacky/cli.rb +4 -2
  13. data/lib/clacky/client.rb +107 -0
  14. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +3 -0
  15. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +40 -21
  16. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  17. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -77
  18. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  19. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  20. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  21. data/lib/clacky/extension/packager.rb +4 -2
  22. data/lib/clacky/extension/verifier.rb +14 -1
  23. data/lib/clacky/message_format/open_ai.rb +5 -1
  24. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  25. data/lib/clacky/message_history.rb +6 -2
  26. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  27. data/lib/clacky/providers.rb +18 -6
  28. data/lib/clacky/server/dir_picker.rb +154 -0
  29. data/lib/clacky/server/git_panel.rb +61 -9
  30. data/lib/clacky/server/http_server.rb +147 -103
  31. data/lib/clacky/tools/terminal.rb +27 -3
  32. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  33. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  34. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  35. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  36. data/lib/clacky/utils/model_pricing.rb +70 -4
  37. data/lib/clacky/version.rb +1 -1
  38. data/lib/clacky/web/app.css +412 -108
  39. data/lib/clacky/web/components/composer.js +45 -7
  40. data/lib/clacky/web/components/custom-select.js +136 -0
  41. data/lib/clacky/web/components/mentions.js +58 -2
  42. data/lib/clacky/web/components/model-picker.js +527 -0
  43. data/lib/clacky/web/components/onboard.js +29 -55
  44. data/lib/clacky/web/core/ext.js +38 -0
  45. data/lib/clacky/web/features/billing/view.js +20 -6
  46. data/lib/clacky/web/features/extensions/view.js +22 -10
  47. data/lib/clacky/web/features/new-session/store.js +15 -1
  48. data/lib/clacky/web/features/new-session/view.js +31 -22
  49. data/lib/clacky/web/features/trash/view.js +31 -19
  50. data/lib/clacky/web/features/workspace/store.js +1 -0
  51. data/lib/clacky/web/features/workspace/view.js +54 -3
  52. data/lib/clacky/web/i18n.js +73 -11
  53. data/lib/clacky/web/index.html +92 -37
  54. data/lib/clacky/web/sessions.js +550 -592
  55. data/lib/clacky/web/settings.js +163 -190
  56. data/lib/clacky.rb +3 -0
  57. metadata +8 -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">
@@ -1258,7 +1288,7 @@ const Sessions = (() => {
1258
1288
  //
1259
1289
  // All search-related interactions. The search UI lives in the sessions
1260
1290
  // sidebar: a magnifier toggle button, the search panel (q input, type
1261
- // <select>, date <input>), inline ✕ clear, and "clear all filters" button.
1291
+ // dropdown, date <input>), inline ✕ clear, and "clear all filters" button.
1262
1292
  //
1263
1293
  // Everything uses event delegation because some elements (e.g. the clear
1264
1294
  // buttons) are re-rendered as filter state changes.
@@ -1272,6 +1302,17 @@ const Sessions = (() => {
1272
1302
  const cmdbarKbd = document.querySelector("#header-cmdbar .cmdbar-kbd");
1273
1303
  if (cmdbarKbd) cmdbarKbd.textContent = _sessionSearchShortcutLabel();
1274
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
+
1275
1316
  // Open the palette: top cmdbar button (or the keyboard shortcut, bound below).
1276
1317
  document.addEventListener("click", (e) => {
1277
1318
  if (e.target && e.target.closest("#header-cmdbar")) {
@@ -1324,9 +1365,8 @@ const Sessions = (() => {
1324
1365
  // "Clear all filters" button — resets type + date and re-fetches once
1325
1366
  document.addEventListener("click", (e) => {
1326
1367
  if (e.target && e.target.id === "btn-search-clear-all") {
1327
- const typeEl = document.getElementById("session-search-type");
1328
1368
  const dateEl = document.getElementById("session-search-date");
1329
- if (typeEl) typeEl.value = "";
1369
+ if (_searchTypeSelect) _searchTypeSelect.setValue("");
1330
1370
  if (dateEl) DatePicker.clear(dateEl);
1331
1371
  Sessions.commitSearch();
1332
1372
  }
@@ -1343,12 +1383,7 @@ const Sessions = (() => {
1343
1383
  }
1344
1384
  });
1345
1385
 
1346
- // Type <select> and date picker — commit immediately on change
1347
- document.addEventListener("change", (e) => {
1348
- if (e.target && e.target.id === "session-search-type") {
1349
- Sessions.commitSearch();
1350
- }
1351
- });
1386
+ // Date picker — commit immediately on change
1352
1387
  document.addEventListener("datepicker:change", (e) => {
1353
1388
  if (e.target && e.target.id === "session-search-date") {
1354
1389
  Sessions.commitSearch();
@@ -2181,6 +2216,7 @@ const Sessions = (() => {
2181
2216
  editBtn.innerHTML = EDIT_SVG;
2182
2217
  editBtn.addEventListener("click", async (e) => {
2183
2218
  e.stopPropagation();
2219
+ if (_activeSessionIsRunning()) return;
2184
2220
  const ok = await Modal.confirmOnce(
2185
2221
  "clacky-edit-warn-dismissed",
2186
2222
  I18n.t("chat.edit.warn"),
@@ -2197,11 +2233,20 @@ const Sessions = (() => {
2197
2233
  wrap.appendChild(bar);
2198
2234
  }
2199
2235
 
2200
- 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";
2201
2242
  const btns = Array.from(container.querySelectorAll(".msg-edit-btn"));
2202
2243
  btns.forEach((btn, i) => {
2203
- btn.style.display = i === btns.length - 1 ? "" : "none";
2244
+ btn.style.display = !running && i === btns.length - 1 ? "" : "none";
2204
2245
  });
2246
+
2247
+ if (running) {
2248
+ container.querySelectorAll(".msg-user.editing").forEach(el => _exitEditMode(el));
2249
+ }
2205
2250
  }
2206
2251
 
2207
2252
  function _extractUserBubbleText(el) {
@@ -2211,7 +2256,7 @@ const Sessions = (() => {
2211
2256
  }
2212
2257
 
2213
2258
  function _enterEditMode(el) {
2214
- if (el.classList.contains("editing")) return;
2259
+ if (_activeSessionIsRunning() || el.classList.contains("editing")) return;
2215
2260
  el.classList.add("editing");
2216
2261
 
2217
2262
  const originalHtml = el.dataset.originalHtml || "";
@@ -2287,6 +2332,10 @@ const Sessions = (() => {
2287
2332
  function _submitEdit(el, newContent) {
2288
2333
  if (!newContent) return;
2289
2334
  if (!Sessions.activeId) return;
2335
+ if (_activeSessionIsRunning()) {
2336
+ _exitEditMode(el);
2337
+ return;
2338
+ }
2290
2339
 
2291
2340
  const createdAt = el.dataset.createdAt || null;
2292
2341
 
@@ -2567,8 +2616,16 @@ const Sessions = (() => {
2567
2616
  // of the raw glyph.
2568
2617
  const CRON_NAME_PREFIXES = ["\u23F0 ", "\u25B6 "];
2569
2618
  const cronPrefix = CRON_NAME_PREFIXES.find(p => s.name && s.name.startsWith(p)) || "";
2570
- const displayName = (cronPrefix ? s.name.slice(cronPrefix.length) : s.name)
2619
+ const storedName = (cronPrefix ? s.name.slice(cronPrefix.length) : s.name)
2571
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;
2572
2629
  const cronIcon = cronPrefix
2573
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>`
2574
2631
  : "";
@@ -3087,10 +3144,9 @@ const Sessions = (() => {
3087
3144
  * search overlay. Never touches the sidebar session list (_sessions). */
3088
3145
  async commitSearch() {
3089
3146
  const qEl = document.getElementById("session-search-q");
3090
- const typeEl = document.getElementById("session-search-type");
3091
3147
  const dateEl = document.getElementById("session-search-date");
3092
3148
  if (qEl) _filter.q = qEl.value.trim();
3093
- if (typeEl) _filter.type = typeEl.value;
3149
+ if (_searchTypeSelect) _filter.type = _searchTypeSelect.value;
3094
3150
  if (dateEl) _filter.date = dateEl.dataset.value || "";
3095
3151
 
3096
3152
  const token = ++_searchToken;
@@ -3166,6 +3222,7 @@ const Sessions = (() => {
3166
3222
  const el = document.getElementById(ids[key]);
3167
3223
  if (el) {
3168
3224
  if (key === "date") DatePicker.clear(el);
3225
+ else if (key === "type" && _searchTypeSelect) _searchTypeSelect.setValue("");
3169
3226
  else el.value = "";
3170
3227
  }
3171
3228
  await Sessions.commitSearch();
@@ -3252,10 +3309,9 @@ const Sessions = (() => {
3252
3309
  // Reset inputs + filter state so the next open starts clean.
3253
3310
  const qEl = document.getElementById("session-search-q");
3254
3311
  const dEl = document.getElementById("session-search-date");
3255
- const tEl = document.getElementById("session-search-type");
3256
3312
  if (qEl) qEl.value = "";
3257
3313
  if (dEl) DatePicker.clear(dEl);
3258
- if (tEl) tEl.value = "";
3314
+ if (_searchTypeSelect) _searchTypeSelect.setValue("");
3259
3315
  const qClear = document.getElementById("btn-search-q-clear");
3260
3316
  if (qClear) qClear.hidden = true;
3261
3317
  _filter.q = _filter.date = _filter.type = "";
@@ -3464,7 +3520,11 @@ const Sessions = (() => {
3464
3520
  return !t || t < latest;
3465
3521
  };
3466
3522
  const renderGroup = source => {
3467
- 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));
3468
3528
  const dotStatus = ["running", "awaiting_feedback"].find(st => rows.some(s => s.status === st))
3469
3529
  || (rows.some(s => _recentlyDone.has(s.id)) ? "done" : null);
3470
3530
  _renderGroupItem(list, source, _groups.get(source).count, dotStatus);
@@ -3599,7 +3659,7 @@ const Sessions = (() => {
3599
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>`;
3600
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>`;
3601
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>`;
3602
- 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>`;
3603
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>`;
3604
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>`;
3605
3665
 
@@ -3624,8 +3684,9 @@ const Sessions = (() => {
3624
3684
  submenuItemsHtml = projects.map(p => {
3625
3685
  const colorAttr = p.color ? ` style="color:${escapeHtml(p.color)}"` : "";
3626
3686
  const projectIcon = Projects.getIconSvg(p.icon || "folder", 14);
3627
- const check = session.project_id === p.id ? `<span class="session-actions-submenu-check">\u2713</span>` : "";
3628
- 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))}">
3629
3690
  <span class="session-actions-submenu-icon"${colorAttr}>${projectIcon}</span>
3630
3691
  <span class="session-actions-submenu-label">${escapeHtml(p.name)}</span>
3631
3692
  ${check}
@@ -3851,9 +3912,10 @@ const Sessions = (() => {
3851
3912
 
3852
3913
  updateStatusBar(status) {
3853
3914
  // chat-header was removed; status text is now shown in the bottom session-info-bar (#sib-status).
3854
- // Here we only update the interrupt button visibility.
3915
+ // Here we update controls whose availability follows the active session status.
3855
3916
  const interrupt = $("btn-interrupt");
3856
3917
  if (interrupt) interrupt.style.display = status === "running" ? "" : "none";
3918
+ _refreshEditButtons(RenderTarget.outer(), status);
3857
3919
 
3858
3920
  // Swap input placeholder so the user knows they can still send extra
3859
3921
  // info while the agent is working.
@@ -4897,42 +4959,6 @@ const Sessions = (() => {
4897
4959
  // ── Session Info Bar Model Switcher ───────────────────────────────────────
4898
4960
  (function() {
4899
4961
  let _isOpen = false;
4900
- // Cache of the most recent benchmark results, keyed by model_id. Kept at
4901
- // closure scope so the numbers survive closing & reopening the dropdown —
4902
- // the user shouldn't have to re-run the test just to peek at results. We
4903
- // intentionally do NOT persist this to disk: latency is a point-in-time
4904
- // measurement, and yesterday's numbers are misleading.
4905
- let _benchCache = {}; // { [model_id]: { ttft_ms, ok, error, ts } }
4906
- let _benchInFlight = false; // prevent double-click spam
4907
-
4908
- // Price-ratio badges for the submodel rows. Ratios come from
4909
- // GET /api/model_prices (backed by Clacky::ModelPricing), so the local
4910
- // pricing table stays the single source of truth - no hardcoded JS prices.
4911
- const _priceCache = new Map(); // model name -> { in, out, label } | null
4912
-
4913
- function _fmtRatio(ratio) {
4914
- if (ratio >= 10) return Math.round(ratio) + "x";
4915
- const s = ratio >= 1 ? ratio.toFixed(1) : ratio.toFixed(2);
4916
- return s.replace(/\.?0+$/, "") + "x";
4917
- }
4918
-
4919
- // Fetches ratios for names not yet cached. Rows simply stay blank when
4920
- // the request fails (offline, unknown model).
4921
- async function _loadPriceRatios(names) {
4922
- const missing = Array.from(new Set(names.filter(Boolean).filter((n) => !_priceCache.has(n))));
4923
- if (!missing.length) return;
4924
- try {
4925
- const res = await fetch("/api/model_prices?models=" + encodeURIComponent(missing.join(",")));
4926
- if (!res.ok) return;
4927
- const data = await res.json();
4928
- missing.forEach((n) => {
4929
- const p = data && data.prices && data.prices[n];
4930
- _priceCache.set(n, p ? { in: p.in, out: p.out, label: _fmtRatio(p.ratio) } : null);
4931
- });
4932
- } catch (e) {
4933
- /* network error - leave prices hidden */
4934
- }
4935
- }
4936
4962
 
4937
4963
  // Toggle model dropdown when clicking on model name
4938
4964
  document.addEventListener("click", async (e) => {
@@ -4946,7 +4972,7 @@ const Sessions = (() => {
4946
4972
  if (_isOpen) {
4947
4973
  dropdown.style.display = "none";
4948
4974
  _isOpen = false;
4949
- _closeSubmodelPanel();
4975
+ ModelPicker.closeSubmodelPanel();
4950
4976
  } else {
4951
4977
  let subOptions = [];
4952
4978
  try { subOptions = JSON.parse(modelEl.dataset.subModelOptions || "[]"); } catch (_) {}
@@ -4956,13 +4982,13 @@ const Sessions = (() => {
4956
4982
  cardModel: modelEl.dataset.cardModel || null
4957
4983
  };
4958
4984
  await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.dataset.modelId || null, subInfo);
4959
-
4985
+
4960
4986
  // Calculate position relative to the model element (fixed positioning)
4961
4987
  const rect = modelEl.getBoundingClientRect();
4962
4988
  dropdown.style.left = `${rect.left + rect.width / 2}px`;
4963
4989
  dropdown.style.top = `${rect.top - 6}px`; // 6px above the element
4964
4990
  dropdown.style.transform = "translate(-50%, -100%)"; // Center horizontally, move up by its own height
4965
-
4991
+
4966
4992
  dropdown.style.display = "block";
4967
4993
  _isOpen = true;
4968
4994
  }
@@ -4974,7 +5000,7 @@ const Sessions = (() => {
4974
5000
  const dropdown = $("sib-model-dropdown");
4975
5001
  if (dropdown) dropdown.style.display = "none";
4976
5002
  _isOpen = false;
4977
- _closeSubmodelPanel();
5003
+ ModelPicker.closeSubmodelPanel();
4978
5004
  }
4979
5005
  });
4980
5006
 
@@ -4985,306 +5011,41 @@ const Sessions = (() => {
4985
5011
  if (!dropdown) return;
4986
5012
 
4987
5013
  try {
4988
- console.log("[Model Switcher] Fetching /api/config...");
4989
5014
  const res = await fetch(`/api/config?session_id=${encodeURIComponent(sessionId)}`);
4990
5015
  const data = await res.json();
4991
- console.log("[Model Switcher] Received data:", data);
4992
5016
  const models = data.models || [];
4993
5017
  const mediaCaps = data.media_capabilities || {};
4994
- console.log("[Model Switcher] Models count:", models.length);
4995
5018
 
4996
5019
  if (models.length === 0) {
4997
5020
  dropdown.innerHTML = '<div style="padding:0.75rem;text-align:center;color:var(--color-text-secondary);font-size:0.6875rem;">No models configured</div>';
4998
5021
  return;
4999
5022
  }
5000
5023
 
5001
- dropdown.innerHTML = "";
5002
-
5003
- // ── Benchmark floating button (top-right of dropdown) ──────────────
5004
- // Tiny ⚡ button pinned to the dropdown's top-right corner. Runs one
5005
- // concurrent request per model and back-fills each row's latency cell.
5006
- // We deliberately avoid a full-width banner — it ate visual space that
5007
- // the model list needs, and most users open the dropdown to SWITCH,
5008
- // not to benchmark. The floating button is discoverable but unobtrusive.
5009
- const bench = document.createElement("div");
5010
- bench.className = "sib-model-bench";
5011
- const btnLabel = (typeof I18n !== "undefined") ? I18n.t("sib.bench.btn") : "Benchmark";
5012
- const btnTooltip = (typeof I18n !== "undefined") ? I18n.t("sib.bench.tooltip") : "Test response latency for every configured model";
5013
- bench.innerHTML = `
5014
- <button type="button" class="sib-bench-btn" title="${btnTooltip}">⚡ <span class="sib-bench-label">${btnLabel}</span></button>
5015
- <span class="sib-bench-hint"></span>
5016
- `;
5017
- dropdown.appendChild(bench);
5018
-
5019
- const benchBtn = bench.querySelector(".sib-bench-btn");
5020
- const benchLabel = bench.querySelector(".sib-bench-label");
5021
- const benchHint = bench.querySelector(".sib-bench-hint");
5022
- benchBtn.addEventListener("click", (ev) => {
5023
- ev.stopPropagation();
5024
- _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,
5025
5033
  });
5026
-
5027
- // ── Model rows ─────────────────────────────────────────────────────
5028
- const _nameCounts = models.reduce((acc, m) => {
5029
- acc[m.model] = (acc[m.model] || 0) + 1;
5030
- return acc;
5031
- }, {});
5032
-
5033
- models.forEach(m => {
5034
- console.log("[Model Switcher] Adding model:", m.model, "id:", m.id, "current:", currentModelId);
5035
- const opt = document.createElement("div");
5036
- opt.className = "sib-model-option";
5037
- opt.dataset.modelId = m.id;
5038
- if (m.id === currentModelId) opt.classList.add("current");
5039
-
5040
- const left = document.createElement("span");
5041
- left.className = "sib-model-name";
5042
-
5043
- const nameLine = document.createElement("span");
5044
- nameLine.className = "sib-model-name-main";
5045
- // When a non-default quick-switch model is active for the current row,
5046
- // show only that model's name (avoid the long "main → quick-switch"
5047
- // string that gets truncated).
5048
- const hasActiveOverride =
5049
- m.id === currentModelId &&
5050
- subInfo.current &&
5051
- subInfo.current !== subInfo.cardModel;
5052
- nameLine.textContent = hasActiveOverride ? subInfo.current : m.model;
5053
- left.appendChild(nameLine);
5054
-
5055
- // Vision status for the active model only — follows whichever model is
5056
- // currently in effect (mediaCaps is computed for that same model).
5057
- if (m.id === currentModelId && mediaCaps.vision) {
5058
- const ok = !!mediaCaps.vision.configured;
5059
- const vis = document.createElement("span");
5060
- vis.className = "sib-model-vision " + (ok ? "is-ok" : "is-missing");
5061
- vis.textContent = ok ? I18n.t("sib.vision.ok") : I18n.t("sib.vision.missing");
5062
- vis.title = ok ? I18n.t("sib.vision.okTip") : I18n.t("sib.vision.missingTip");
5063
- left.appendChild(vis);
5064
- }
5065
-
5066
- // Distinguish rows that would otherwise look identical: user-typed
5067
- // remark first (most explicit), then host/key when names collide.
5068
- const remark = (m.remark || "").trim();
5069
- if (remark || _nameCounts[m.model] > 1) {
5070
- left.classList.add("has-sub");
5071
- const host = (() => {
5072
- try { return new URL(m.base_url).host; } catch { return m.base_url || ""; }
5073
- })();
5074
- const subBits = [remark, host, m.api_key_masked].filter(Boolean);
5075
- if (subBits.length) {
5076
- const subLine = document.createElement("span");
5077
- subLine.className = "sib-model-name-sub";
5078
- subLine.textContent = subBits.join(" · ");
5079
- left.appendChild(subLine);
5080
- opt.title = `${m.model} · ${subBits.join(" · ")}`;
5081
- }
5082
- }
5083
-
5084
- opt.appendChild(left);
5085
-
5086
- const right = document.createElement("span");
5087
- right.className = "sib-model-right";
5088
-
5089
- if (m.type === "default") {
5090
- const badge = document.createElement("span");
5091
- badge.className = `model-badge ${m.type}`;
5092
- badge.textContent = m.type;
5093
- right.appendChild(badge);
5094
- }
5095
-
5096
- const lat = document.createElement("span");
5097
- lat.className = "sib-model-latency";
5098
- _fillLatencyCell(lat, _benchCache[m.id]);
5099
- right.appendChild(lat);
5100
-
5101
- const hasSubModels =
5102
- m.id === currentModelId &&
5103
- subInfo.options &&
5104
- subInfo.options.length > 1;
5105
-
5106
- if (hasSubModels) {
5107
- const toggleBtn = document.createElement("button");
5108
- toggleBtn.type = "button";
5109
- toggleBtn.className = "sib-submodel-toggle";
5110
- toggleBtn.title = I18n.t("sib.variant.header");
5111
- toggleBtn.setAttribute("aria-expanded", "false");
5112
- toggleBtn.innerHTML =
5113
- '<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">' +
5114
- '<path d="M6 3.5L10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' +
5115
- '</svg>';
5116
- right.appendChild(toggleBtn);
5117
-
5118
- toggleBtn.addEventListener("click", (ev) => {
5119
- ev.stopPropagation();
5120
- _toggleSubmodelPanel(opt, toggleBtn, sessionId, subInfo);
5121
- });
5122
- }
5123
-
5124
- opt.appendChild(right);
5125
-
5126
- opt.addEventListener("click", () => _switchModel(sessionId, m.id, m.model));
5127
- dropdown.appendChild(opt);
5128
- });
5129
-
5130
- _appendGenerationFooter(dropdown, mediaCaps);
5131
- console.log("[Model Switcher] Dropdown populated, children count:", dropdown.children.length);
5132
5034
  } catch (e) {
5133
5035
  console.error("Failed to load models:", e);
5134
5036
  dropdown.innerHTML = '<div style="padding:0.75rem;text-align:center;color:var(--color-error);font-size:0.6875rem;">Error loading models</div>';
5135
5037
  }
5136
5038
  }
5137
5039
 
5138
- // Footer for image/video/audio generation. These come only from dedicated
5139
- // sidecar models (the chat model can't generate media). We always show all
5140
- // three kinds — configured ones highlighted, the rest dimmed — plus a single
5141
- // Settings entry so users can add or change a generation model.
5142
- function _appendGenerationFooter(dropdown, mediaCaps) {
5143
- const kinds = ["image", "video", "audio"];
5144
- const footer = document.createElement("div");
5145
- footer.className = "sib-gen-footer";
5146
-
5147
- const list = document.createElement("span");
5148
- list.className = "sib-gen-list";
5149
- kinds.forEach(k => {
5150
- const cap = mediaCaps[k] || {};
5151
- const ok = !!cap.configured;
5152
- const chip = document.createElement("span");
5153
- chip.className = "sib-gen-chip " + (ok ? "is-ok" : "is-off");
5154
- chip.textContent = (ok ? "✓ " : "") + I18n.t(`sib.gen.kind.${k}`);
5155
- chip.title = ok
5156
- ? I18n.t("sib.gen.okTip", { model: cap.model || "" })
5157
- : I18n.t("sib.gen.offTip");
5158
- list.appendChild(chip);
5159
- });
5160
- footer.appendChild(list);
5161
-
5162
- const configBtn = document.createElement("button");
5163
- configBtn.type = "button";
5164
- configBtn.className = "sib-gen-config";
5165
- configBtn.textContent = I18n.t("sib.gen.config");
5166
- configBtn.title = I18n.t("sib.gen.offTip");
5167
- configBtn.addEventListener("click", (ev) => {
5168
- ev.stopPropagation();
5169
- _goConfigureMedia();
5170
- });
5171
- footer.appendChild(configBtn);
5172
-
5173
- dropdown.appendChild(footer);
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;
5174
5047
  }
5175
5048
 
5176
- function _goConfigureMedia() {
5177
- const dropdown = $("sib-model-dropdown");
5178
- if (dropdown) dropdown.style.display = "none";
5179
- _isOpen = false;
5180
- _closeSubmodelPanel();
5181
- if (typeof Router !== "undefined") Router.navigate("settings");
5182
- setTimeout(() => {
5183
- const sec = document.getElementById("media-section");
5184
- if (sec) sec.scrollIntoView({ behavior: "smooth", block: "start" });
5185
- }, 200);
5186
- }
5187
-
5188
- // Render one latency cell based on a cached result.
5189
- // undefined → empty slot (never tested / in-flight starts from here)
5190
- // { ok:true } → "812ms" in green/amber/red per threshold
5191
- // { ok:false } → "✕" with error in tooltip
5192
- // { pending:true } → "…" spinner-ish marker
5193
- function _fillLatencyCell(el, entry) {
5194
- el.className = "sib-model-latency";
5195
- el.textContent = "";
5196
- el.removeAttribute("title");
5197
- if (!entry) return;
5198
- if (entry.pending) {
5199
- el.textContent = "…";
5200
- el.classList.add("is-pending");
5201
- return;
5202
- }
5203
- if (!entry.ok) {
5204
- el.textContent = "✕";
5205
- el.classList.add("is-err");
5206
- el.title = entry.error || "failed";
5207
- return;
5208
- }
5209
- const ms = entry.ttft_ms;
5210
- // Same thresholds as the sib-signal status bar — keep them aligned so
5211
- // "3 bars in the status bar" ≈ "green number in the picker".
5212
- // We measure full non-streaming response time (not real TTFT), so ≤60s is
5213
- // normal, ≤120s is slow, beyond is bad. ≤2s still gets the "feels instant"
5214
- // green treatment like the 4-bar signal.
5215
- let cls = "is-bad";
5216
- if (ms <= 2000) cls = "is-ok";
5217
- else if (ms <= 60000) cls = "is-ok";
5218
- else if (ms <= 120000) cls = "is-warn";
5219
- el.classList.add(cls);
5220
- el.textContent = ms >= 1000 ? (ms / 1000).toFixed(1) + "s" : ms + "ms";
5221
- if (typeof I18n !== "undefined") {
5222
- el.title = I18n.t("sib.bench.latencyTooltip", {
5223
- ttft: el.textContent,
5224
- time: new Date(entry.ts).toLocaleTimeString(),
5225
- });
5226
- } else {
5227
- el.title = `TTFT ${el.textContent} · tested ${new Date(entry.ts).toLocaleTimeString()}`;
5228
- }
5229
- }
5230
-
5231
- async function _runBenchmark(sessionId, dropdown, btn, label, hint) {
5232
- if (_benchInFlight) return;
5233
- _benchInFlight = true;
5234
- btn.disabled = true;
5235
- const origLabel = label.textContent;
5236
- const _t = (key, vars) => (typeof I18n !== "undefined") ? I18n.t(key, vars) : key;
5237
- label.textContent = _t("sib.bench.running");
5238
- hint.textContent = "";
5239
-
5240
- // Mark every row as pending so the user sees instant feedback instead of
5241
- // a silent button. _fillLatencyCell handles the visual treatment.
5242
- dropdown.querySelectorAll(".sib-model-option").forEach(opt => {
5243
- const id = opt.dataset.modelId;
5244
- if (!id) return;
5245
- _benchCache[id] = { pending: true };
5246
- _fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[id]);
5247
- });
5248
-
5249
- const t0 = performance.now();
5250
- try {
5251
- const res = await fetch(`/api/sessions/${sessionId}/benchmark`, { method: "POST" });
5252
- const data = await res.json();
5253
- if (!res.ok || !data.ok) throw new Error(data.error || "benchmark failed");
5254
-
5255
- const now = Date.now();
5256
- (data.results || []).forEach(r => {
5257
- _benchCache[r.model_id] = {
5258
- ok: !!r.ok,
5259
- ttft_ms: r.ttft_ms,
5260
- error: r.error,
5261
- ts: now,
5262
- };
5263
- const opt = dropdown.querySelector(`.sib-model-option[data-model-id="${CSS.escape(r.model_id)}"]`);
5264
- if (opt) _fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[r.model_id]);
5265
- });
5266
-
5267
- const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
5268
- hint.textContent = _t("sib.bench.done", { t: elapsed });
5269
- } catch (e) {
5270
- console.error("Benchmark failed:", e);
5271
- hint.textContent = _t("sib.bench.failed", { msg: e.message });
5272
- // Clear pending markers so rows don't stay stuck on "…"
5273
- dropdown.querySelectorAll(".sib-model-option").forEach(opt => {
5274
- const id = opt.dataset.modelId;
5275
- if (id && _benchCache[id]?.pending) {
5276
- _benchCache[id] = undefined;
5277
- _fillLatencyCell(opt.querySelector(".sib-model-latency"), undefined);
5278
- }
5279
- });
5280
- } finally {
5281
- _benchInFlight = false;
5282
- btn.disabled = false;
5283
- label.textContent = origLabel;
5284
- }
5285
- }
5286
-
5287
- // Switch session model via API
5288
5049
  // Switch the session's current card. modelId is the stable runtime id,
5289
5050
  // modelName is for optimistic display.
5290
5051
  async function _switchModel(sessionId, modelId, modelName) {
@@ -5293,7 +5054,7 @@ const Sessions = (() => {
5293
5054
  dropdown.style.display = "none";
5294
5055
  _isOpen = false;
5295
5056
  }
5296
- _closeSubmodelPanel();
5057
+ ModelPicker.closeSubmodelPanel();
5297
5058
 
5298
5059
  try {
5299
5060
  const res = await fetch(`/api/sessions/${sessionId}/model`, {
@@ -5312,8 +5073,7 @@ const Sessions = (() => {
5312
5073
  // backend emits inside this same request. Don't touch sibModel here:
5313
5074
  // the broadcast typically arrives BEFORE this fetch resolves (WS frame
5314
5075
  // vs HTTP response on separate TCP streams), so writing here would
5315
- // overwrite the already-correct value with an incomplete one (this
5316
- // function only knows the card name, not whether a sub-model is pinned).
5076
+ // overwrite the already-correct value with an incomplete one.
5317
5077
 
5318
5078
  console.log(`Switched session ${sessionId} to model ${modelName} (${modelId})`);
5319
5079
  } catch (e) {
@@ -5330,7 +5090,7 @@ const Sessions = (() => {
5330
5090
  dropdown.style.display = "none";
5331
5091
  _isOpen = false;
5332
5092
  }
5333
- _closeSubmodelPanel();
5093
+ ModelPicker.closeSubmodelPanel();
5334
5094
 
5335
5095
  try {
5336
5096
  const res = await fetch(`/api/sessions/${sessionId}/submodel`, {
@@ -5342,145 +5102,24 @@ const Sessions = (() => {
5342
5102
  if (!res.ok) throw new Error(data.error || "Unknown error");
5343
5103
 
5344
5104
  // The status bar is updated by the session_update broadcast that the
5345
- // backend emits inside this same request. Don't touch sibModel here:
5346
- // the broadcast typically arrives BEFORE this fetch resolves, so any
5347
- // write here would race with (and often overwrite) the correct value.
5105
+ // backend emits inside this same request. Don't touch sibModel here.
5348
5106
  } catch (e) {
5349
5107
  console.error("Failed to switch sub-model:", e);
5350
5108
  alert("Failed to switch sub-model: " + e.message);
5351
5109
  }
5352
5110
  }
5353
5111
 
5354
- let _activeSubmodelAnchor = null;
5355
-
5356
- function _closeSubmodelPanel() {
5357
- const panel = $("sib-submodel-panel");
5358
- if (panel) panel.style.display = "none";
5359
- if (_activeSubmodelAnchor) {
5360
- const btn = _activeSubmodelAnchor.querySelector(".sib-submodel-toggle");
5361
- if (btn) btn.setAttribute("aria-expanded", "false");
5362
- _activeSubmodelAnchor.classList.remove("submodel-open");
5363
- _activeSubmodelAnchor = null;
5364
- }
5365
- }
5366
-
5367
- function _toggleSubmodelPanel(anchorRow, btn, sessionId, subInfo) {
5368
- const panel = $("sib-submodel-panel");
5112
+ // Navigate to media config.
5113
+ function _goConfigureMedia() {
5369
5114
  const dropdown = $("sib-model-dropdown");
5370
- if (!panel || !dropdown) return;
5371
-
5372
- if (panel.parentElement !== document.body) {
5373
- document.body.appendChild(panel);
5374
- }
5375
-
5376
- const isOpen = panel.style.display !== "none" && _activeSubmodelAnchor === anchorRow;
5377
- if (isOpen) {
5378
- _closeSubmodelPanel();
5379
- return;
5380
- }
5381
-
5382
- _renderSubmodelPanel(panel, sessionId, subInfo);
5383
-
5384
- // Reset any prior position so measurements are accurate.
5385
- panel.style.left = "0px";
5386
- panel.style.top = "0px";
5387
- panel.style.display = "block";
5388
- panel.style.visibility = "hidden";
5389
-
5390
- const dropRect = dropdown.getBoundingClientRect();
5391
- const btnRect = btn.getBoundingClientRect();
5392
- const panelRect = panel.getBoundingClientRect();
5393
- const gap = 6;
5394
- const margin = 8;
5395
- const vw = window.innerWidth;
5396
- const vh = window.innerHeight;
5397
-
5398
- // Prefer right of dropdown; flip to left if we'd overflow viewport.
5399
- let left = dropRect.right + gap;
5400
- if (left + panelRect.width > vw - margin) {
5401
- left = dropRect.left - panelRect.width - gap;
5402
- }
5403
- // If still off-screen on the left, clamp inside viewport.
5404
- if (left < margin) left = margin;
5405
-
5406
- // Vertically align to the chevron button, but clamp inside viewport.
5407
- let top = btnRect.top - 6;
5408
- if (top + panelRect.height > vh - margin) {
5409
- top = vh - margin - panelRect.height;
5410
- }
5411
- if (top < margin) top = margin;
5412
-
5413
- panel.style.left = `${left}px`;
5414
- panel.style.top = `${top}px`;
5415
- panel.style.visibility = "";
5416
-
5417
- _activeSubmodelAnchor = anchorRow;
5418
- anchorRow.classList.add("submodel-open");
5419
- btn.setAttribute("aria-expanded", "true");
5420
- }
5421
-
5422
- function _renderSubmodelPanel(panel, sessionId, subInfo) {
5423
- panel.innerHTML = "";
5424
-
5425
- const header = document.createElement("div");
5426
- header.className = "sib-submodel-panel-header";
5427
- header.textContent = I18n.t("sib.variant.header");
5428
- panel.appendChild(header);
5429
-
5430
- const cardDefault = subInfo.cardModel;
5431
- subInfo.options.forEach(name => {
5432
- const row = document.createElement("div");
5433
- row.className = "sib-submodel-row";
5434
- row.dataset.subModel = name;
5435
-
5436
- const isActive = subInfo.current
5437
- ? name === subInfo.current
5438
- : name === cardDefault;
5439
- if (isActive) row.classList.add("current");
5440
-
5441
- const nameEl = document.createElement("span");
5442
- nameEl.className = "sib-submodel-row-name";
5443
- nameEl.textContent = name;
5444
- row.appendChild(nameEl);
5445
-
5446
- const rightBox = document.createElement("span");
5447
- rightBox.style.cssText = "display:inline-flex;align-items:center;gap:0.375rem;flex-shrink:0;";
5448
-
5449
- const priceEl = document.createElement("span");
5450
- priceEl.className = "sib-model-price";
5451
- rightBox.appendChild(priceEl);
5452
-
5453
- if (name === cardDefault) {
5454
- const tag = document.createElement("span");
5455
- tag.className = "sib-submodel-default-tag";
5456
- tag.textContent = I18n.t("sib.variant.default");
5457
- rightBox.appendChild(tag);
5458
- }
5459
- row.appendChild(rightBox);
5460
-
5461
- row.addEventListener("click", (ev) => {
5462
- ev.stopPropagation();
5463
- const passName = (name === cardDefault) ? null : name;
5464
- _switchSubModel(sessionId, passName, name);
5465
- });
5466
- panel.appendChild(row);
5467
- });
5468
-
5469
- _fillSubmodelPrices(panel);
5470
- }
5471
-
5472
- // Ratio text fills in asynchronously once /api/model_prices responds
5473
- // (cached afterwards, so re-opening the panel shows prices instantly).
5474
- async function _fillSubmodelPrices(panel) {
5475
- const rows = Array.from(panel.querySelectorAll(".sib-submodel-row"));
5476
- await _loadPriceRatios(rows.map((r) => r.dataset.subModel));
5477
- rows.forEach((row) => {
5478
- const price = _priceCache.get(row.dataset.subModel);
5479
- const el = row.querySelector(".sib-model-price");
5480
- if (!price || !el || el.textContent) return;
5481
- el.textContent = price.label;
5482
- el.title = I18n.t("sib.price.tip", { in: price.in, out: price.out });
5483
- });
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);
5484
5123
  }
5485
5124
  })();
5486
5125
 
@@ -5490,6 +5129,30 @@ const Sessions = (() => {
5490
5129
  // ── Tree-based directory picker ─────────────────────────────────────────
5491
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>';
5492
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
+
5493
5156
  function showDirectoryPicker(currentDir, sessionId) {
5494
5157
  return new Promise((resolve) => {
5495
5158
  const t = (key, fallback) => {
@@ -5501,12 +5164,17 @@ const Sessions = (() => {
5501
5164
  // real filesystem via /api/dirs instead of the session-scoped files API.
5502
5165
  const sessionLess = !sessionId;
5503
5166
 
5167
+ let currentPath = currentDir;
5504
5168
  let selectedPath = currentDir;
5169
+ let currentHasChildDirs = false;
5505
5170
  let rootDir = ""; // absolute path of the session's working directory
5506
5171
  let homeDir = ""; // user home, used as a quick preset
5507
5172
  let defaultDir = ""; // default workspace from agent_config (or fallback)
5508
5173
  let showHidden = false;
5509
5174
  let placesLoaded = false;
5175
+ let currentPathLoaded = false;
5176
+ let sidebarPlaces = [];
5177
+ let directoryFavorites = loadDirectoryFavorites();
5510
5178
 
5511
5179
  // Fetch directory entries from API, returns dirs with absolute paths
5512
5180
  async function fetchDirs(relPath, absolute = false) {
@@ -5520,10 +5188,14 @@ const Sessions = (() => {
5520
5188
  rootDir = data.root || rootDir;
5521
5189
  homeDir = data.home || homeDir;
5522
5190
  defaultDir = data.default || defaultDir;
5523
- if (!placesLoaded && data.places) { placesLoaded = true; renderSidebar(data.places); }
5191
+ if (!placesLoaded && data.places) {
5192
+ placesLoaded = true;
5193
+ sidebarPlaces = data.places;
5194
+ renderSidebar();
5195
+ }
5524
5196
  const dirs = (data.entries || []).filter(e => e.type === "dir");
5525
5197
  dirs.forEach(d => { d.absPath = d.path; d.absolute = true; });
5526
- return dirs;
5198
+ return { dirs, resolvedPath: data.path || data.root || relPath, exact: data.exact };
5527
5199
  }
5528
5200
  let url = `/api/sessions/${encodeURIComponent(sessionId)}/files?path=${encodeURIComponent(relPath || "")}`;
5529
5201
  if (absolute) url += "&absolute=true";
@@ -5535,7 +5207,11 @@ const Sessions = (() => {
5535
5207
  if (!absolute) rootDir = data.root || rootDir;
5536
5208
  homeDir = data.home || homeDir;
5537
5209
  defaultDir = data.default || defaultDir;
5538
- if (!placesLoaded && data.places) { placesLoaded = true; renderSidebar(data.places); }
5210
+ if (!placesLoaded && data.places) {
5211
+ placesLoaded = true;
5212
+ sidebarPlaces = data.places;
5213
+ renderSidebar();
5214
+ }
5539
5215
  const dirs = (data.entries || []).filter(e => e.type === "dir");
5540
5216
  // Convert relative paths to absolute
5541
5217
  dirs.forEach(d => {
@@ -5543,7 +5219,8 @@ const Sessions = (() => {
5543
5219
  const cleanPath = d.path.replace(/^\/+/, "");
5544
5220
  d.absPath = absolute ? ("/" + cleanPath) : (rootDir.replace(/\/+$/, "") + "/" + cleanPath);
5545
5221
  d.absolute = absolute; // Store absolute flag for child expansion
5546
- }); return dirs;
5222
+ });
5223
+ return { dirs, resolvedPath: data.path || data.root || relPath, exact: true };
5547
5224
  }
5548
5225
 
5549
5226
  // Build a flat list row for a directory entry (Finder-style)
@@ -5576,7 +5253,6 @@ const Sessions = (() => {
5576
5253
  listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
5577
5254
  row.classList.add("selected");
5578
5255
  selectedPath = entry.absPath;
5579
- refreshBreadcrumb(entry.absPath);
5580
5256
  }, 200);
5581
5257
  });
5582
5258
 
@@ -5614,30 +5290,42 @@ const Sessions = (() => {
5614
5290
  const toolbar = document.createElement("div");
5615
5291
  toolbar.className = "dp-toolbar";
5616
5292
 
5617
- // Back / Forward buttons
5293
+ // Back / Forward / Refresh buttons
5618
5294
  const backBtn = document.createElement("button");
5619
5295
  backBtn.className = "dp-nav-btn";
5620
5296
  backBtn.title = t("sib.dir.back", "后退");
5621
- 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>`;
5622
5298
  backBtn.disabled = true;
5623
5299
 
5624
5300
  const fwdBtn = document.createElement("button");
5625
5301
  fwdBtn.className = "dp-nav-btn";
5626
5302
  fwdBtn.title = t("sib.dir.forward", "前进");
5627
- 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>`;
5628
5304
  fwdBtn.disabled = true;
5629
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
+
5630
5311
  const navBtns = document.createElement("div");
5631
5312
  navBtns.className = "dp-nav-btns";
5632
5313
  navBtns.appendChild(backBtn);
5633
5314
  navBtns.appendChild(fwdBtn);
5315
+ navBtns.appendChild(refreshBtn);
5634
5316
 
5635
5317
  // Breadcrumb
5636
5318
  const breadcrumb = document.createElement("div");
5637
5319
  breadcrumb.className = "dp-breadcrumb";
5638
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
+
5639
5326
  toolbar.appendChild(navBtns);
5640
5327
  toolbar.appendChild(breadcrumb);
5328
+ toolbar.appendChild(favoriteBtn);
5641
5329
  main.appendChild(toolbar);
5642
5330
 
5643
5331
  // ── Column header ─────────────────────────────────────────────────
@@ -5702,101 +5390,320 @@ const Sessions = (() => {
5702
5390
  // ── History stack for back/fwd ────────────────────────────────────
5703
5391
  const history = [];
5704
5392
  let historyIndex = -1;
5393
+ let breadcrumbMenu = null;
5394
+ let breadcrumbMenuAnchor = null;
5395
+ let breadcrumbMenuRequest = 0;
5705
5396
 
5706
5397
  function refreshNavBtns() {
5707
5398
  backBtn.disabled = historyIndex <= 0;
5708
5399
  fwdBtn.disabled = historyIndex >= history.length - 1;
5709
5400
  }
5710
5401
 
5711
- // Render the sidebar quick-access favorites
5712
- function renderSidebar(places) {
5402
+ // Render the sidebar quick-access favorites + drives
5403
+ function renderSidebar() {
5713
5404
  sidebar.innerHTML = "";
5714
- if (!places || !places.length) return;
5715
- const label = document.createElement("div");
5716
- label.className = "dp-sidebar-label";
5717
- label.textContent = t("sib.dir.favorites", "Quick access");
5718
- sidebar.appendChild(label);
5719
-
5720
- places.forEach(place => {
5721
- const item = document.createElement("button");
5722
- item.type = "button";
5723
- item.className = "dp-sidebar-item";
5724
- item.title = place.path;
5725
-
5726
- const icon = document.createElement("span");
5727
- icon.className = "dp-sidebar-icon";
5728
- icon.innerHTML = ICON_FOLDER_SVG;
5729
-
5730
- const name = document.createElement("span");
5731
- name.className = "dp-sidebar-name";
5732
- name.textContent = place.label || t("sib.dir.places." + place.id, place.path);
5733
-
5734
- item.appendChild(icon);
5735
- item.appendChild(name);
5736
- item.addEventListener("click", () => navigateTo(place.path, true));
5737
- sidebar.appendChild(item);
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();
5738
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`;
5739
5521
  }
5740
5522
 
5741
- // Build breadcrumb from an absolute path
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.
5742
5601
  function refreshBreadcrumb(absPath) {
5602
+ closeBreadcrumbMenu();
5603
+ breadcrumb.classList.remove("editing");
5743
5604
  breadcrumb.innerHTML = "";
5744
5605
  if (!absPath) return;
5745
- const parts = absPath.replace(/\/+$/, "").split("/");
5746
- // parts[0] is "" for absolute paths starting with /
5747
- parts.forEach((part, i) => {
5748
- if (i === 0 && part === "") {
5749
- // Root "/"
5750
- const seg = document.createElement("span");
5751
- seg.className = "dp-bc-seg";
5752
- seg.textContent = "/";
5753
- seg.addEventListener("click", () => navigateTo("/", true));
5754
- breadcrumb.appendChild(seg);
5755
- 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");
5756
5664
  }
5757
- if (!part) return;
5758
- const sep = document.createElement("span");
5759
- sep.className = "dp-bc-sep";
5760
- 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>`;
5761
- const seg = document.createElement("span");
5762
- seg.className = "dp-bc-seg";
5763
- seg.textContent = part;
5764
- const targetPath = parts.slice(0, i + 1).join("/") || "/";
5765
- seg.addEventListener("click", () => navigateTo(targetPath, true));
5766
- // Last segment = current dir, styled differently
5767
- if (i === parts.length - 1) {
5768
- seg.classList.add("dp-bc-current");
5769
- // Add folder icon before last segment
5770
- const folderIcon = document.createElement("span");
5771
- folderIcon.className = "dp-bc-folder-icon";
5772
- folderIcon.innerHTML = ICON_FOLDER_SVG;
5773
- breadcrumb.appendChild(sep);
5774
- breadcrumb.appendChild(folderIcon);
5775
- } else {
5776
- breadcrumb.appendChild(sep);
5665
+ });
5666
+ input.addEventListener("keydown", (e) => {
5667
+ e.stopPropagation();
5668
+ if (e.key === "Escape") {
5669
+ e.preventDefault();
5670
+ cancel();
5777
5671
  }
5778
- breadcrumb.appendChild(seg);
5779
5672
  });
5780
- // Always scroll to show the rightmost (current) segment
5781
- 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
+ });
5782
5682
  }
5783
5683
 
5784
- // Core navigation function
5785
- async function navigateTo(dirPath, absolute = true, pushHistory = true) {
5786
- listContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
5787
- listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
5684
+ breadcrumb.addEventListener("click", (e) => {
5685
+ if (e.target === breadcrumb) enterAddressMode();
5686
+ });
5788
5687
 
5789
- // Push to history
5790
- if (pushHistory) {
5791
- // Trim forward history
5792
- history.splice(historyIndex + 1);
5793
- history.push(dirPath);
5794
- historyIndex = history.length - 1;
5795
- refreshNavBtns();
5688
+ function comparablePath(path) {
5689
+ let normalized = (path || "").trim().replaceAll("\\", "/");
5690
+ if (normalized === "~" || normalized.startsWith("~/")) {
5691
+ normalized = `${homeDir}${normalized.slice(1)}`;
5692
+ }
5693
+ return normalized.replace(/\/+$/, "") || "/";
5694
+ }
5695
+
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;
5796
5702
  }
5797
5703
 
5798
- selectedPath = dirPath;
5799
- refreshBreadcrumb(dirPath);
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"));
5800
5707
 
5801
5708
  const useAbsolute = absolute || sessionLess || dirPath.startsWith("/");
5802
5709
  let relPath = dirPath;
@@ -5805,16 +5712,37 @@ const Sessions = (() => {
5805
5712
  }
5806
5713
 
5807
5714
  try {
5808
- const dirs = await fetchDirs(relPath, useAbsolute);
5809
- // 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.
5810
5725
  if (rootDir && dirPath === "") {
5811
- const effectivePath = currentDir && (currentDir === rootDir || currentDir.startsWith(rootDir + "/"))
5726
+ effectivePath = currentDir && (currentDir === rootDir || currentDir.startsWith(rootDir + "/"))
5812
5727
  ? currentDir
5813
5728
  : rootDir;
5814
- selectedPath = effectivePath;
5815
- refreshBreadcrumb(effectivePath);
5816
- history[historyIndex] = effectivePath;
5817
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();
5818
5746
  listContainer.innerHTML = "";
5819
5747
  if (dirs.length === 0) {
5820
5748
  listContainer.innerHTML = `<div class="dp-empty">${t("sib.dir.empty", "空目录")}</div>`;
@@ -5823,37 +5751,63 @@ const Sessions = (() => {
5823
5751
  dirs.forEach(d => frag.appendChild(buildDirNode(d, 0)));
5824
5752
  listContainer.appendChild(frag);
5825
5753
  }
5754
+ return true;
5826
5755
  } catch (err) {
5827
5756
  console.error("dir picker load failed:", err);
5757
+ refreshFavoriteButton();
5828
5758
  listContainer.innerHTML = `<div class="dp-error">${t("sib.dir.loadError", "加载失败")}</div>`;
5759
+ return false;
5829
5760
  }
5830
5761
  }
5831
5762
 
5832
5763
  // Back / Forward
5833
- backBtn.addEventListener("click", () => {
5764
+ backBtn.addEventListener("click", async () => {
5834
5765
  if (historyIndex <= 0) return;
5835
- historyIndex--;
5836
- refreshNavBtns();
5837
- navigateTo(history[historyIndex], true, false);
5766
+ const targetIndex = historyIndex - 1;
5767
+ if (await navigateTo(history[targetIndex], true, false)) {
5768
+ historyIndex = targetIndex;
5769
+ refreshNavBtns();
5770
+ }
5838
5771
  });
5839
- fwdBtn.addEventListener("click", () => {
5772
+ fwdBtn.addEventListener("click", async () => {
5840
5773
  if (historyIndex >= history.length - 1) return;
5841
- historyIndex++;
5842
- refreshNavBtns();
5843
- 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();
5844
5798
  });
5845
5799
 
5846
5800
  // Hidden files toggle
5847
5801
  hiddenCheckbox.addEventListener("change", () => {
5848
5802
  showHidden = hiddenCheckbox.checked;
5849
- navigateTo(selectedPath || currentDir, true);
5803
+ navigateTo(currentPath || currentDir, true, false);
5850
5804
  });
5851
5805
 
5852
5806
  // New folder — inserts an editable placeholder row at the top of the list
5853
5807
  newFolderBtn.addEventListener("click", () => beginCreateFolder());
5854
5808
 
5855
5809
  function beginCreateFolder() {
5856
- const parentDir = (selectedPath || "").trim();
5810
+ const parentDir = (currentPath || "").trim();
5857
5811
  if (!parentDir.startsWith("/")) {
5858
5812
  alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", "invalid parent path"));
5859
5813
  return;
@@ -5910,7 +5864,8 @@ const Sessions = (() => {
5910
5864
  listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
5911
5865
  realNode._dpRow.classList.add("selected");
5912
5866
  selectedPath = data.path;
5913
- refreshBreadcrumb(data.path);
5867
+ currentHasChildDirs = true;
5868
+ refreshBreadcrumb(currentPath);
5914
5869
  } catch (err) {
5915
5870
  alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", err.message || String(err)));
5916
5871
  placeholder.remove();
@@ -5959,26 +5914,29 @@ const Sessions = (() => {
5959
5914
  const startDir = currentDir || "/";
5960
5915
  navigateTo(startDir, true);
5961
5916
  } else {
5962
- // First load relative root to get rootDir from API, then optionally
5963
- // re-navigate to the actual currentDir.
5964
- navigateTo("", false).then ? navigateTo("", false) : navigateTo("", false);
5965
- if (currentDir) {
5966
- // After rootDir is known, breadcrumb + selectedPath already set by navigateTo.
5967
- // If currentDir differs from rootDir, do a second navigate.
5968
- setTimeout(() => {
5969
- if (currentDir && currentDir !== rootDir) {
5970
- navigateTo(currentDir, currentDir.startsWith("/"));
5971
- }
5972
- }, 100);
5973
- }
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
+ });
5974
5924
  }
5975
5925
 
5976
5926
  overlay.addEventListener("keydown", (e) => {
5977
- 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();
5978
5934
  });
5979
5935
  overlay.addEventListener("click", (e) => {
5936
+ if (breadcrumbMenu && !breadcrumbMenu.contains(e.target)) closeBreadcrumbMenu();
5980
5937
  if (e.target === overlay) cancelButton.click();
5981
5938
  });
5939
+ breadcrumb.addEventListener("scroll", closeBreadcrumbMenu);
5982
5940
  });
5983
5941
  }
5984
5942