openclacky 1.3.3 → 1.3.4

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/docs/rich_ui_guide.md +277 -0
  4. data/docs/rich_ui_refactor_plan.md +396 -0
  5. data/lib/clacky/agent/llm_caller.rb +10 -4
  6. data/lib/clacky/agent/session_serializer.rb +3 -2
  7. data/lib/clacky/agent.rb +3 -2
  8. data/lib/clacky/agent_config.rb +2 -14
  9. data/lib/clacky/api_extension.rb +262 -0
  10. data/lib/clacky/api_extension_loader.rb +156 -0
  11. data/lib/clacky/cli.rb +93 -3
  12. data/lib/clacky/client.rb +38 -13
  13. data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
  14. data/lib/clacky/default_agents/_panels/time_machine/panel.js +1 -1
  15. data/lib/clacky/default_skills/media-gen/SKILL.md +9 -6
  16. data/lib/clacky/idle_compression_timer.rb +3 -1
  17. data/lib/clacky/locales/en.rb +26 -0
  18. data/lib/clacky/locales/i18n.rb +26 -0
  19. data/lib/clacky/locales/zh.rb +26 -0
  20. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  21. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  22. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  23. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  24. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  25. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  26. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  27. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  28. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  29. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  30. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  31. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  32. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  33. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  34. data/lib/clacky/rich_ui.rb +57 -0
  35. data/lib/clacky/rich_ui_controller.rb +3 -1549
  36. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  37. data/lib/clacky/server/http_server.rb +150 -103
  38. data/lib/clacky/server/session_registry.rb +1 -1
  39. data/lib/clacky/shell_hook_loader.rb +1 -1
  40. data/lib/clacky/tools/edit.rb +14 -2
  41. data/lib/clacky/ui2/ui_controller.rb +7 -0
  42. data/lib/clacky/version.rb +1 -1
  43. data/lib/clacky/web/app.css +56 -59
  44. data/lib/clacky/web/app.js +65 -7
  45. data/lib/clacky/web/components/onboard.js +18 -2
  46. data/lib/clacky/web/core/aside.js +8 -3
  47. data/lib/clacky/web/core/ext.js +1 -1
  48. data/lib/clacky/web/features/skills/store.js +30 -2
  49. data/lib/clacky/web/features/skills/view.js +32 -1
  50. data/lib/clacky/web/features/workspace/view.js +1 -1
  51. data/lib/clacky/web/i18n.js +32 -20
  52. data/lib/clacky/web/index.html +9 -17
  53. data/lib/clacky/web/sessions.js +286 -28
  54. data/lib/clacky/web/settings.js +109 -111
  55. data/lib/clacky/web/ws-dispatcher.js +7 -3
  56. data/lib/clacky.rb +17 -2
  57. metadata +38 -2
  58. data/lib/clacky/media/output_dir.rb +0 -43
