openclacky 1.3.4 → 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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
  4. data/lib/clacky/agent/session_serializer.rb +3 -2
  5. data/lib/clacky/agent/tool_executor.rb +0 -12
  6. data/lib/clacky/agent.rb +74 -9
  7. data/lib/clacky/api_extension.rb +81 -0
  8. data/lib/clacky/api_extension_loader.rb +13 -1
  9. data/lib/clacky/client.rb +14 -17
  10. data/lib/clacky/default_agents/_panels/time_machine/panel.js +22 -0
  11. data/lib/clacky/default_agents/base_prompt.md +1 -0
  12. data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
  13. data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
  14. data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
  15. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
  16. data/lib/clacky/default_skills/media-gen/SKILL.md +63 -0
  17. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
  18. data/lib/clacky/json_ui_controller.rb +1 -1
  19. data/lib/clacky/media/base.rb +60 -0
  20. data/lib/clacky/media/dashscope.rb +385 -21
  21. data/lib/clacky/media/gemini.rb +9 -0
  22. data/lib/clacky/media/generator.rb +52 -0
  23. data/lib/clacky/media/openai_compat.rb +166 -0
  24. data/lib/clacky/null_ui_controller.rb +13 -0
  25. data/lib/clacky/plain_ui_controller.rb +1 -1
  26. data/lib/clacky/providers.rb +50 -2
  27. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  28. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  29. data/lib/clacky/server/http_server.rb +144 -9
  30. data/lib/clacky/server/session_registry.rb +4 -2
  31. data/lib/clacky/server/web_ui_controller.rb +3 -2
  32. data/lib/clacky/skill_loader.rb +14 -2
  33. data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
  34. data/lib/clacky/tools/terminal.rb +0 -43
  35. data/lib/clacky/ui2/components/modal_component.rb +1 -1
  36. data/lib/clacky/ui2/ui_controller.rb +140 -31
  37. data/lib/clacky/ui_interface.rb +10 -1
  38. data/lib/clacky/utils/encoding.rb +25 -0
  39. data/lib/clacky/version.rb +1 -1
  40. data/lib/clacky/web/app.css +145 -22
  41. data/lib/clacky/web/components/onboard.js +1 -14
  42. data/lib/clacky/web/features/brand/view.js +8 -5
  43. data/lib/clacky/web/features/channels/store.js +1 -20
  44. data/lib/clacky/web/features/mcp/store.js +1 -20
  45. data/lib/clacky/web/features/profile/store.js +1 -13
  46. data/lib/clacky/web/features/profile/view.js +16 -4
  47. data/lib/clacky/web/features/skills/store.js +6 -21
  48. data/lib/clacky/web/features/version/store.js +2 -0
  49. data/lib/clacky/web/i18n.js +24 -1
  50. data/lib/clacky/web/index.html +15 -0
  51. data/lib/clacky/web/sessions.js +141 -51
  52. data/lib/clacky/web/settings.js +34 -2
  53. data/lib/clacky/web/ws-dispatcher.js +11 -3
  54. data/lib/clacky.rb +12 -5
  55. metadata +8 -1
