openclacky 1.3.3 → 1.3.5

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -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/fake_tool_call_detector.rb +52 -0
  6. data/lib/clacky/agent/llm_caller.rb +10 -4
  7. data/lib/clacky/agent/session_serializer.rb +5 -3
  8. data/lib/clacky/agent/tool_executor.rb +0 -12
  9. data/lib/clacky/agent.rb +77 -11
  10. data/lib/clacky/agent_config.rb +2 -14
  11. data/lib/clacky/api_extension.rb +343 -0
  12. data/lib/clacky/api_extension_loader.rb +168 -0
  13. data/lib/clacky/cli.rb +93 -3
  14. data/lib/clacky/client.rb +47 -25
  15. data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
  16. data/lib/clacky/default_agents/_panels/time_machine/panel.js +23 -1
  17. data/lib/clacky/default_agents/base_prompt.md +1 -0
  18. data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
  19. data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
  20. data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
  21. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
  22. data/lib/clacky/default_skills/media-gen/SKILL.md +72 -6
  23. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
  24. data/lib/clacky/idle_compression_timer.rb +3 -1
  25. data/lib/clacky/json_ui_controller.rb +1 -1
  26. data/lib/clacky/locales/en.rb +26 -0
  27. data/lib/clacky/locales/i18n.rb +26 -0
  28. data/lib/clacky/locales/zh.rb +26 -0
  29. data/lib/clacky/media/base.rb +60 -0
  30. data/lib/clacky/media/dashscope.rb +385 -21
  31. data/lib/clacky/media/gemini.rb +9 -0
  32. data/lib/clacky/media/generator.rb +52 -0
  33. data/lib/clacky/media/openai_compat.rb +166 -0
  34. data/lib/clacky/null_ui_controller.rb +13 -0
  35. data/lib/clacky/plain_ui_controller.rb +1 -1
  36. data/lib/clacky/providers.rb +50 -2
  37. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  38. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  39. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  40. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  41. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  42. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  43. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  44. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  45. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  46. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  47. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  48. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  49. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  50. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  51. data/lib/clacky/rich_ui.rb +57 -0
  52. data/lib/clacky/rich_ui_controller.rb +3 -1549
  53. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  54. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  55. data/lib/clacky/server/http_server.rb +291 -109
  56. data/lib/clacky/server/session_registry.rb +5 -3
  57. data/lib/clacky/server/web_ui_controller.rb +3 -2
  58. data/lib/clacky/shell_hook_loader.rb +1 -1
  59. data/lib/clacky/skill_loader.rb +14 -2
  60. data/lib/clacky/tools/edit.rb +14 -2
  61. data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
  62. data/lib/clacky/tools/terminal.rb +0 -43
  63. data/lib/clacky/ui2/components/modal_component.rb +1 -1
  64. data/lib/clacky/ui2/ui_controller.rb +147 -31
  65. data/lib/clacky/ui_interface.rb +10 -1
  66. data/lib/clacky/utils/encoding.rb +25 -0
  67. data/lib/clacky/version.rb +1 -1
  68. data/lib/clacky/web/app.css +201 -81
  69. data/lib/clacky/web/app.js +65 -7
  70. data/lib/clacky/web/components/onboard.js +19 -16
  71. data/lib/clacky/web/core/aside.js +8 -3
  72. data/lib/clacky/web/core/ext.js +1 -1
  73. data/lib/clacky/web/features/brand/view.js +8 -5
  74. data/lib/clacky/web/features/channels/store.js +1 -20
  75. data/lib/clacky/web/features/mcp/store.js +1 -20
  76. data/lib/clacky/web/features/profile/store.js +1 -13
  77. data/lib/clacky/web/features/profile/view.js +16 -4
  78. data/lib/clacky/web/features/skills/store.js +36 -23
  79. data/lib/clacky/web/features/skills/view.js +32 -1
  80. data/lib/clacky/web/features/version/store.js +2 -0
  81. data/lib/clacky/web/features/workspace/view.js +1 -1
  82. data/lib/clacky/web/i18n.js +48 -13
  83. data/lib/clacky/web/index.html +24 -17
  84. data/lib/clacky/web/sessions.js +427 -79
  85. data/lib/clacky/web/settings.js +143 -113
  86. data/lib/clacky/web/ws-dispatcher.js +18 -6
  87. data/lib/clacky.rb +27 -5
  88. metadata +45 -2
  89. 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
  }
@@ -384,42 +411,27 @@ const Sessions = (() => {
384
411
  _hideNewMessageBanner();
385
412
  });
386
413
 