@@ -124,6 +124,16 @@ const Sessions = (() => {
124
124
 
125
125
  let html;
126
126
  if (typeof marked !== "undefined") {
127
+ // Restore KTX placeholders that ended up inside code regions back to
128
+ // their original literal — those weren't math, just text that happened
129
+ // to look like math (C-5635). Marked alone decides what's a code region.
130
+ const restoreMathInCode = (s) =>
131
+ s.replace(/\u0000KTX(\d+)\u0000/g, (_, i) => {
132
+ const m = math[+i];
133
+ if (m) m.disabled = true; // suppress later KaTeX render
134
+ return m ? m.raw : "";
135
+ });
136
+
127
137
  const renderer = new marked.Renderer();
128
138
  renderer.link = function({ href, title, text }) {
129
139
  const titleAttr = title ? ` title="${title}"` : "";
@@ -132,6 +142,7 @@ const Sessions = (() => {
132
142
  // Override code block rendering: apply syntax highlighting + header with
133
143
  // language label and copy button.
134
144
  renderer.code = function({ text: code, lang }) {
145
+ code = restoreMathInCode(code);
135
146
  const language = (lang || "").split(/\s+/)[0]; // strip extra info after lang
136
147
  const highlighted = _highlightCode(code, language);
137
148
  const displayLang = language || "text";
@@ -152,6 +163,10 @@ const Sessions = (() => {
152
163
  `</div>`
153
164
  );
154
165
  };
166
+ // Inline code: same restoration, then plain <code> with HTML escaping.
167
+ renderer.codespan = function({ text }) {
168
+ return `<code>${escapeHtml(restoreMathInCode(text))}</code>`;
169
+ };
155
170
  try {
156
171
  html = marked.parse(prepared, { breaks: true, gfm: true, renderer });
157
172
  } catch (_) {
@@ -163,7 +178,11 @@ const Sessions = (() => {
163
178
  }
164
179
 
165
180
  if (math.length) {
166
- html = html.replace(/\u0000KTX(\d+)\u0000/g, (_, i) => _renderMath(math[+i]));
181
+ html = html.replace(/\u0000KTX(\d+)\u0000/g, (_, i) => {
182
+ const m = math[+i];
183
+ if (!m || m.disabled) return ""; // already restored by renderer
184
+ return _renderMath(m);
185
+ });
167
186
  }
168
187
  return html;
169
188
  }
@@ -187,22 +206,30 @@ const Sessions = (() => {
187
206
 
188
207
  // Pull $$...$$, \[...\], $...$, \(...\) out of `text` and replace each with a
189
208
  // sentinel placeholder so marked won't mangle the LaTeX source. The matched
190
- // segments are pushed (with display flag) onto `out` for later KaTeX rendering.
209
+ // segments are pushed onto `out` as { body, display, raw } for later KaTeX
210
+ // rendering or — if the placeholder ends up landing inside a code block /
211
+ // code span — restoration to the original literal by the renderer (C-5635).
212
+ //
213
+ // Why no code-block detection here: we don't want a second, parallel notion
214
+ // of "what counts as code" living next to marked's. Instead we extract math
215
+ // unconditionally, then let marked be the single arbiter — its
216
+ // renderer.code / renderer.codespan hooks restore any placeholder that
217
+ // turns out to be inside a code region (see _markedParse).
191
218
  function _extractMath(text, out, placeholder) {
192
219
  // Order matters: longest/most-specific delimiters first.
193
220
  const patterns = [
194
- { re: /\$\$([\s\S]+?)\$\$/g, display: true },
195
- { re: /\\\[([\s\S]+?)\\\]/g, display: true },
196
- { re: /\\\(([\s\S]+?)\\\)/g, display: false },
221
+ { re: /\$\$([\s\S]+?)\$\$/g, display: true, wrap: (b) => `$$${b}$$` },
222
+ { re: /\\\[([\s\S]+?)\\\]/g, display: true, wrap: (b) => `\\[${b}\\]` },
223
+ { re: /\\\(([\s\S]+?)\\\)/g, display: false, wrap: (b) => `\\(${b}\\)` },
197
224
  // Inline $...$: avoid $$, escaped \$, and prevent crossing newlines/blanks.
198
- { re: /(^|[^\$])\$(?!\s)([^\$\n]+?)(?<!\s)\$(?!\d)/g, display: false, hasPrefix: true },
225
+ { re: /(^|[^\$])\$(?!\s)([^\$\n]+?)(?<!\s)\$(?!\d)/g, display: false, hasPrefix: true, wrap: (b) => `$${b}$` },
199
226
  ];
200
227
  let result = text;
201
- for (const { re, display, hasPrefix } of patterns) {
228
+ for (const { re, display, hasPrefix, wrap } of patterns) {
202
229
  result = result.replace(re, (m, a, b) => {
203
230
  const body = hasPrefix ? b : a;
204
231
  const idx = out.length;
205
- out.push({ body, display });
232
+ out.push({ body, display, raw: wrap(body) });
206
233
  return (hasPrefix ? a : "") + placeholder(idx);
207
234
  });
208
235
  }
@@ -827,7 +854,7 @@ const Sessions = (() => {
827
854
  _imageSeq = 0;
828
855
  _renderAttachmentPreviews();
829
856
 
830
- WS.send({ type: "message", session_id: Sessions.activeId, content, files });
857
+ WS.send({ type: "message", session_id: Sessions.activeId, content, files, lang: I18n.lang() });
831
858
 
832
859
  // Disable any pending feedback cards — user has replied (either by clicking
833
860
  // an option button or by typing directly). The backend has already consumed
@@ -1360,11 +1387,11 @@ const Sessions = (() => {
1360
1387
  bubbleHtml += escapeHtml(ev.content || "");
1361
1388
  el.innerHTML = bubbleHtml;
1362
1389
  if (ev.created_at) el.dataset.createdAt = ev.created_at;
1363
- _appendMsgTime(el, ev.created_at);
1364
1390
  const wrap = document.createElement("div");
1365
1391
  wrap.className = "msg-user-wrap";
1366
1392
  wrap.appendChild(el);
1367
1393
  _appendUserActionBar(el, wrap);
1394
+ _appendMsgTime(wrap, ev.created_at);
1368
1395
  container.appendChild(wrap);
1369
1396
  break;
1370
1397
  }
@@ -1616,6 +1643,7 @@ const Sessions = (() => {
1616
1643
  // If no more history remains, insert a "beginning of conversation" marker at the top.
1617
1644
  // Remove any existing marker first to avoid duplicates.
1618
1645
  messages.querySelector(".history-start-marker")?.remove();
1646
+ _refreshEditButtons(messages);
1619
1647
  if (!state.hasMore) {
1620
1648
  const marker = document.createElement("div");
1621
1649
  marker.className = "history-start-marker";
@@ -1663,9 +1691,11 @@ const Sessions = (() => {
1663
1691
  if (!createdAt) return I18n.t("sessions.untitled") || "Untitled";
1664
1692
  const d = new Date(createdAt);
1665
1693
  const now = new Date();
1666
- const diffDays = Math.floor((now - d) / 86400000);
1667
1694
  const pad = n => String(n).padStart(2, "0");
1668
1695
  const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
1696
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
1697
+ const dDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
1698
+ const diffDays = Math.round((today - dDay) / 86400000);
1669
1699
  if (diffDays === 0) return `Today ${hhmm}`;
1670
1700
  if (diffDays === 1) return `Yesterday ${hhmm}`;
1671
1701
  return `${d.getMonth() + 1}/${d.getDate()} ${hhmm}`;
@@ -1738,12 +1768,18 @@ const Sessions = (() => {
1738
1768
 
1739
1769
  const editBtn = document.createElement("button");
1740
1770
  editBtn.type = "button";
1741
- editBtn.className = "msg-user-action-btn";
1771
+ editBtn.className = "msg-user-action-btn msg-edit-btn";
1742
1772
  editBtn.setAttribute("aria-label", I18n.t("chat.edit"));
1743
1773
  editBtn.title = I18n.t("chat.edit");
1744
1774
  editBtn.innerHTML = EDIT_SVG;
1745
- editBtn.addEventListener("click", (e) => {
1775
+ editBtn.addEventListener("click", async (e) => {
1746
1776
  e.stopPropagation();
1777
+ const ok = await Modal.confirmOnce(
1778
+ "clacky-edit-warn-dismissed",
1779
+ I18n.t("chat.edit.warn"),
1780
+ I18n.t("chat.edit.warnSkip")
1781
+ );
1782
+ if (!ok) return;
1747
1783
  _enterEditMode(el);
1748
1784
  });
1749
1785
 
@@ -1752,6 +1788,13 @@ const Sessions = (() => {
1752
1788
  wrap.appendChild(bar);
1753
1789
  }
1754
1790
 
1791
+ function _refreshEditButtons(container) {
1792
+ const btns = Array.from(container.querySelectorAll(".msg-edit-btn"));
1793
+ btns.forEach((btn, i) => {
1794
+ btn.style.display = i === btns.length - 1 ? "" : "none";
1795
+ });
1796
+ }
1797
+
1755
1798
  function _extractUserBubbleText(el) {
1756
1799
  const clone = el.cloneNode(true);
1757
1800
  clone.querySelectorAll(".msg-user-actions, .msg-time").forEach(n => n.remove());
@@ -1821,9 +1864,14 @@ const Sessions = (() => {
1821
1864
  });
1822
1865
  }
1823
1866
 
1824
- function _exitEditMode(el) {
1867
+ function _exitEditMode(el, newContent) {
1825
1868
  el.classList.remove("editing");
1826
- el.innerHTML = el.dataset.originalHtml || "";
1869
+ if (newContent) {
1870
+ el.dataset.originalHtml = escapeHtml(newContent);
1871
+ el.innerHTML = escapeHtml(newContent);
1872
+ } else {
1873
+ el.innerHTML = el.dataset.originalHtml || "";
1874
+ }
1827
1875
  }
1828
1876
 
1829
1877
  function _submitEdit(el, newContent) {
@@ -1843,7 +1891,7 @@ const Sessions = (() => {
1843
1891
  }
1844
1892
  }
1845
1893
 
1846
- _exitEditMode(el);
1894
+ _exitEditMode(el, newContent);
1847
1895
 
1848
1896
  WS.send({ type: "edit_message", session_id: Sessions.activeId, content: newContent, created_at: createdAt });
1849
1897
 
@@ -3428,6 +3476,16 @@ const Sessions = (() => {
3428
3476
  }
3429
3477
  },
3430
3478
 
3479
+ stampLastUserBubble(createdAt) {
3480
+ const messages = RenderTarget.outer();
3481
+ const wraps = messages.querySelectorAll(".msg-user-wrap");
3482
+ if (!wraps.length) return;
3483
+ const el = wraps[wraps.length - 1].querySelector(".msg-user");
3484
+ if (el) el.dataset.createdAt = createdAt;
3485
+ const dedup = _renderedCreatedAt[_activeId] || (_renderedCreatedAt[_activeId] = new Set());
3486
+ dedup.add(createdAt);
3487
+ },
3488
+
3431
3489
  appendMsg(type, html, { time } = {}) {
3432
3490
  // Starting a new assistant/user/info message: close any open tool group
3433
3491
  if (type !== "tool") Sessions.collapseToolGroup();
@@ -3453,14 +3511,15 @@ const Sessions = (() => {
3453
3511
  } else {
3454
3512
  el.innerHTML = html;
3455
3513
  }
3456
- if (type === "user" && time) _appendMsgTime(el, time);
3457
3514
 
3458
3515
  if (type === "user") {
3459
3516
  const wrap = document.createElement("div");
3460
3517
  wrap.className = "msg-user-wrap";
3461
3518
  wrap.appendChild(el);
3462
3519
  _appendUserActionBar(el, wrap);
3520
+ if (time) _appendMsgTime(wrap, time);
3463
3521
  messages.appendChild(wrap);
3522
+ _refreshEditButtons(messages);
3464
3523
  } else {
3465
3524
  // For error messages, add a retry button
3466
3525
  if (type === "error") {
@@ -4613,7 +4672,7 @@ const Sessions = (() => {
4613
4672
  // ── Tree-based directory picker ─────────────────────────────────────────
4614
4673
  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>';
4615
4674
  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>';
4616
- function showDirectoryPicker(currentDir, sessionId, titleText) {
4675
+ function showDirectoryPicker(currentDir, sessionId) {
4617
4676
  return new Promise((resolve) => {
4618
4677
  const t = (key, fallback) => {
4619
4678
  const s = I18n.t(key);
@@ -4626,7 +4685,8 @@ const Sessions = (() => {
4626
4685
 
4627
4686
  let selectedPath = currentDir;
4628
4687
  let rootDir = ""; // absolute path of the session's working directory
4629
- let homeDir = ""; // user home, used as the "working directory" preset when session-less
4688
+ let homeDir = ""; // user home, used as a quick preset
4689
+ let defaultDir = ""; // default workspace from agent_config (or fallback)
4630
4690
  let showHidden = false;
4631
4691
 
4632
4692
  // Fetch directory entries from API, returns dirs with absolute paths
@@ -4640,6 +4700,7 @@ const Sessions = (() => {
4640
4700
  const data = await resp.json();
4641
4701
  rootDir = data.root || rootDir;
4642
4702
  homeDir = data.home || homeDir;
4703
+ defaultDir = data.default || defaultDir;
4643
4704
  const dirs = (data.entries || []).filter(e => e.type === "dir");
4644
4705
  dirs.forEach(d => { d.absPath = d.path; d.absolute = true; });
4645
4706
  return dirs;
@@ -4652,6 +4713,8 @@ const Sessions = (() => {
4652
4713
  const data = await resp.json();
4653
4714
  // Only update rootDir in relative mode; absolute mode would overwrite it with "/"
4654
4715
  if (!absolute) rootDir = data.root || rootDir;
4716
+ homeDir = data.home || homeDir;
4717
+ defaultDir = data.default || defaultDir;
4655
4718
  const dirs = (data.entries || []).filter(e => e.type === "dir");
4656
4719
  // Convert relative paths to absolute
4657
4720
  dirs.forEach(d => {
@@ -4717,6 +4780,12 @@ const Sessions = (() => {
4717
4780
  // Enter this directory - reload tree with this as root
4718
4781
  loadTreeForPath(entry.absPath, entry.absolute);
4719
4782
  });
4783
+
4784
+ // Make the row addressable for inline-edit invoked elsewhere
4785
+ // (e.g. from the "+ New folder" button placing its placeholder).
4786
+ node._dpEntry = entry;
4787
+ node._dpName = name;
4788
+ node._dpRow = row;
4720
4789
  return node;
4721
4790
  }
4722
4791
 
@@ -4765,10 +4834,9 @@ const Sessions = (() => {
4765
4834
  // Title
4766
4835
  const title = document.createElement("div");
4767
4836
  title.className = "modal-title";
4768
- title.textContent = titleText
4769
- || (sessionLess
4770
- ? t("sessions.modal.dirpicker.title", "选择工作目录")
4771
- : t("sib.dir.changePrompt", "切换工作目录"));
4837
+ title.textContent = sessionLess
4838
+ ? t("sessions.modal.dirpicker.title", "选择工作目录")
4839
+ : t("sib.dir.changePrompt", "切换工作目录");
4772
4840
  modal.appendChild(title);
4773
4841
 
4774
4842
  // Modal body
@@ -4809,18 +4877,206 @@ const Sessions = (() => {
4809
4877
  loadTreeForPath(parent, true);
4810
4878
  });
4811
4879
 
4880
+ // ── "+ New folder" ────────────────────────────────────────────────
4881
+ // Drops an editable placeholder row at the top of the current view
4882
+ // and POSTs to the backend on commit. Confirm = Enter / blur, cancel
4883
+ // = Escape. The placeholder is purely visual; nothing is created on
4884
+ // disk until the user commits a non-empty name.
4885
+ const newFolderBtn = document.createElement("button");
4886
+ newFolderBtn.className = "btn btn-secondary btn-sm dp-newfolder-btn";
4887
+ newFolderBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg><span>${t("sib.dir.newFolder", "新建文件夹")}</span>`;
4888
+ newFolderBtn.addEventListener("click", () => beginCreateFolder());
4889
+
4890
+ function beginCreateFolder() {
4891
+ const parentDir = (pathInput.value || "").trim();
4892
+ if (!parentDir.startsWith("/")) {
4893
+ alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", "invalid parent path"));
4894
+ return;
4895
+ }
4896
+
4897
+ // Locate the parent node in the current tree, if it's rendered.
4898
+ // The path input may point at any expanded descendant of the
4899
+ // tree root, so we can't assume parentDir == tree root.
4900
+ // Walk every rendered .dp-node and match by entry.absPath.
4901
+ let parentNode = null;
4902
+ treeContainer.querySelectorAll(".dp-node").forEach(n => {
4903
+ if (parentNode) return;
4904
+ if (n._dpEntry && n._dpEntry.absPath === parentDir) parentNode = n;
4905
+ });
4906
+
4907
+ // Pick where to insert + what depth the placeholder lives at.
4908
+ // - Tree root (parentDir is the root) → depth 0, host = treeContainer
4909
+ // - Rendered subtree → depth = parent.depth + 1,
4910
+ // host = parent's .dp-children
4911
+ let host, depth, parentRow, parentCaret, parentChildren;
4912
+ if (parentNode) {
4913
+ parentRow = parentNode._dpRow;
4914
+ parentCaret = parentRow.querySelector(".dp-caret");
4915
+ parentChildren = parentNode.querySelector(":scope > .dp-children");
4916
+ depth = (parseInt(parentNode.dataset.depth, 10) || 0) + 1;
4917
+ host = parentChildren;
4918
+ } else {
4919
+ depth = 0;
4920
+ host = treeContainer;
4921
+ }
4922
+
4923
+ // Build a placeholder node and seed it with an inline editor.
4924
+ const placeholder = document.createElement("div");
4925
+ placeholder.className = "dp-node dp-node-pending";
4926
+ placeholder.dataset.depth = depth;
4927
+
4928
+ const row = document.createElement("div");
4929
+ row.className = "dp-row dp-row-editing";
4930
+ row.style.paddingLeft = `${depth * 16 + 8}px`;
4931
+
4932
+ const caret = document.createElement("span");
4933
+ caret.className = "dp-caret";
4934
+ caret.innerHTML = ICON_CARET_SVG;
4935
+
4936
+ const icon = document.createElement("span");
4937
+ icon.className = "dp-icon";
4938
+ icon.innerHTML = ICON_FOLDER_SVG;
4939
+
4940
+ const nameSpan = document.createElement("span");
4941
+ nameSpan.className = "dp-name";
4942
+
4943
+ row.appendChild(caret);
4944
+ row.appendChild(icon);
4945
+ row.appendChild(nameSpan);
4946
+ placeholder.appendChild(row);
4947
+
4948
+ // If we're inserting under a parent node, make sure the parent is
4949
+ // visually expanded so the user sees the placeholder. Mark the
4950
+ // children container as loaded so we don't trigger a refetch on
4951
+ // first toggleExpand (we'll keep our placeholder + inserted node).
4952
+ if (parentNode) {
4953
+ parentCaret.classList.add("open");
4954
+ parentChildren.style.display = "flex";
4955
+ // Drop "loading" / "empty" / "error" stub if present.
4956
+ const stub = parentChildren.querySelector(".dp-empty, .dp-loading, .dp-error");
4957
+ if (stub) stub.remove();
4958
+ parentChildren.dataset.loaded = "1";
4959
+ parentChildren.insertBefore(placeholder, parentChildren.firstChild);
4960
+ } else {
4961
+ // Root level: drop the empty/loading/error stub, then prepend.
4962
+ const emptyEl = treeContainer.querySelector(":scope > .dp-empty, :scope > .dp-loading, :scope > .dp-error");
4963
+ if (emptyEl) emptyEl.remove();
4964
+ treeContainer.insertBefore(placeholder, treeContainer.firstChild);
4965
+ }
4966
+
4967
+ startInlineEdit(nameSpan, t("sib.dir.newFolderDefault", "New Folder"), {
4968
+ onCommit: async (newName) => {
4969
+ const trimmed = (newName || "").trim();
4970
+ if (!trimmed) { placeholder.remove(); return; }
4971
+ try {
4972
+ const resp = await fetch("/api/dirs/mkdir", {
4973
+ method: "POST",
4974
+ headers: { "Content-Type": "application/json" },
4975
+ body: JSON.stringify({ parent: parentDir, name: trimmed })
4976
+ });
4977
+ const data = await resp.json().catch(() => ({}));
4978
+ if (!resp.ok || !data.ok) {
4979
+ alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}")
4980
+ .replace("{{msg}}", data.error || `HTTP ${resp.status}`));
4981
+ placeholder.remove();
4982
+ return;
4983
+ }
4984
+ // Replace placeholder with a real, fully-wired node so it can
4985
+ // be expanded / right-clicked exactly like any sibling.
4986
+ const realEntry = {
4987
+ name: data.name,
4988
+ path: data.path,
4989
+ absPath: data.path,
4990
+ absolute: true,
4991
+ type: "dir"
4992
+ };
4993
+ const realNode = buildDirNode(realEntry, depth);
4994
+ placeholder.replaceWith(realNode);
4995
+ // Auto-select the new folder so the path input updates.
4996
+ modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
4997
+ realNode._dpRow.classList.add("selected");
4998
+ selectedPath = data.path;
4999
+ pathInput.value = data.path;
5000
+ refreshUpBtn();
5001
+ } catch (err) {
5002
+ alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", err.message || String(err)));
5003
+ placeholder.remove();
5004
+ }
5005
+ },
5006
+ onCancel: () => placeholder.remove()
5007
+ });
5008
+ }
5009
+
5010
+ // ── Inline rename editor (shared by mkdir + rename) ───────────────
5011
+ // Replaces a name span with an <input> seeded by `initialValue`,
5012
+ // commits on Enter/blur, cancels on Escape. The caller wires the
5013
+ // backend round-trip via onCommit (it receives the new name string).
5014
+ function startInlineEdit(nameSpan, initialValue, { onCommit, onCancel }) {
5015
+ const input = document.createElement("input");
5016
+ input.type = "text";
5017
+ input.className = "dp-name-input";
5018
+ input.value = initialValue;
5019
+ input.spellcheck = false;
5020
+ input.autocomplete = "off";
5021
+ input.setAttribute("autocapitalize", "off");
5022
+
5023
+ // Stop click-through so editing doesn't trigger row selection /
5024
+ // expand / collapse.
5025
+ ["click", "dblclick", "mousedown"].forEach(ev =>
5026
+ input.addEventListener(ev, (e) => e.stopPropagation())
5027
+ );
5028
+
5029
+ // Replace span with input. Track via parent so we can swap back.
5030
+ const parent = nameSpan.parentNode;
5031
+ parent.replaceChild(input, nameSpan);
5032
+ // Defer focus until the next tick so the caller's surrounding DOM
5033
+ // mutations (e.g. inserting a placeholder row) finish first.
5034
+ setTimeout(() => {
5035
+ input.focus();
5036
+ input.select();
5037
+ }, 0);
5038
+
5039
+ let finished = false;
5040
+ const finish = (commit) => {
5041
+ if (finished) return;
5042
+ finished = true;
5043
+ const value = input.value;
5044
+ if (input.parentNode === parent) parent.replaceChild(nameSpan, input);
5045
+ if (commit) {
5046
+ try { onCommit && onCommit(value); } catch (e) { console.error(e); }
5047
+ } else {
5048
+ try { onCancel && onCancel(); } catch (e) { console.error(e); }
5049
+ }
5050
+ };
5051
+
5052
+ // Enter commits; Escape cancels. Use IME.bindEnter so that the
5053
+ // Enter that confirms a Chinese / Japanese / Korean IME candidate
5054
+ // does NOT commit the rename. Mirrors Sessions._startRename.
5055
+ IME.bindEnter(input, () => finish(true));
5056
+ input.addEventListener("keydown", (e) => {
5057
+ if (e.key === "Escape") { e.preventDefault(); finish(false); }
5058
+ });
5059
+ input.addEventListener("blur", () => finish(true));
5060
+ }
5061
+
4812
5062
  function setupPresets() {
4813
5063
  presets.innerHTML = "";
4814
5064
  presets.appendChild(upBtn);
4815
5065
  const presetDirs = sessionLess
4816
5066
  ? [
4817
- { value: homeDir, text: t("sib.dir.home", "主目录"), absolute: true },
4818
- { value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
5067
+ { value: defaultDir, text: t("sib.dir.default", "默认工作目录"), absolute: true },
5068
+ { value: homeDir, text: t("sib.dir.home", "主目录"), absolute: true },
5069
+ { value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
4819
5070
  ]
4820
5071
  : [
4821
- { value: rootDir, text: t("sib.dir.current", "当前工作目录"), absolute: false },
4822
- { value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
4823
- ]; presetDirs.forEach(p => {
5072
+ { value: defaultDir, text: t("sib.dir.default", "默认工作目录"), absolute: true },
5073
+ { value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
5074
+ ];
5075
+ presetDirs.forEach(p => {
5076
+ // Skip preset whose absolute path isn't known yet (defensive: the
5077
+ // backend always returns it, but a malformed deploy shouldn't render
5078
+ // a button that navigates to "").
5079
+ if (!p.value) return;
4824
5080
  const btn = document.createElement("button");
4825
5081
  btn.className = "btn btn-secondary btn-sm";
4826
5082
  btn.textContent = p.text;
@@ -4832,6 +5088,8 @@ const Sessions = (() => {
4832
5088
  });
4833
5089
  presets.appendChild(btn);
4834
5090
  });
5091
+ // "+ New folder" trailing action on the same toolbar.
5092
+ presets.appendChild(newFolderBtn);
4835
5093
  }
4836
5094
 
4837
5095
  // Path input