openclacky 1.3.2 → 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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/docs/rich_ui_guide.md +277 -0
  7. data/docs/rich_ui_refactor_plan.md +396 -0
  8. data/lib/clacky/agent/llm_caller.rb +10 -4
  9. data/lib/clacky/agent/session_serializer.rb +52 -7
  10. data/lib/clacky/agent/time_machine.rb +247 -26
  11. data/lib/clacky/agent.rb +15 -3
  12. data/lib/clacky/api_extension.rb +262 -0
  13. data/lib/clacky/api_extension_loader.rb +156 -0
  14. data/lib/clacky/cli.rb +93 -3
  15. data/lib/clacky/client.rb +38 -13
  16. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  17. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  18. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  19. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  20. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  21. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  22. data/lib/clacky/default_skills/media-gen/SKILL.md +37 -10
  23. data/lib/clacky/idle_compression_timer.rb +3 -1
  24. data/lib/clacky/locales/en.rb +26 -0
  25. data/lib/clacky/locales/i18n.rb +26 -0
  26. data/lib/clacky/locales/zh.rb +26 -0
  27. data/lib/clacky/media/openai_compat.rb +64 -1
  28. data/lib/clacky/message_history.rb +9 -0
  29. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  30. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  31. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  32. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  33. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  34. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  35. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  36. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  37. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  38. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  39. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  41. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  42. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  43. data/lib/clacky/rich_ui.rb +57 -0
  44. data/lib/clacky/rich_ui_controller.rb +3 -1549
  45. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  46. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  47. data/lib/clacky/server/git_panel.rb +115 -0
  48. data/lib/clacky/server/http_server.rb +547 -15
  49. data/lib/clacky/server/server_master.rb +6 -4
  50. data/lib/clacky/server/session_registry.rb +1 -1
  51. data/lib/clacky/shell_hook_loader.rb +1 -1
  52. data/lib/clacky/tools/edit.rb +14 -2
  53. data/lib/clacky/ui2/ui_controller.rb +7 -0
  54. data/lib/clacky/version.rb +1 -1
  55. data/lib/clacky/web/app.css +511 -101
  56. data/lib/clacky/web/app.js +95 -14
  57. data/lib/clacky/web/components/code-editor.js +197 -0
  58. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  59. data/lib/clacky/web/{onboard.js → components/onboard.js} +18 -2
  60. data/lib/clacky/web/core/aside.js +117 -0
  61. data/lib/clacky/web/core/ext.js +387 -0
  62. data/lib/clacky/web/features/backup/store.js +92 -0
  63. data/lib/clacky/web/features/backup/view.js +94 -0
  64. data/lib/clacky/web/features/billing/store.js +163 -0
  65. data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
  66. data/lib/clacky/web/features/brand/store.js +110 -0
  67. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  68. data/lib/clacky/web/features/channels/store.js +103 -0
  69. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  70. data/lib/clacky/web/features/creator/store.js +81 -0
  71. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  72. data/lib/clacky/web/features/mcp/store.js +158 -0
  73. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  74. data/lib/clacky/web/features/model-tester/store.js +77 -0
  75. data/lib/clacky/web/features/model-tester/view.js +7 -0
  76. data/lib/clacky/web/features/profile/store.js +170 -0
  77. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  78. data/lib/clacky/web/features/share/store.js +145 -0
  79. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  80. data/lib/clacky/web/features/skills/store.js +331 -0
  81. data/lib/clacky/web/features/skills/view.js +581 -0
  82. data/lib/clacky/web/features/tasks/store.js +135 -0
  83. data/lib/clacky/web/features/tasks/view.js +241 -0
  84. data/lib/clacky/web/features/trash/store.js +242 -0
  85. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  86. data/lib/clacky/web/features/version/store.js +165 -0
  87. data/lib/clacky/web/features/version/view.js +323 -0
  88. data/lib/clacky/web/features/workspace/store.js +99 -0
  89. data/lib/clacky/web/features/workspace/view.js +305 -0
  90. data/lib/clacky/web/i18n.js +68 -6
  91. data/lib/clacky/web/index.html +113 -62
  92. data/lib/clacky/web/sessions.js +493 -39
  93. data/lib/clacky/web/settings.js +143 -49
  94. data/lib/clacky/web/skills.js +3 -863
  95. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  96. data/lib/clacky/web/ws-dispatcher.js +7 -3
  97. data/lib/clacky.rb +17 -1
  98. metadata +81 -20
  99. data/lib/clacky/web/backup.js +0 -119
  100. data/lib/clacky/web/model-tester.js +0 -66
  101. data/lib/clacky/web/tasks.js +0 -373
  102. data/lib/clacky/web/version.js +0 -449
  103. data/lib/clacky/web/workspace.js +0 -316
  104. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  105. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  106. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  107. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -29,6 +29,12 @@ const Sessions = (() => {
29
29
  // Search results live in their own list, rendered into the overlay's
30
30
  // #session-search-results — they NEVER replace the sidebar session list.
31
31
  let _searchResults = [];
32
+ // Sessions resolved by id but not in the paged sidebar list — e.g. landed
33
+ // here via search-result click, URL deep link, share link, browser
34
+ // back/forward, or external notification jump. Acts as a local cache for
35
+ // `findOrFetch`. Excluded from sidebar render and from the loadMore cursor
36
+ // so the pagination of `_sessions` stays correct.
37
+ const _extraSessions = [];
32
38
  // Active search result split when _filter.q is non-empty:
33
39
  // { nameIds: Set<id>, contentIds: Set<id>, contentLoaded: bool }
34
40
  let _searchSplit = null;
@@ -118,6 +124,16 @@ const Sessions = (() => {
118
124
 
119
125
  let html;
120
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
+
121
137
  const renderer = new marked.Renderer();
122
138
  renderer.link = function({ href, title, text }) {
123
139
  const titleAttr = title ? ` title="${title}"` : "";
@@ -126,6 +142,7 @@ const Sessions = (() => {
126
142
  // Override code block rendering: apply syntax highlighting + header with
127
143
  // language label and copy button.
128
144
  renderer.code = function({ text: code, lang }) {
145
+ code = restoreMathInCode(code);
129
146
  const language = (lang || "").split(/\s+/)[0]; // strip extra info after lang
130
147
  const highlighted = _highlightCode(code, language);
131
148
  const displayLang = language || "text";
@@ -146,6 +163,10 @@ const Sessions = (() => {
146
163
  `</div>`
147
164
  );
148
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
+ };
149
170
  try {
150
171
  html = marked.parse(prepared, { breaks: true, gfm: true, renderer });
151
172
  } catch (_) {
@@ -157,7 +178,11 @@ const Sessions = (() => {
157
178
  }
158
179
 
159
180
  if (math.length) {
160
- 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
+ });
161
186
  }
162
187
  return html;
163
188
  }
@@ -181,22 +206,30 @@ const Sessions = (() => {
181
206
 
182
207
  // Pull $$...$$, \[...\], $...$, \(...\) out of `text` and replace each with a
183
208
  // sentinel placeholder so marked won't mangle the LaTeX source. The matched
184
- // 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).
185
218
  function _extractMath(text, out, placeholder) {
186
219
  // Order matters: longest/most-specific delimiters first.
187
220
  const patterns = [
188
- { re: /\$\$([\s\S]+?)\$\$/g, display: true },
189
- { re: /\\\[([\s\S]+?)\\\]/g, display: true },
190
- { 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}\\)` },
191
224
  // Inline $...$: avoid $$, escaped \$, and prevent crossing newlines/blanks.
192
- { re: /(^|[^\$])\$(?!\s)([^\$\n]+?)(?<!\s)\$(?!\d)/g, display: false, hasPrefix: true },
225
+ { re: /(^|[^\$])\$(?!\s)([^\$\n]+?)(?<!\s)\$(?!\d)/g, display: false, hasPrefix: true, wrap: (b) => `$${b}$` },
193
226
  ];
194
227
  let result = text;
195
- for (const { re, display, hasPrefix } of patterns) {
228
+ for (const { re, display, hasPrefix, wrap } of patterns) {
196
229
  result = result.replace(re, (m, a, b) => {
197
230
  const body = hasPrefix ? b : a;
198
231
  const idx = out.length;
199
- out.push({ body, display });
232
+ out.push({ body, display, raw: wrap(body) });
200
233
  return (hasPrefix ? a : "") + placeholder(idx);
201
234
  });
202
235
  }
@@ -821,7 +854,7 @@ const Sessions = (() => {
821
854
  _imageSeq = 0;
822
855
  _renderAttachmentPreviews();
823
856
 
824
- WS.send({ type: "message", session_id: Sessions.activeId, content, files });
857
+ WS.send({ type: "message", session_id: Sessions.activeId, content, files, lang: I18n.lang() });
825
858
 
826
859
  // Disable any pending feedback cards — user has replied (either by clicking
827
860
  // an option button or by typing directly). The backend has already consumed
@@ -1353,8 +1386,13 @@ const Sessions = (() => {
1353
1386
  }
1354
1387
  bubbleHtml += escapeHtml(ev.content || "");
1355
1388
  el.innerHTML = bubbleHtml;
1356
- _appendMsgTime(el, ev.created_at);
1357
- container.appendChild(el);
1389
+ if (ev.created_at) el.dataset.createdAt = ev.created_at;
1390
+ const wrap = document.createElement("div");
1391
+ wrap.className = "msg-user-wrap";
1392
+ wrap.appendChild(el);
1393
+ _appendUserActionBar(el, wrap);
1394
+ _appendMsgTime(wrap, ev.created_at);
1395
+ container.appendChild(wrap);
1358
1396
  break;
1359
1397
  }
1360
1398
 
@@ -1605,6 +1643,7 @@ const Sessions = (() => {
1605
1643
  // If no more history remains, insert a "beginning of conversation" marker at the top.
1606
1644
  // Remove any existing marker first to avoid duplicates.
1607
1645
  messages.querySelector(".history-start-marker")?.remove();
1646
+ _refreshEditButtons(messages);
1608
1647
  if (!state.hasMore) {
1609
1648
  const marker = document.createElement("div");
1610
1649
  marker.className = "history-start-marker";
@@ -1652,9 +1691,11 @@ const Sessions = (() => {
1652
1691
  if (!createdAt) return I18n.t("sessions.untitled") || "Untitled";
1653
1692
  const d = new Date(createdAt);
1654
1693
  const now = new Date();
1655
- const diffDays = Math.floor((now - d) / 86400000);
1656
1694
  const pad = n => String(n).padStart(2, "0");
1657
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);
1658
1699
  if (diffDays === 0) return `Today ${hhmm}`;
1659
1700
  if (diffDays === 1) return `Yesterday ${hhmm}`;
1660
1701
  return `${d.getMonth() + 1}/${d.getDate()} ${hhmm}`;
@@ -1694,6 +1735,169 @@ const Sessions = (() => {
1694
1735
  el.appendChild(span);
1695
1736
  }
1696
1737
 
1738
+ // ── User message action bar (copy + edit) ───────────────────────────────
1739
+
1740
+ const COPY_SVG = `<svg class="msg-user-copy-icon" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
1741
+ `<path fill="currentColor" d="M10 1H4a2 2 0 0 0-2 2v8h1.5V3a.5.5 0 0 1 .5-.5h6V1zm3 3H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm.5 10a.5.5 0 0 1-.5.5H6a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5v8z"/>` +
1742
+ `</svg>` +
1743
+ `<svg class="msg-user-copy-icon-check" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
1744
+ `<path fill="currentColor" d="M13.5 3.5 6 11 2.5 7.5 1 9l5 5 9-9z"/>` +
1745
+ `</svg>`;
1746
+
1747
+ const EDIT_SVG = `<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
1748
+ `<path fill="currentColor" d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354l-1.086-1.086zM11.189 6.25 9.75 4.81l-6.286 6.287a.25.25 0 0 0-.064.108l-.558 1.953 1.953-.558a.249.249 0 0 0 .108-.064l6.286-6.286z"/>` +
1749
+ `</svg>`;
1750
+
1751
+ function _appendUserActionBar(el, wrap) {
1752
+ el.dataset.originalHtml = el.innerHTML;
1753
+
1754
+ const bar = document.createElement("div");
1755
+ bar.className = "msg-user-actions";
1756
+
1757
+ const copyBtn = document.createElement("button");
1758
+ copyBtn.type = "button";
1759
+ copyBtn.className = "msg-user-action-btn";
1760
+ copyBtn.setAttribute("aria-label", I18n.t("chat.copy"));
1761
+ copyBtn.title = I18n.t("chat.copy");
1762
+ copyBtn.innerHTML = COPY_SVG;
1763
+ copyBtn.addEventListener("click", (e) => {
1764
+ e.stopPropagation();
1765
+ const text = _extractUserBubbleText(el);
1766
+ _copyTextAndFlash(copyBtn, text);
1767
+ });
1768
+
1769
+ const editBtn = document.createElement("button");
1770
+ editBtn.type = "button";
1771
+ editBtn.className = "msg-user-action-btn msg-edit-btn";
1772
+ editBtn.setAttribute("aria-label", I18n.t("chat.edit"));
1773
+ editBtn.title = I18n.t("chat.edit");
1774
+ editBtn.innerHTML = EDIT_SVG;
1775
+ editBtn.addEventListener("click", async (e) => {
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;
1783
+ _enterEditMode(el);
1784
+ });
1785
+
1786
+ bar.appendChild(copyBtn);
1787
+ bar.appendChild(editBtn);
1788
+ wrap.appendChild(bar);
1789
+ }
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
+
1798
+ function _extractUserBubbleText(el) {
1799
+ const clone = el.cloneNode(true);
1800
+ clone.querySelectorAll(".msg-user-actions, .msg-time").forEach(n => n.remove());
1801
+ return (clone.textContent || "").trim();
1802
+ }
1803
+
1804
+ function _enterEditMode(el) {
1805
+ if (el.classList.contains("editing")) return;
1806
+ el.classList.add("editing");
1807
+
1808
+ const originalHtml = el.dataset.originalHtml || "";
1809
+ const originalText = (() => {
1810
+ const tmp = document.createElement("div");
1811
+ tmp.innerHTML = originalHtml;
1812
+ tmp.querySelectorAll(".msg-user-actions, .msg-time, .msg-pdf-badge, img").forEach(n => n.remove());
1813
+ return (tmp.textContent || "").trim();
1814
+ })();
1815
+
1816
+ el.innerHTML = "";
1817
+
1818
+ const wrap = document.createElement("div");
1819
+ wrap.className = "msg-user-edit-wrap";
1820
+
1821
+ const textarea = document.createElement("textarea");
1822
+ textarea.className = "msg-user-edit-textarea";
1823
+ textarea.value = originalText;
1824
+ textarea.rows = 1;
1825
+
1826
+ const actions = document.createElement("div");
1827
+ actions.className = "msg-user-edit-actions";
1828
+
1829
+ const cancelBtn = document.createElement("button");
1830
+ cancelBtn.type = "button";
1831
+ cancelBtn.className = "msg-user-edit-cancel";
1832
+ cancelBtn.textContent = I18n.t("chat.cancel");
1833
+ cancelBtn.addEventListener("click", () => _exitEditMode(el));
1834
+
1835
+ const sendBtn = document.createElement("button");
1836
+ sendBtn.type = "button";
1837
+ sendBtn.className = "msg-user-edit-send";
1838
+ sendBtn.textContent = I18n.t("chat.send");
1839
+ sendBtn.addEventListener("click", () => _submitEdit(el, textarea.value.trim()));
1840
+
1841
+ textarea.addEventListener("keydown", (e) => {
1842
+ if (e.key === "Enter" && !e.shiftKey) {
1843
+ e.preventDefault();
1844
+ _submitEdit(el, textarea.value.trim());
1845
+ }
1846
+ if (e.key === "Escape") _exitEditMode(el);
1847
+ });
1848
+
1849
+ textarea.addEventListener("input", () => {
1850
+ textarea.style.height = "auto";
1851
+ textarea.style.height = textarea.scrollHeight + "px";
1852
+ });
1853
+
1854
+ actions.appendChild(cancelBtn);
1855
+ actions.appendChild(sendBtn);
1856
+ wrap.appendChild(textarea);
1857
+ wrap.appendChild(actions);
1858
+ el.appendChild(wrap);
1859
+
1860
+ requestAnimationFrame(() => {
1861
+ textarea.style.height = textarea.scrollHeight + "px";
1862
+ textarea.focus();
1863
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
1864
+ });
1865
+ }
1866
+
1867
+ function _exitEditMode(el, newContent) {
1868
+ el.classList.remove("editing");
1869
+ if (newContent) {
1870
+ el.dataset.originalHtml = escapeHtml(newContent);
1871
+ el.innerHTML = escapeHtml(newContent);
1872
+ } else {
1873
+ el.innerHTML = el.dataset.originalHtml || "";
1874
+ }
1875
+ }
1876
+
1877
+ function _submitEdit(el, newContent) {
1878
+ if (!newContent) return;
1879
+ if (!Sessions.activeId) return;
1880
+
1881
+ const createdAt = el.dataset.createdAt || null;
1882
+
1883
+ const messages = el.closest("#messages, .messages");
1884
+ if (messages) {
1885
+ const wrap = el.parentElement;
1886
+ let sibling = wrap ? wrap.nextSibling : el.nextSibling;
1887
+ while (sibling) {
1888
+ const next = sibling.nextSibling;
1889
+ sibling.remove();
1890
+ sibling = next;
1891
+ }
1892
+ }
1893
+
1894
+ _exitEditMode(el, newContent);
1895
+
1896
+ WS.send({ type: "edit_message", session_id: Sessions.activeId, content: newContent, created_at: createdAt });
1897
+
1898
+ if (messages) messages.scrollTop = messages.scrollHeight;
1899
+ }
1900
+
1697
1901
  // ── Copy button for assistant messages ──────────────────────────────────
1698
1902
  //
1699
1903
  // Each assistant bubble gets a small copy button in its top-right corner.
@@ -2017,7 +2221,41 @@ const Sessions = (() => {
2017
2221
  get all() { return _sessions; },
2018
2222
  get activeId() { return _activeId; },
2019
2223
  get searchOpen() { return _searchOpen; },
2020
- find: id => _sessions.find(s => s.id === id),
2224
+ find: id => _sessions.find(s => s.id === id)
2225
+ || _extraSessions.find(s => s.id === id),
2226
+
2227
+ // Async variant of `find`: when not found in memory, falls back to
2228
+ // GET /api/sessions/:id which returns the on-disk session merged with
2229
+ // any live in-memory state (see SessionRegistry#snapshot in
2230
+ // session_registry.rb). Resolved rows are cached in `_extraSessions`
2231
+ // so subsequent synchronous `find` calls hit too. Returns null on
2232
+ // 404 / network error.
2233
+ //
2234
+ // Use this in code paths where missing-id should NOT silently fail
2235
+ // (Router navigation: search clicks, URL deep links, share links,
2236
+ // browser back/forward, notification jumps). For tight synchronous
2237
+ // paths (WS dispatch, status updates) keep using `find`.
2238
+ async findOrFetch(id) {
2239
+ if (!id) return null;
2240
+ const local = _sessions.find(s => s.id === id)
2241
+ || _extraSessions.find(s => s.id === id);
2242
+ if (local) return local;
2243
+ try {
2244
+ const resp = await fetch(`/api/sessions/${encodeURIComponent(id)}`);
2245
+ if (!resp.ok) return null;
2246
+ const data = await resp.json();
2247
+ if (!data || !data.session) return null;
2248
+ // Race guard: another caller may have hydrated meanwhile.
2249
+ if (!_sessions.find(s => s.id === id)
2250
+ && !_extraSessions.find(s => s.id === id)) {
2251
+ _extraSessions.push(data.session);
2252
+ }
2253
+ return data.session;
2254
+ } catch (e) {
2255
+ console.error("Sessions.findOrFetch failed:", e);
2256
+ return null;
2257
+ }
2258
+ },
2021
2259
 
2022
2260
  // Composer entry point — called by Skill autocomplete keydown handler
2023
2261
  // (in app.js) when the user presses Enter without an active completion.
@@ -3238,6 +3476,16 @@ const Sessions = (() => {
3238
3476
  }
3239
3477
  },
3240
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
+
3241
3489
  appendMsg(type, html, { time } = {}) {
3242
3490
  // Starting a new assistant/user/info message: close any open tool group
3243
3491
  if (type !== "tool") Sessions.collapseToolGroup();
@@ -3263,28 +3511,34 @@ const Sessions = (() => {
3263
3511
  } else {
3264
3512
  el.innerHTML = html;
3265
3513
  }
3266
- if (type === "user" && time) _appendMsgTime(el, time);
3267
3514
 
3268
- // For error messages, add a retry button
3269
- if (type === "error") {
3270
- const retryBtn = document.createElement("button");
3271
- retryBtn.className = "retry-btn";
3272
- retryBtn.textContent = I18n.t("chat.retry");
3273
- retryBtn.onclick = () => {
3274
- if (!_activeId) return;
3275
- // Send "continue" or "继续" based on user's language preference
3276
- const retryMessage = I18n.lang() === "zh" ? "继续" : "continue";
3277
- WS.send({
3278
- type: "message",
3279
- session_id: _activeId,
3280
- content: retryMessage
3281
- });
3282
- retryBtn.disabled = true; // Disable button after clicking (keep it visible)
3283
- };
3284
- el.appendChild(retryBtn);
3515
+ if (type === "user") {
3516
+ const wrap = document.createElement("div");
3517
+ wrap.className = "msg-user-wrap";
3518
+ wrap.appendChild(el);
3519
+ _appendUserActionBar(el, wrap);
3520
+ if (time) _appendMsgTime(wrap, time);
3521
+ messages.appendChild(wrap);
3522
+ _refreshEditButtons(messages);
3523
+ } else {
3524
+ // For error messages, add a retry button
3525
+ if (type === "error") {
3526
+ const retryBtn = document.createElement("button");
3527
+ retryBtn.className = "retry-btn";
3528
+ retryBtn.textContent = I18n.t("chat.retry");
3529
+ retryBtn.onclick = () => {
3530
+ if (!_activeId) return;
3531
+ WS.send({
3532
+ type: "message",
3533
+ session_id: _activeId,
3534
+ content: I18n.t("chat.continue")
3535
+ });
3536
+ retryBtn.disabled = true;
3537
+ };
3538
+ el.appendChild(retryBtn);
3539
+ }
3540
+ messages.appendChild(el);
3285
3541
  }
3286
-
3287
- messages.appendChild(el);
3288
3542
  // User messages: force scroll to bottom (user just sent a message)
3289
3543
  // Assistant/info: conditional scroll (preserve position if user is viewing history)
3290
3544
  if (type === "user") {
@@ -4431,7 +4685,8 @@ const Sessions = (() => {
4431
4685
 
4432
4686
  let selectedPath = currentDir;
4433
4687
  let rootDir = ""; // absolute path of the session's working directory
4434
- 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)
4435
4690
  let showHidden = false;
4436
4691
 
4437
4692
  // Fetch directory entries from API, returns dirs with absolute paths
@@ -4445,6 +4700,7 @@ const Sessions = (() => {
4445
4700
  const data = await resp.json();
4446
4701
  rootDir = data.root || rootDir;
4447
4702
  homeDir = data.home || homeDir;
4703
+ defaultDir = data.default || defaultDir;
4448
4704
  const dirs = (data.entries || []).filter(e => e.type === "dir");
4449
4705
  dirs.forEach(d => { d.absPath = d.path; d.absolute = true; });
4450
4706
  return dirs;
@@ -4457,6 +4713,8 @@ const Sessions = (() => {
4457
4713
  const data = await resp.json();
4458
4714
  // Only update rootDir in relative mode; absolute mode would overwrite it with "/"
4459
4715
  if (!absolute) rootDir = data.root || rootDir;
4716
+ homeDir = data.home || homeDir;
4717
+ defaultDir = data.default || defaultDir;
4460
4718
  const dirs = (data.entries || []).filter(e => e.type === "dir");
4461
4719
  // Convert relative paths to absolute
4462
4720
  dirs.forEach(d => {
@@ -4522,6 +4780,12 @@ const Sessions = (() => {
4522
4780
  // Enter this directory - reload tree with this as root
4523
4781
  loadTreeForPath(entry.absPath, entry.absolute);
4524
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;
4525
4789
  return node;
4526
4790
  }
4527
4791
 
@@ -4613,18 +4877,206 @@ const Sessions = (() => {
4613
4877
  loadTreeForPath(parent, true);
4614
4878
  });
4615
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
+
4616
5062
  function setupPresets() {
4617
5063
  presets.innerHTML = "";
4618
5064
  presets.appendChild(upBtn);
4619
5065
  const presetDirs = sessionLess
4620
5066
  ? [
4621
- { value: homeDir, text: t("sib.dir.home", "主目录"), absolute: true },
4622
- { 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 }
4623
5070
  ]
4624
5071
  : [
4625
- { value: rootDir, text: t("sib.dir.current", "当前工作目录"), absolute: false },
4626
- { value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
4627
- ]; 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;
4628
5080
  const btn = document.createElement("button");
4629
5081
  btn.className = "btn btn-secondary btn-sm";
4630
5082
  btn.textContent = p.text;
@@ -4636,6 +5088,8 @@ const Sessions = (() => {
4636
5088
  });
4637
5089
  presets.appendChild(btn);
4638
5090
  });
5091
+ // "+ New folder" trailing action on the same toolbar.
5092
+ presets.appendChild(newFolderBtn);
4639
5093
  }
4640
5094
 
4641
5095
  // Path input