387
- // Detect actual user scroll interactions (wheel, touch, keyboard)
388
- // These fire BEFORE the scroll event, so we can set the flag reliably.
389
- const detectUserScroll = (e) => {
390
- // Only flag if user is scrolling up (negative deltaY = scroll up)
391
- // For wheel events: deltaY < 0 means scroll up
392
- // For touch/keyboard: check scroll position in the scroll event
393
- const isWheelUp = e.type === "wheel" && e.deltaY < 0;
394
- const isKeyboardUp = e.type === "keydown" && (e.key === "ArrowUp" || e.key === "PageUp" || e.key === "Home");
395
-
396
- if (isWheelUp || isKeyboardUp) {
397
- _userScrolledUp = true;
398
- }
399
- };
400
-
401
- messages.addEventListener("wheel", detectUserScroll, { passive: true });
402
- messages.addEventListener("keydown", detectUserScroll);
403
-
404
- // For touch devices: touchmove doesn't tell us direction, so check in scroll event
405
- let touchStartY = 0;
406
- messages.addEventListener("touchstart", (e) => {
407
- touchStartY = e.touches[0].clientY;
408
- }, { passive: true });
409
-
410
- messages.addEventListener("touchmove", (e) => {
411
- const touchDeltaY = e.touches[0].clientY - touchStartY;
412
- // touchDeltaY > 0 means finger moved down = content scrolls up
413
- if (touchDeltaY > 5) {
414
- _userScrolledUp = true;
415
- }
416
- }, { passive: true });
417
-
418
- // Monitor scroll position: clear flag when user reaches bottom
414
+ // Single source of truth for "is the user browsing history?": the scroll
415
+ // position itself. Every scrolling method mouse wheel, dragging the
416
+ // scrollbar, keyboard (Up/PageUp/Home/Space), touch swipe and momentum
417
+ // scrolling funnels through the `scroll` event, so reading the position
418
+ // here covers them all with no blind spots.
419
+ //
420
+ // The previous approach instead listened to specific input events
421
+ // (wheel/keydown/touchmove) to *infer* intent. Dragging the scrollbar
422
+ // fires `scroll` but none of those, so it was never detected and AI
423
+ // messages kept yanking the view back to the bottom — see C-5629.
424
+ //
425
+ // Streaming-append safety: appending content grows scrollHeight but leaves
426
+ // scrollTop untouched (verified in-browser, even with overflow-anchor:auto),
427
+ // so a content update never moves us "away from bottom" and never trips a
428
+ // false positive here. The 150px threshold in _isAtBottom is extra slack.
419
429
  messages.addEventListener("scroll", () => {
420
430
  if (_isAtBottom(messages)) {
421
431
  _userScrolledUp = false;
422
432
  _hideNewMessageBanner();
433
+ } else {
434
+ _userScrolledUp = true;
423
435
  }
424
436
  });
425
437
  }