@@ -575,20 +575,7 @@ const Onboard = (() => {
575
575
  async function _launchOnboardSession() {
576
576
  try {
577
577
  await _complete();
578
- const res = await fetch("/api/sessions", {
579
- method: "POST",
580
- headers: { "Content-Type": "application/json" },
581
- body: JSON.stringify({ name: "Onboard", source: "setup" })
582
- });
583
- const data = await res.json();
584
- const session = data.session;
585
- if (!session) throw new Error("No session returned");
586
-
587
- Sessions.add(session);
588
- Sessions.renderList();
589
- Sessions.setPendingMessage(session.id, `/onboard lang:${_selectedLang}`);
590
- Sessions.select(session.id);
591
-
578
+ await Sessions.startWith(`/onboard lang:${_selectedLang}`, { name: "Onboard", source: "setup" });
592
579
  _bootUI();
593
580
  } catch (_) {
594
581
  // Fallback: just boot normally if session creation fails
@@ -18,6 +18,7 @@ const BrandView = (() => {
18
18
  if (!data || !data.branded) return;
19
19
 
20
20
  if (data.needs_activation) {
21
+ if (data.theme_color) Settings.applyAccentColor(data.theme_color, { persist: true });
21
22
  _showActivationBanner(data.product_name);
22
23
  _applyHeaderLogo();
23
24
  if (data.distribution_refresh_pending) _scheduleDistributionRefreshPoll();
@@ -153,6 +154,7 @@ const BrandView = (() => {
153
154
  if (data.ok) {
154
155
  _setResult(true, I18n.t("brand.activate.success"));
155
156
  if (data.product_name) _applyBrandName(data.product_name);
157
+ if (data.theme_color) Settings.applyAccentColor(data.theme_color, { persist: true });
156
158
  Brand.clearBrandCache();
157
159
  _applyHeaderLogo();
158
160
  setTimeout(_bootUI, 800);
@@ -201,13 +203,14 @@ const BrandView = (() => {
201
203
  const brandWrap = document.getElementById("header-brand");
202
204
 
203
205
  if (info.theme_color) {
204
- const root = document.documentElement;
205
- root.style.setProperty("--color-accent-primary", info.theme_color);
206
- root.style.setProperty("--color-accent-hover", info.theme_color);
207
- root.style.setProperty("--color-button-primary", info.theme_color);
208
- root.style.setProperty("--color-button-primary-hover", info.theme_color);
209
206
  const metaTheme = document.querySelector("meta[name='theme-color']");
210
207
  if (metaTheme) metaTheme.setAttribute("content", info.theme_color);
208
+ }
209
+ const userAccent = (() => { try { return localStorage.getItem("clacky-accent-color"); } catch (_) { return null; } })();
210
+ if (userAccent) {
211
+ Settings.applyAccentColor(userAccent, { persist: false });
212
+ } else if (info.theme_color) {
213
+ Settings.applyAccentColor(info.theme_color, { persist: true });
211
214
  } else {
212
215
  const root = document.documentElement;
213
216
  root.style.removeProperty("--color-accent-primary");
@@ -37,26 +37,7 @@ const ChannelsStore = (() => {
37
37
  // Create a session, register it, queue a command, and navigate to it.
38
38
  async function _sendToAgent(command, sessionName) {
39
39
  try {
40
- const maxN = Sessions.all.reduce((max, s) => {
41
- const m = s.name.match(/^Session (\d+)$/);
42
- return m ? Math.max(max, parseInt(m[1], 10)) : max;
43
- }, 0);
44
- const name = sessionName || ("Session " + (maxN + 1));
45
-
46
- const res = await fetch("/api/sessions", {
47
- method: "POST",
48
- headers: { "Content-Type": "application/json" },
49
- body: JSON.stringify({ name, source: "setup" }),
50
- });
51
- const data = await res.json();
52
- if (!res.ok) throw new Error(data.error || I18n.t("channels.sessionError"));
53
- const session = data.session;
54
- if (!session) throw new Error(I18n.t("channels.noSession"));
55
-
56
- Sessions.add(session);
57
- Sessions.renderList();
58
- Sessions.setPendingMessage(session.id, command);
59
- Sessions.select(session.id);
40
+ await Sessions.startWith(command, { name: sessionName });
60
41
  } catch (e) {
61
42
  alert("Error: " + e.message);
62
43
  }
@@ -43,26 +43,7 @@ const McpStore = (() => {
43
43
 
44
44
  async function _sendToAgent(command, sessionName) {
45
45
  try {
46
- const maxN = Sessions.all.reduce((max, s) => {
47
- const m = s.name.match(/^Session (\d+)$/);
48
- return m ? Math.max(max, parseInt(m[1], 10)) : max;
49
- }, 0);
50
- const name = sessionName || ("Session " + (maxN + 1));
51
-
52
- const res = await fetch("/api/sessions", {
53
- method: "POST",
54
- headers: { "Content-Type": "application/json" },
55
- body: JSON.stringify({ name, source: "mcp" }),
56
- });
57
- const data = await res.json();
58
- if (!res.ok) throw new Error(data.error || "failed to create session");
59
- const session = data.session;
60
- if (!session) throw new Error("no session returned");
61
-
62
- Sessions.add(session);
63
- Sessions.renderList();
64
- Sessions.setPendingMessage(session.id, command);
65
- Sessions.select(session.id);
46
+ await Sessions.startWith(command, { name: sessionName });
66
47
  } catch (e) {
67
48
  alert("Error: " + e.message);
68
49
  }
@@ -64,19 +64,7 @@ const ProfileStore = (() => {
64
64
  }
65
65
 
66
66
  async function _openCurateSession(name, command) {
67
- const res = await fetch("/api/sessions", {
68
- method: "POST",
69
- headers: { "Content-Type": "application/json" },
70
- body: JSON.stringify({ name, source: "onboard" })
71
- });
72
- const data = await res.json();
73
- const session = data.session;
74
- if (!session) throw new Error("No session returned");
75
-
76
- Sessions.add(session);
77
- Sessions.renderList();
78
- Sessions.setPendingMessage(session.id, command);
79
- Sessions.select(session.id);
67
+ await Sessions.startWith(command, { name, source: "onboard" });
80
68
  }
81
69
 
82
70
  const Profile = {
@@ -49,7 +49,7 @@ const ProfileView = (() => {
49
49
 
50
50
  function flushPara() {
51
51
  if (paraBuf.length === 0) return;
52
- out.push("<p>" + _renderInline(paraBuf.join(" ")) + "</p>");
52
+ out.push("<p>" + _renderInline(paraBuf.join("<br>")) + "</p>");
53
53
  paraBuf = [];
54
54
  }
55
55
  function openList(type) {
@@ -152,11 +152,17 @@ const ProfileView = (() => {
152
152
  return;
153
153
  }
154
154
 
155
+ const openSet = new Set(
156
+ [...list.querySelectorAll(".memory-card")]
157
+ .filter(c => c.querySelector(".memory-card-body")?.style.display !== "none")
158
+ .map(c => c.dataset.filename)
159
+ );
160
+
155
161
  list.innerHTML = "";
156
- memories.forEach(m => list.appendChild(_buildMemoryCard(m)));
162
+ memories.forEach(m => list.appendChild(_buildMemoryCard(m, openSet.has(m.filename))));
157
163
  }
158
164
 
159
- function _buildMemoryCard(m) {
165
+ function _buildMemoryCard(m, expanded = false) {
160
166
  const card = document.createElement("div");
161
167
  card.className = "memory-card";
162
168
  card.dataset.filename = m.filename;
@@ -254,6 +260,8 @@ const ProfileView = (() => {
254
260
  expandBtn.addEventListener("click", (e) => { e.stopPropagation(); toggle(); });
255
261
  head.querySelector(".memory-card-info").addEventListener("click", toggle);
256
262
 
263
+ if (expanded) toggle();
264
+
257
265
  return card;
258
266
  }
259
267
 
@@ -303,6 +311,7 @@ const ProfileView = (() => {
303
311
  onSave: async (newContent) => {
304
312
  const r = await Profile.updateMemory(m.filename, newContent);
305
313
  if (!r.ok) throw new Error(r.error);
314
+ Modal.toast(_t("profile.savedOk"), "success");
306
315
  }
307
316
  });
308
317
  }
@@ -333,7 +342,10 @@ const ProfileView = (() => {
333
342
  content: file.content || "",
334
343
  title,
335
344
  language: "markdown",
336
- onSave: (newContent) => Profile.updateProfile(kind, newContent)
345
+ onSave: async (newContent) => {
346
+ await Profile.updateProfile(kind, newContent);
347
+ Modal.toast(_t("profile.savedOk"), "success");
348
+ }
337
349
  });
338
350
  }
339
351
 
@@ -71,28 +71,13 @@ const SkillsStore = (() => {
71
71
  // Resolve the next "Session N" name and create a session, then hand off to
72
72
  // Sessions. Used by every "open a session and run a command" action.
73
73
  async function _openSessionWith(message) {
74
- const maxN = Sessions.all.reduce((max, s) => {
75
- const m = s.name.match(/^Session (\d+)$/);
76
- return m ? Math.max(max, parseInt(m[1], 10)) : max;
77
- }, 0);
78
- const res = await fetch("/api/sessions", {
79
- method: "POST",
80
- headers: { "Content-Type": "application/json" },
81
- body: JSON.stringify({ name: "Session " + (maxN + 1), source: "manual" })
82
- });
83
- const data = await res.json();
84
- if (!res.ok) { alert(I18n.t("tasks.sessionError") + (data.error || "unknown")); return null; }
85
-
86
- const session = data.session;
87
- if (!session) return null;
88
-
89
74
  if (!WS.ready) { WS.connect(); Skills.load(); }
90
-
91
- Sessions.add(session);
92
- Sessions.renderList();
93
- Sessions.setPendingMessage(session.id, message);
94
- Sessions.select(session.id);
95
- return session;
75
+ try {
76
+ return await Sessions.startWith(message, { source: "manual" });
77
+ } catch (e) {
78
+ alert(I18n.t("tasks.sessionError") + (e.message || "unknown"));
79
+ return null;
80
+ }
96
81
  }
97
82
 
98
83
  /** Return a user-friendly message for install/update errors. */
@@ -143,6 +143,8 @@ const VersionStore = (() => {
143
143
  _upgradeDone = false;
144
144
  }
145
145
  _emit({ reason: "upgrade-complete", success: !!event.success });
146
+ } else if (event.type === "_ws_connected") {
147
+ checkVersion();
146
148
  }
147
149
  }
148
150
 
@@ -68,6 +68,9 @@ const I18n = (() => {
68
68
  "chat.resetSession": "Reset session",
69
69
  "chat.resetSessionConfirm": "Reset will start a brand-new session. The current conversation history stays in your sidebar but will no longer be active. Continue?",
70
70
  "chat.copy": "Copy",
71
+ "chat.todo.spawn": "Start",
72
+ "chat.todo.spawning": "Starting…",
73
+ "chat.todo.spawnFailed": "Failed to start session: {msg}",
71
74
  "chat.copied": "Copied",
72
75
  "chat.continue": "Continue",
73
76
  "chat.edit": "Edit",
@@ -570,6 +573,8 @@ const I18n = (() => {
570
573
  "settings.media.kind.image": "Image",
571
574
  "settings.media.kind.video": "Video",
572
575
  "settings.media.kind.audio": "Audio",
576
+ "settings.media.kind.stt": "Speech-to-Text",
577
+ "settings.media.kind.video_understanding": "Video Understanding",
573
578
  "settings.media.kind.ocr": "Vision",
574
579
  "settings.media.source.off": "Off",
575
580
  "settings.media.source.auto": "Auto",
@@ -727,6 +732,8 @@ const I18n = (() => {
727
732
  "settings.currency.updated": "Updated from {{source}} on {{date}}",
728
733
  "settings.currency.updateFailed": "Failed to fetch the latest rate. You can still enter it manually.",
729
734
 
735
+ "settings.accentColor.title": "Accent Color",
736
+
730
737
  // ── Onboard ──
731
738
  "onboard.title": "Welcome to {{brand}}",
732
739
  "onboard.subtitle": "Let's get you set up in a minute.",
@@ -920,6 +927,7 @@ const I18n = (() => {
920
927
 
921
928
  "error.insufficient_credit": "Insufficient LLM credit. Please top up your account to continue.",
922
929
  "error.insufficient_credit.action": "Top up",
930
+ "error.show_detail": "Show details",
923
931
 
924
932
  "billing.sessions": "Sessions",
925
933
  "billing.sessionId": "Session",
@@ -986,6 +994,9 @@ const I18n = (() => {
986
994
  "chat.newMessageHint": "有新消息 ↓",
987
995
  "chat.retry": "重试",
988
996
  "chat.copy": "复制",
997
+ "chat.todo.spawn": "开干",
998
+ "chat.todo.spawning": "创建中…",
999
+ "chat.todo.spawnFailed": "创建会话失败:{msg}",
989
1000
  "chat.copied": "已复制",
990
1001
  "chat.continue": "继续",
991
1002
  "chat.edit": "编辑",
@@ -1481,12 +1492,14 @@ const I18n = (() => {
1481
1492
  "settings.models.badge.default": "默认",
1482
1493
  "settings.models.badge.lite": "轻量",
1483
1494
  "settings.media.title": "配置副模型",
1484
- "settings.media.desc": "可选。图片生成 / 视频生成 / 音频生成 / 视觉理解。",
1495
+ "settings.media.desc": "图片生成 / 视频生成 / 音频生成 / 语音转写 / 视频理解 / 视觉理解(可选)",
1485
1496
  "settings.media.loading": "加载中…",
1486
1497
  "settings.media.error": "加载失败:{{msg}}",
1487
1498
  "settings.media.kind.image": "图片生成",
1488
1499
  "settings.media.kind.video": "视频生成",
1489
1500
  "settings.media.kind.audio": "音频生成",
1501
+ "settings.media.kind.stt": "语音转写",
1502
+ "settings.media.kind.video_understanding": "视频理解",
1490
1503
  "settings.media.kind.ocr": "视觉理解",
1491
1504
  "settings.media.source.off": "关闭",
1492
1505
  "settings.media.source.auto": "自动",
@@ -1514,6 +1527,13 @@ const I18n = (() => {
1514
1527
  "settings.media.apiKey.required": "请填写 API Key",
1515
1528
  "settings.media.model.required": "请填写模型名称",
1516
1529
  "settings.media.baseUrl.required": "请填写 Base URL",
1530
+ "settings.media.output_dir.desc": "生成的图片、视频、音频文件保存位置(可选)",
1531
+ "settings.media.output_dir.browse": "选择目录…",
1532
+ "settings.media.output_dir.picker": "选择媒体输出目录",
1533
+ "settings.media.output_dir.clear": "清除",
1534
+ "settings.media.output_dir.saved": "已保存",
1535
+ "settings.media.output_dir.cleared": "已清除",
1536
+ "settings.media.output_dir.invalid": "目录无效",
1517
1537
  "settings.models.field.quicksetup": "快速配置",
1518
1538
  "settings.models.field.model": "Model",
1519
1539
  "settings.models.field.baseurl": "Base URL",
@@ -1644,6 +1664,8 @@ const I18n = (() => {
1644
1664
  "settings.currency.updated": "已从 {{source}} 更新,日期 {{date}}",
1645
1665
  "settings.currency.updateFailed": "获取最新汇率失败,仍可手动输入。",
1646
1666
 
1667
+ "settings.accentColor.title": "主色",
1668
+
1647
1669
  // ── Onboard ──
1648
1670
  "onboard.title": "欢迎使用 {{brand}}",
1649
1671
  "onboard.subtitle": "一分钟完成配置,马上开始。",
@@ -1838,6 +1860,7 @@ const I18n = (() => {
1838
1860
 
1839
1861
  "error.insufficient_credit": "LLM(大模型)余额不足,请充值后继续使用。",
1840
1862
  "error.insufficient_credit.action": "去充值",
1863
+ "error.show_detail": "查看详情",
1841
1864
  "billing.sessions": "会话消耗",
1842
1865
  "billing.sessionId": "会话",
1843
1866
  "billing.tokens": "Token",
@@ -890,6 +890,21 @@
890
890
  </label>
891
891
  </div>
892
892
  </section>
893
+
894
+ <!-- Accent Color section -->
895
+ <section class="settings-section" id="accent-color-section">
896
+ <div class="settings-section-title">
897
+ <span data-i18n="settings.accentColor.title">Accent Color</span>
898
+ </div>
899
+ <div class="settings-accent-swatches">
900
+ <button class="settings-accent-swatch swatch-indigo" data-color="#4f46e5" title="Indigo"></button>
901
+ <button class="settings-accent-swatch swatch-aurora-blue" data-color="#3B82F6" title="Aurora Blue"></button>
902
+ <button class="settings-accent-swatch swatch-forest-green" data-color="#10B981" title="Forest Green"></button>
903
+ <button class="settings-accent-swatch swatch-sunrise-orange" data-color="#F59E0B" title="Sunrise Orange"></button>
904
+ <button class="settings-accent-swatch swatch-rose-violet" data-color="#8B5CF6" title="Rose Violet"></button>
905
+ <button class="settings-accent-swatch swatch-coral-red" data-color="#EF4444" title="Coral Red"></button>
906
+ </div>
907
+ </section>
893
908
  </div>
894
909
 
895
910
  <!-- ══ Tab: General ══ -->
@@ -411,42 +411,27 @@ const Sessions = (() => {
411
411
  _hideNewMessageBanner();
412
412
  });
413
413
 
414
- // Detect actual user scroll interactions (wheel, touch, keyboard)
415
- // These fire BEFORE the scroll event, so we can set the flag reliably.
416
- const detectUserScroll = (e) => {
417
- // Only flag if user is scrolling up (negative deltaY = scroll up)
418
- // For wheel events: deltaY < 0 means scroll up
419
- // For touch/keyboard: check scroll position in the scroll event
420
- const isWheelUp = e.type === "wheel" && e.deltaY < 0;
421
- const isKeyboardUp = e.type === "keydown" && (e.key === "ArrowUp" || e.key === "PageUp" || e.key === "Home");
422
-
423
- if (isWheelUp || isKeyboardUp) {
424
- _userScrolledUp = true;
425
- }
426
- };
427
-
428
- messages.addEventListener("wheel", detectUserScroll, { passive: true });
429
- messages.addEventListener("keydown", detectUserScroll);
430
-
431
- // For touch devices: touchmove doesn't tell us direction, so check in scroll event
432
- let touchStartY = 0;
433
- messages.addEventListener("touchstart", (e) => {
434
- touchStartY = e.touches[0].clientY;
435
- }, { passive: true });
436
-
437
- messages.addEventListener("touchmove", (e) => {
438
- const touchDeltaY = e.touches[0].clientY - touchStartY;
439
- // touchDeltaY > 0 means finger moved down = content scrolls up
440
- if (touchDeltaY > 5) {
441
- _userScrolledUp = true;
442
- }
443
- }, { passive: true });
444
-
445
- // 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.
446
429
  messages.addEventListener("scroll", () => {
447
430
  if (_isAtBottom(messages)) {
448
431
  _userScrolledUp = false;
449
432
  _hideNewMessageBanner();
433
+ } else {
434
+ _userScrolledUp = true;
450
435
  }
451
436
  });
452
437
  }
@@ -468,30 +453,26 @@ const Sessions = (() => {
468
453
  document.getElementById("btn-new-session-inline")
469
454
  .addEventListener("click", () => Sessions.create("general"));
470
455
 
471
- // Split button: arrow (toggle dropdown)
472
- document.getElementById("btn-new-session-arrow")
473
- .addEventListener("click", (e) => {
474
- e.stopPropagation();
475
- const dd = document.getElementById("new-session-dropdown");
476
- dd.hidden = !dd.hidden;
477
- });
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");
478
460
 
479
- // Dropdown item "Advanced Options…" delegated because the dropdown
480
- // 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…"
481
467
  document.addEventListener("click", (e) => {
482
468
  if (e.target && e.target.id === "btn-new-session-modal") {
483
- e.stopPropagation();
484
- document.getElementById("new-session-dropdown").hidden = true;
469
+ dd.hidden = true;
485
470
  Sessions.openNewSessionModal();
471
+ } else if (!wrap.contains(e.target)) {
472
+ dd.hidden = true;
486
473
  }
487
474
  });
488
475
 
489
- // Close dropdown when clicking anywhere else
490
- document.addEventListener("click", () => {
491
- const dd = document.getElementById("new-session-dropdown");
492
- if (dd && !dd.hidden) dd.hidden = true;
493
- });
494
-
495
476
  // Welcome screen "+ New Session" button
496
477
  document.getElementById("btn-welcome-new")
497
478
  .addEventListener("click", () => Sessions.create("general"));
@@ -1404,6 +1385,7 @@ const Sessions = (() => {
1404
1385
  el.dataset.raw = ev.content || "";
1405
1386
  el.innerHTML = _renderMarkdown(ev.content || "");
1406
1387
  _appendCopyButton(el);
1388
+ _enhanceTaskItems(el, ev.content || "");
1407
1389
  container.appendChild(el);
1408
1390
  break;
1409
1391
  }
@@ -1667,6 +1649,7 @@ const Sessions = (() => {
1667
1649
  code: session.error_code,
1668
1650
  message: session.error,
1669
1651
  top_up_url: session.top_up_url,
1652
+ raw_message: session.raw_message,
1670
1653
  });
1671
1654
  } else {
1672
1655
  Sessions.appendMsg("error", session.error);
@@ -1925,6 +1908,78 @@ const Sessions = (() => {
1925
1908
  _ensureCopyDelegation();
1926
1909
  }
1927
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
+
1928
1983
  // Install the click-delegation listener on #messages exactly once.
1929
1984
  // Handles copy clicks for all current AND future assistant bubbles
1930
1985
  // AND code block copy buttons.
@@ -2338,6 +2393,36 @@ const Sessions = (() => {
2338
2393
  }
2339
2394
  },
2340
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
+
2341
2426
  /** Patch a single session's fields (from session_update event).
2342
2427
  * If the session is not in the list yet (e.g. just created by another tab),
2343
2428
  * prepend it so the sidebar shows it immediately. */
@@ -3508,6 +3593,7 @@ const Sessions = (() => {
3508
3593
  el.dataset.raw = html || "";
3509
3594
  el.innerHTML = _renderMarkdown(html);
3510
3595
  _appendCopyButton(el);
3596
+ _enhanceTaskItems(el, html || "");
3511
3597
  } else {
3512
3598
  el.innerHTML = html;
3513
3599
  }
@@ -3535,7 +3621,11 @@ const Sessions = (() => {
3535
3621
  });
3536
3622
  retryBtn.disabled = true;
3537
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);
3538
3627
  el.appendChild(retryBtn);
3628
+ if (rawDetail) el.appendChild(rawDetail);
3539
3629
  }
3540
3630
  messages.appendChild(el);
3541
3631
  }
@@ -3928,8 +4018,8 @@ const Sessions = (() => {
3928
4018
  },
3929
4019
 
3930
4020
  /** Register a slash-command message to send after subscribe is confirmed. */
3931
- setPendingMessage(sessionId, content) {
3932
- _pendingMessage = { session_id: sessionId, content };
4021
+ setPendingMessage(sessionId, content, display = null) {
4022
+ _pendingMessage = { session_id: sessionId, content, display };
3933
4023
  },
3934
4024
 
3935
4025
  /** Consume and return the pending message (clears it). */