@@ -441,30 +453,26 @@ const Sessions = (() => {
441
453
  document.getElementById("btn-new-session-inline")
442
454
  .addEventListener("click", () => Sessions.create("general"));
443
455
 
444
- // Split button: arrow (toggle dropdown)
445
- document.getElementById("btn-new-session-arrow")
446
- .addEventListener("click", (e) => {
447
- e.stopPropagation();
448
- const dd = document.getElementById("new-session-dropdown");
449
- dd.hidden = !dd.hidden;
450
- });
456
+ // Split button: arrow show dropdown on hover, hide when leaving the whole wrap
457
+ const arrow = document.getElementById("btn-new-session-arrow");
458
+ const wrap = arrow.closest(".btn-split-wrap");
459
+ const dd = document.getElementById("new-session-dropdown");
451
460
 
452
- // Dropdown item "Advanced Options…" delegated because the dropdown
453
- // panel may be re-rendered; this keeps the binding stable.
461
+ arrow.addEventListener("mouseenter", () => { dd.hidden = false; });
462
+ dd.addEventListener("mouseenter", () => { dd.hidden = false; });
463
+ wrap.addEventListener("mouseleave", () => { dd.hidden = true; });
464
+ dd.addEventListener("mouseleave", () => { dd.hidden = true; });
465
+
466
+ // Dropdown item "Advanced Options…"
454
467
  document.addEventListener("click", (e) => {
455
468
  if (e.target && e.target.id === "btn-new-session-modal") {
456
- e.stopPropagation();
457
- document.getElementById("new-session-dropdown").hidden = true;
469
+ dd.hidden = true;
458
470
  Sessions.openNewSessionModal();
471
+ } else if (!wrap.contains(e.target)) {
472
+ dd.hidden = true;
459
473
  }
460
474
  });
461
475
 
462
- // Close dropdown when clicking anywhere else
463
- document.addEventListener("click", () => {
464
- const dd = document.getElementById("new-session-dropdown");
465
- if (dd && !dd.hidden) dd.hidden = true;
466
- });
467
-
468
476
  // Welcome screen "+ New Session" button
469
477
  document.getElementById("btn-welcome-new")
470
478
  .addEventListener("click", () => Sessions.create("general"));
@@ -827,7 +835,7 @@ const Sessions = (() => {
827
835
  _imageSeq = 0;
828
836
  _renderAttachmentPreviews();
829
837
 
830
- WS.send({ type: "message", session_id: Sessions.activeId, content, files });
838
+ WS.send({ type: "message", session_id: Sessions.activeId, content, files, lang: I18n.lang() });
831
839
 
832
840
  // Disable any pending feedback cards — user has replied (either by clicking
833
841
  // an option button or by typing directly). The backend has already consumed
@@ -1360,11 +1368,11 @@ const Sessions = (() => {
1360
1368
  bubbleHtml += escapeHtml(ev.content || "");
1361
1369
  el.innerHTML = bubbleHtml;
1362
1370
  if (ev.created_at) el.dataset.createdAt = ev.created_at;
1363
- _appendMsgTime(el, ev.created_at);
1364
1371
  const wrap = document.createElement("div");
1365
1372
  wrap.className = "msg-user-wrap";
1366
1373
  wrap.appendChild(el);
1367
1374
  _appendUserActionBar(el, wrap);
1375
+ _appendMsgTime(wrap, ev.created_at);
1368
1376
  container.appendChild(wrap);
1369
1377
  break;
1370
1378
  }
@@ -1377,6 +1385,7 @@ const Sessions = (() => {
1377
1385
  el.dataset.raw = ev.content || "";
1378
1386
  el.innerHTML = _renderMarkdown(ev.content || "");
1379
1387
  _appendCopyButton(el);
1388
+ _enhanceTaskItems(el, ev.content || "");
1380
1389
  container.appendChild(el);
1381
1390
  break;
1382
1391
  }
@@ -1616,6 +1625,7 @@ const Sessions = (() => {
1616
1625
  // If no more history remains, insert a "beginning of conversation" marker at the top.
1617
1626
  // Remove any existing marker first to avoid duplicates.
1618
1627
  messages.querySelector(".history-start-marker")?.remove();
1628
+ _refreshEditButtons(messages);
1619
1629
  if (!state.hasMore) {
1620
1630
  const marker = document.createElement("div");
1621
1631
  marker.className = "history-start-marker";
@@ -1639,6 +1649,7 @@ const Sessions = (() => {
1639
1649
  code: session.error_code,
1640
1650
  message: session.error,
1641
1651
  top_up_url: session.top_up_url,
1652
+ raw_message: session.raw_message,
1642
1653
  });
1643
1654
  } else {
1644
1655
  Sessions.appendMsg("error", session.error);
@@ -1663,9 +1674,11 @@ const Sessions = (() => {
1663
1674
  if (!createdAt) return I18n.t("sessions.untitled") || "Untitled";
1664
1675
  const d = new Date(createdAt);
1665
1676
  const now = new Date();
1666
- const diffDays = Math.floor((now - d) / 86400000);
1667
1677
  const pad = n => String(n).padStart(2, "0");
1668
1678
  const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
1679
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
1680
+ const dDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
1681
+ const diffDays = Math.round((today - dDay) / 86400000);
1669
1682
  if (diffDays === 0) return `Today ${hhmm}`;
1670
1683
  if (diffDays === 1) return `Yesterday ${hhmm}`;
1671
1684
  return `${d.getMonth() + 1}/${d.getDate()} ${hhmm}`;
@@ -1738,12 +1751,18 @@ const Sessions = (() => {
1738
1751
 
1739
1752
  const editBtn = document.createElement("button");
1740
1753
  editBtn.type = "button";
1741
- editBtn.className = "msg-user-action-btn";
1754
+ editBtn.className = "msg-user-action-btn msg-edit-btn";
1742
1755
  editBtn.setAttribute("aria-label", I18n.t("chat.edit"));
1743
1756
  editBtn.title = I18n.t("chat.edit");
1744
1757
  editBtn.innerHTML = EDIT_SVG;
1745
- editBtn.addEventListener("click", (e) => {
1758
+ editBtn.addEventListener("click", async (e) => {
1746
1759
  e.stopPropagation();
1760
+ const ok = await Modal.confirmOnce(
1761
+ "clacky-edit-warn-dismissed",
1762
+ I18n.t("chat.edit.warn"),
1763
+ I18n.t("chat.edit.warnSkip")
1764
+ );
1765
+ if (!ok) return;
1747
1766
  _enterEditMode(el);
1748
1767
  });
1749
1768
 
@@ -1752,6 +1771,13 @@ const Sessions = (() => {
1752
1771
  wrap.appendChild(bar);
1753
1772
  }
1754
1773
 
1774
+ function _refreshEditButtons(container) {
1775
+ const btns = Array.from(container.querySelectorAll(".msg-edit-btn"));
1776
+ btns.forEach((btn, i) => {
1777
+ btn.style.display = i === btns.length - 1 ? "" : "none";
1778
+ });
1779
+ }
1780
+
1755
1781
  function _extractUserBubbleText(el) {
1756
1782
  const clone = el.cloneNode(true);
1757
1783
  clone.querySelectorAll(".msg-user-actions, .msg-time").forEach(n => n.remove());
@@ -1821,9 +1847,14 @@ const Sessions = (() => {
1821
1847
  });
1822
1848
  }
1823
1849
 
1824
- function _exitEditMode(el) {
1850
+ function _exitEditMode(el, newContent) {
1825
1851
  el.classList.remove("editing");
1826
- el.innerHTML = el.dataset.originalHtml || "";
1852
+ if (newContent) {
1853
+ el.dataset.originalHtml = escapeHtml(newContent);
1854
+ el.innerHTML = escapeHtml(newContent);
1855
+ } else {
1856
+ el.innerHTML = el.dataset.originalHtml || "";
1857
+ }
1827
1858
  }
1828
1859
 
1829
1860
  function _submitEdit(el, newContent) {
@@ -1843,7 +1874,7 @@ const Sessions = (() => {
1843
1874
  }
1844
1875
  }
1845
1876
 
1846
- _exitEditMode(el);
1877
+ _exitEditMode(el, newContent);
1847
1878
 
1848
1879
  WS.send({ type: "edit_message", session_id: Sessions.activeId, content: newContent, created_at: createdAt });
1849
1880
 
@@ -1877,6 +1908,78 @@ const Sessions = (() => {
1877
1908
  _ensureCopyDelegation();
1878
1909
  }
1879
1910
 
1911
+ // System feature: any assistant message containing GFM task-list items
1912
+ // (`- [ ] ...`) gets a "spawn" button on each UNCHECKED item, turning a
1913
+ // todo into its own session. The new session's first prompt is the item
1914
+ // text plus the full message for context.
1915
+ function _stripThink(text) {
1916
+ if (!text) return "";
1917
+ return text.replace(/<think>[\s\S]*?<\/think>/g, "").replace(/^\s+/, "");
1918
+ }
1919
+
1920
+ function _enhanceTaskItems(el, rawText) {
1921
+ const seen = new Set();
1922
+ const context = _stripThink(rawText);
1923
+ el.querySelectorAll("li").forEach((li) => {
1924
+ const box = li.querySelector('input[type="checkbox"]');
1925
+ if (!box || box.checked) return; // only unchecked todos
1926
+ if (seen.has(li)) return;
1927
+ seen.add(li);
1928
+
1929
+ const text = li.textContent.trim();
1930
+ if (!text) return;
1931
+
1932
+ const btn = document.createElement("button");
1933
+ btn.type = "button";
1934
+ btn.className = "msg-todo-spawn";
1935
+ btn.title = I18n.t("chat.todo.spawn");
1936
+ btn.textContent = I18n.t("chat.todo.spawn");
1937
+ btn.dataset.todoText = text;
1938
+ btn.dataset.todoContext = context;
1939
+ li.appendChild(btn);
1940
+ });
1941
+ _ensureTodoSpawnDelegation();
1942
+ }
1943
+
1944
+ let _todoSpawnDelegationInstalled = false;
1945
+ function _ensureTodoSpawnDelegation() {
1946
+ if (_todoSpawnDelegationInstalled) return;
1947
+ const messages = RenderTarget.outer();
1948
+ if (!messages) return;
1949
+ _todoSpawnDelegationInstalled = true;
1950
+ messages.addEventListener("click", (e) => {
1951
+ const btn = e.target.closest(".msg-todo-spawn");
1952
+ if (!btn) return;
1953
+ e.preventDefault();
1954
+ e.stopPropagation();
1955
+ _spawnFromTodo(btn);
1956
+ });
1957
+ }
1958
+
1959
+ async function _spawnFromTodo(btn) {
1960
+ if (btn.disabled) return;
1961
+ btn.disabled = true;
1962
+ const original = btn.textContent;
1963
+ btn.textContent = I18n.t("chat.todo.spawning");
1964
+
1965
+ const todoText = btn.dataset.todoText || "";
1966
+ const context = btn.dataset.todoContext || "";
1967
+ const prompt =
1968
+ `Task:\n${todoText}\n\n` +
1969
+ `--- Context (the message this task came from) ---\n${context}`;
1970
+
1971
+ try {
1972
+ await Sessions.startWith(prompt, {
1973
+ name: todoText.slice(0, 60),
1974
+ display: `📋 ${todoText}`,
1975
+ });
1976
+ } catch (err) {
1977
+ btn.disabled = false;
1978
+ btn.textContent = original;
1979
+ alert(I18n.t("chat.todo.spawnFailed", { msg: err.message }));
1980
+ }
1981
+ }
1982
+
1880
1983
  // Install the click-delegation listener on #messages exactly once.
1881
1984
  // Handles copy clicks for all current AND future assistant bubbles
1882
1985
  // AND code block copy buttons.
@@ -2290,6 +2393,36 @@ const Sessions = (() => {
2290
2393
  }
2291
2394
  },
2292
2395
 
2396
+ // Create a session, queue a command to run once subscribed, and navigate to
2397
+ // it. The user bubble is rendered locally on the "subscribed" event (see
2398
+ // ws-dispatcher), so callers never touch history or WS timing.
2399
+ // Returns the created session.
2400
+ async startWith(command, { name, source = "setup", display = null } = {}) {
2401
+ if (!name) {
2402
+ const maxN = _sessions.reduce((max, s) => {
2403
+ const m = s.name && s.name.match(/^Session (\d+)$/);
2404
+ return m ? Math.max(max, parseInt(m[1], 10)) : max;
2405
+ }, 0);
2406
+ name = "Session " + (maxN + 1);
2407
+ }
2408
+
2409
+ const res = await fetch("/api/sessions", {
2410
+ method: "POST",
2411
+ headers: { "Content-Type": "application/json" },
2412
+ body: JSON.stringify({ name, source }),
2413
+ });
2414
+ const data = await res.json();
2415
+ if (!res.ok) throw new Error(data.error || "failed to create session");
2416
+ const session = data.session;
2417
+ if (!session) throw new Error("no session returned");
2418
+
2419
+ Sessions.add(session);
2420
+ Sessions.renderList();
2421
+ Sessions.setPendingMessage(session.id, command, display);
2422
+ Sessions.select(session.id);
2423
+ return session;
2424
+ },
2425
+
2293
2426
  /** Patch a single session's fields (from session_update event).
2294
2427
  * If the session is not in the list yet (e.g. just created by another tab),
2295
2428
  * prepend it so the sidebar shows it immediately. */
@@ -3428,6 +3561,16 @@ const Sessions = (() => {
3428
3561
  }
3429
3562
  },
3430
3563
 
3564
+ stampLastUserBubble(createdAt) {
3565
+ const messages = RenderTarget.outer();
3566
+ const wraps = messages.querySelectorAll(".msg-user-wrap");
3567
+ if (!wraps.length) return;
3568
+ const el = wraps[wraps.length - 1].querySelector(".msg-user");
3569
+ if (el) el.dataset.createdAt = createdAt;
3570
+ const dedup = _renderedCreatedAt[_activeId] || (_renderedCreatedAt[_activeId] = new Set());
3571
+ dedup.add(createdAt);
3572
+ },
3573
+
3431
3574
  appendMsg(type, html, { time } = {}) {
3432
3575
  // Starting a new assistant/user/info message: close any open tool group
3433
3576
  if (type !== "tool") Sessions.collapseToolGroup();
@@ -3450,17 +3593,19 @@ const Sessions = (() => {
3450
3593
  el.dataset.raw = html || "";
3451
3594
  el.innerHTML = _renderMarkdown(html);
3452
3595
  _appendCopyButton(el);
3596
+ _enhanceTaskItems(el, html || "");
3453
3597
  } else {
3454
3598
  el.innerHTML = html;
3455
3599
  }
3456
- if (type === "user" && time) _appendMsgTime(el, time);
3457
3600
 
3458
3601
  if (type === "user") {
3459
3602
  const wrap = document.createElement("div");
3460
3603
  wrap.className = "msg-user-wrap";
3461
3604
  wrap.appendChild(el);
3462
3605
  _appendUserActionBar(el, wrap);
3606
+ if (time) _appendMsgTime(wrap, time);
3463
3607
  messages.appendChild(wrap);
3608
+ _refreshEditButtons(messages);
3464
3609
  } else {
3465
3610
  // For error messages, add a retry button
3466
3611
  if (type === "error") {
@@ -3476,7 +3621,11 @@ const Sessions = (() => {
3476
3621
  });
3477
3622
  retryBtn.disabled = true;
3478
3623
  };
3624
+ // Move any .error-raw-detail to after the retry button
3625
+ const rawDetail = el.querySelector(".error-raw-detail");
3626
+ if (rawDetail) el.removeChild(rawDetail);
3479
3627
  el.appendChild(retryBtn);
3628
+ if (rawDetail) el.appendChild(rawDetail);
3480
3629
  }
3481
3630
  messages.appendChild(el);
3482
3631
  }
@@ -3869,8 +4018,8 @@ const Sessions = (() => {
3869
4018
  },
3870
4019
 
3871
4020
  /** Register a slash-command message to send after subscribe is confirmed. */
3872
- setPendingMessage(sessionId, content) {
3873
- _pendingMessage = { session_id: sessionId, content };
4021
+ setPendingMessage(sessionId, content, display = null) {
4022
+ _pendingMessage = { session_id: sessionId, content, display };
3874
4023
  },
3875
4024
 
3876
4025
  /** Consume and return the pending message (clears it). */
@@ -4613,7 +4762,7 @@ const Sessions = (() => {
4613
4762
  // ── Tree-based directory picker ─────────────────────────────────────────
4614
4763
  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
4764
  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) {
4765
+ function showDirectoryPicker(currentDir, sessionId) {
4617
4766
  return new Promise((resolve) => {
4618
4767
  const t = (key, fallback) => {
4619
4768
  const s = I18n.t(key);
@@ -4626,7 +4775,8 @@ const Sessions = (() => {
4626
4775
 
4627
4776
  let selectedPath = currentDir;
4628
4777
  let rootDir = ""; // absolute path of the session's working directory
4629
- let homeDir = ""; // user home, used as the "working directory" preset when session-less
4778
+ let homeDir = ""; // user home, used as a quick preset
4779
+ let defaultDir = ""; // default workspace from agent_config (or fallback)
4630
4780
  let showHidden = false;
4631
4781
 
4632
4782
  // Fetch directory entries from API, returns dirs with absolute paths
@@ -4640,6 +4790,7 @@ const Sessions = (() => {
4640
4790
  const data = await resp.json();
4641
4791
  rootDir = data.root || rootDir;
4642
4792
  homeDir = data.home || homeDir;
4793
+ defaultDir = data.default || defaultDir;
4643
4794
  const dirs = (data.entries || []).filter(e => e.type === "dir");
4644
4795
  dirs.forEach(d => { d.absPath = d.path; d.absolute = true; });
4645
4796
  return dirs;
@@ -4652,6 +4803,8 @@ const Sessions = (() => {
4652
4803
  const data = await resp.json();
4653
4804
  // Only update rootDir in relative mode; absolute mode would overwrite it with "/"
4654
4805
  if (!absolute) rootDir = data.root || rootDir;
4806
+ homeDir = data.home || homeDir;
4807
+ defaultDir = data.default || defaultDir;
4655
4808
  const dirs = (data.entries || []).filter(e => e.type === "dir");
4656
4809
  // Convert relative paths to absolute
4657
4810
  dirs.forEach(d => {
@@ -4717,6 +4870,12 @@ const Sessions = (() => {
4717
4870
  // Enter this directory - reload tree with this as root
4718
4871
  loadTreeForPath(entry.absPath, entry.absolute);
4719
4872
  });
4873
+
4874
+ // Make the row addressable for inline-edit invoked elsewhere
4875
+ // (e.g. from the "+ New folder" button placing its placeholder).
4876
+ node._dpEntry = entry;
4877
+ node._dpName = name;
4878
+ node._dpRow = row;
4720
4879
  return node;
4721
4880
  }
4722
4881
 
@@ -4765,10 +4924,9 @@ const Sessions = (() => {
4765
4924
  // Title
4766
4925
  const title = document.createElement("div");
4767
4926
  title.className = "modal-title";
4768
- title.textContent = titleText
4769
- || (sessionLess
4770
- ? t("sessions.modal.dirpicker.title", "选择工作目录")
4771
- : t("sib.dir.changePrompt", "切换工作目录"));
4927
+ title.textContent = sessionLess
4928
+ ? t("sessions.modal.dirpicker.title", "选择工作目录")
4929
+ : t("sib.dir.changePrompt", "切换工作目录");
4772
4930
  modal.appendChild(title);
4773
4931
 
4774
4932
  // Modal body
@@ -4809,18 +4967,206 @@ const Sessions = (() => {
4809
4967
  loadTreeForPath(parent, true);
4810
4968
  });
4811
4969
 
4970
+ // ── "+ New folder" ────────────────────────────────────────────────
4971
+ // Drops an editable placeholder row at the top of the current view
4972
+ // and POSTs to the backend on commit. Confirm = Enter / blur, cancel
4973
+ // = Escape. The placeholder is purely visual; nothing is created on
4974
+ // disk until the user commits a non-empty name.
4975
+ const newFolderBtn = document.createElement("button");
4976
+ newFolderBtn.className = "btn btn-secondary btn-sm dp-newfolder-btn";
4977
+ 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>`;
4978
+ newFolderBtn.addEventListener("click", () => beginCreateFolder());
4979
+
4980
+ function beginCreateFolder() {
4981
+ const parentDir = (pathInput.value || "").trim();
4982
+ if (!parentDir.startsWith("/")) {
4983
+ alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", "invalid parent path"));
4984
+ return;
4985
+ }
4986
+
4987
+ // Locate the parent node in the current tree, if it's rendered.
4988
+ // The path input may point at any expanded descendant of the
4989
+ // tree root, so we can't assume parentDir == tree root.
4990
+ // Walk every rendered .dp-node and match by entry.absPath.
4991
+ let parentNode = null;
4992
+ treeContainer.querySelectorAll(".dp-node").forEach(n => {
4993
+ if (parentNode) return;
4994
+ if (n._dpEntry && n._dpEntry.absPath === parentDir) parentNode = n;
4995
+ });
4996
+
4997
+ // Pick where to insert + what depth the placeholder lives at.
4998
+ // - Tree root (parentDir is the root) → depth 0, host = treeContainer
4999
+ // - Rendered subtree → depth = parent.depth + 1,
5000
+ // host = parent's .dp-children
5001
+ let host, depth, parentRow, parentCaret, parentChildren;
5002
+ if (parentNode) {
5003
+ parentRow = parentNode._dpRow;
5004
+ parentCaret = parentRow.querySelector(".dp-caret");
5005
+ parentChildren = parentNode.querySelector(":scope > .dp-children");
5006
+ depth = (parseInt(parentNode.dataset.depth, 10) || 0) + 1;
5007
+ host = parentChildren;
5008
+ } else {
5009
+ depth = 0;
5010
+ host = treeContainer;
5011
+ }
5012
+
5013
+ // Build a placeholder node and seed it with an inline editor.
5014
+ const placeholder = document.createElement("div");
5015
+ placeholder.className = "dp-node dp-node-pending";
5016
+ placeholder.dataset.depth = depth;
5017
+
5018
+ const row = document.createElement("div");
5019
+ row.className = "dp-row dp-row-editing";
5020
+ row.style.paddingLeft = `${depth * 16 + 8}px`;
5021
+
5022
+ const caret = document.createElement("span");
5023
+ caret.className = "dp-caret";
5024
+ caret.innerHTML = ICON_CARET_SVG;
5025
+
5026
+ const icon = document.createElement("span");
5027
+ icon.className = "dp-icon";
5028
+ icon.innerHTML = ICON_FOLDER_SVG;
5029
+
5030
+ const nameSpan = document.createElement("span");
5031
+ nameSpan.className = "dp-name";
5032
+
5033
+ row.appendChild(caret);
5034
+ row.appendChild(icon);
5035
+ row.appendChild(nameSpan);
5036
+ placeholder.appendChild(row);
5037
+
5038
+ // If we're inserting under a parent node, make sure the parent is
5039
+ // visually expanded so the user sees the placeholder. Mark the
5040
+ // children container as loaded so we don't trigger a refetch on
5041
+ // first toggleExpand (we'll keep our placeholder + inserted node).
5042
+ if (parentNode) {
5043
+ parentCaret.classList.add("open");
5044
+ parentChildren.style.display = "flex";
5045
+ // Drop "loading" / "empty" / "error" stub if present.
5046
+ const stub = parentChildren.querySelector(".dp-empty, .dp-loading, .dp-error");
5047
+ if (stub) stub.remove();
5048
+ parentChildren.dataset.loaded = "1";
5049
+ parentChildren.insertBefore(placeholder, parentChildren.firstChild);
5050
+ } else {
5051
+ // Root level: drop the empty/loading/error stub, then prepend.
5052
+ const emptyEl = treeContainer.querySelector(":scope > .dp-empty, :scope > .dp-loading, :scope > .dp-error");
5053
+ if (emptyEl) emptyEl.remove();
5054
+ treeContainer.insertBefore(placeholder, treeContainer.firstChild);
5055
+ }
5056
+
5057
+ startInlineEdit(nameSpan, t("sib.dir.newFolderDefault", "New Folder"), {
5058
+ onCommit: async (newName) => {
5059
+ const trimmed = (newName || "").trim();
5060
+ if (!trimmed) { placeholder.remove(); return; }
5061
+ try {
5062
+ const resp = await fetch("/api/dirs/mkdir", {
5063
+ method: "POST",
5064
+ headers: { "Content-Type": "application/json" },
5065
+ body: JSON.stringify({ parent: parentDir, name: trimmed })
5066
+ });
5067
+ const data = await resp.json().catch(() => ({}));
5068
+ if (!resp.ok || !data.ok) {
5069
+ alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}")
5070
+ .replace("{{msg}}", data.error || `HTTP ${resp.status}`));
5071
+ placeholder.remove();
5072
+ return;
5073
+ }
5074
+ // Replace placeholder with a real, fully-wired node so it can
5075
+ // be expanded / right-clicked exactly like any sibling.
5076
+ const realEntry = {
5077
+ name: data.name,
5078
+ path: data.path,
5079
+ absPath: data.path,
5080
+ absolute: true,
5081
+ type: "dir"
5082
+ };
5083
+ const realNode = buildDirNode(realEntry, depth);
5084
+ placeholder.replaceWith(realNode);
5085
+ // Auto-select the new folder so the path input updates.
5086
+ modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
5087
+ realNode._dpRow.classList.add("selected");
5088
+ selectedPath = data.path;
5089
+ pathInput.value = data.path;
5090
+ refreshUpBtn();
5091
+ } catch (err) {
5092
+ alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", err.message || String(err)));
5093
+ placeholder.remove();
5094
+ }
5095
+ },
5096
+ onCancel: () => placeholder.remove()
5097
+ });
5098
+ }
5099
+
5100
+ // ── Inline rename editor (shared by mkdir + rename) ───────────────
5101
+ // Replaces a name span with an <input> seeded by `initialValue`,
5102
+ // commits on Enter/blur, cancels on Escape. The caller wires the
5103
+ // backend round-trip via onCommit (it receives the new name string).
5104
+ function startInlineEdit(nameSpan, initialValue, { onCommit, onCancel }) {
5105
+ const input = document.createElement("input");
5106
+ input.type = "text";
5107
+ input.className = "dp-name-input";
5108
+ input.value = initialValue;
5109
+ input.spellcheck = false;
5110
+ input.autocomplete = "off";
5111
+ input.setAttribute("autocapitalize", "off");
5112
+
5113
+ // Stop click-through so editing doesn't trigger row selection /
5114
+ // expand / collapse.
5115
+ ["click", "dblclick", "mousedown"].forEach(ev =>
5116
+ input.addEventListener(ev, (e) => e.stopPropagation())
5117
+ );
5118
+
5119
+ // Replace span with input. Track via parent so we can swap back.
5120
+ const parent = nameSpan.parentNode;
5121
+ parent.replaceChild(input, nameSpan);
5122
+ // Defer focus until the next tick so the caller's surrounding DOM
5123
+ // mutations (e.g. inserting a placeholder row) finish first.
5124
+ setTimeout(() => {
5125
+ input.focus();
5126
+ input.select();
5127
+ }, 0);
5128
+
5129
+ let finished = false;
5130
+ const finish = (commit) => {
5131
+ if (finished) return;
5132
+ finished = true;
5133
+ const value = input.value;
5134
+ if (input.parentNode === parent) parent.replaceChild(nameSpan, input);
5135
+ if (commit) {
5136
+ try { onCommit && onCommit(value); } catch (e) { console.error(e); }
5137
+ } else {
5138
+ try { onCancel && onCancel(); } catch (e) { console.error(e); }
5139
+ }
5140
+ };
5141
+
5142
+ // Enter commits; Escape cancels. Use IME.bindEnter so that the
5143
+ // Enter that confirms a Chinese / Japanese / Korean IME candidate
5144
+ // does NOT commit the rename. Mirrors Sessions._startRename.
5145
+ IME.bindEnter(input, () => finish(true));
5146
+ input.addEventListener("keydown", (e) => {
5147
+ if (e.key === "Escape") { e.preventDefault(); finish(false); }
5148
+ });
5149
+ input.addEventListener("blur", () => finish(true));
5150
+ }
5151
+
4812
5152
  function setupPresets() {
4813
5153
  presets.innerHTML = "";
4814
5154
  presets.appendChild(upBtn);
4815
5155
  const presetDirs = sessionLess
4816
5156
  ? [
4817
- { value: homeDir, text: t("sib.dir.home", "主目录"), absolute: true },
4818
- { value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
5157
+ { value: defaultDir, text: t("sib.dir.default", "默认工作目录"), absolute: true },
5158
+ { value: homeDir, text: t("sib.dir.home", "主目录"), absolute: true },
5159
+ { value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
4819
5160
  ]
4820
5161
  : [
4821
- { value: rootDir, text: t("sib.dir.current", "当前工作目录"), absolute: false },
4822
- { value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
4823
- ]; presetDirs.forEach(p => {
5162
+ { value: defaultDir, text: t("sib.dir.default", "默认工作目录"), absolute: true },
5163
+ { value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
5164
+ ];
5165
+ presetDirs.forEach(p => {
5166
+ // Skip preset whose absolute path isn't known yet (defensive: the
5167
+ // backend always returns it, but a malformed deploy shouldn't render
5168
+ // a button that navigates to "").
5169
+ if (!p.value) return;
4824
5170
  const btn = document.createElement("button");
4825
5171
  btn.className = "btn btn-secondary btn-sm";
4826
5172
  btn.textContent = p.text;
@@ -4832,6 +5178,8 @@ const Sessions = (() => {
4832
5178
  });
4833
5179
  presets.appendChild(btn);
4834
5180
  });
5181
+ // "+ New folder" trailing action on the same toolbar.
5182
+ presets.appendChild(newFolderBtn);
4835
5183
  }
4836
5184
 
4837
5185
  // Path input