openclacky 1.3.2 → 1.3.3

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/lib/clacky/agent/session_serializer.rb +49 -5
  7. data/lib/clacky/agent/time_machine.rb +247 -26
  8. data/lib/clacky/agent.rb +12 -1
  9. data/lib/clacky/agent_config.rb +14 -2
  10. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  11. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  12. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  13. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  14. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  15. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  16. data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
  17. data/lib/clacky/media/openai_compat.rb +64 -1
  18. data/lib/clacky/media/output_dir.rb +43 -0
  19. data/lib/clacky/message_history.rb +9 -0
  20. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  21. data/lib/clacky/server/git_panel.rb +115 -0
  22. data/lib/clacky/server/http_server.rb +497 -12
  23. data/lib/clacky/server/server_master.rb +6 -4
  24. data/lib/clacky/version.rb +1 -1
  25. data/lib/clacky/web/app.css +473 -60
  26. data/lib/clacky/web/app.js +30 -7
  27. data/lib/clacky/web/components/code-editor.js +197 -0
  28. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  29. data/lib/clacky/web/core/aside.js +112 -0
  30. data/lib/clacky/web/core/ext.js +387 -0
  31. data/lib/clacky/web/features/backup/store.js +92 -0
  32. data/lib/clacky/web/features/backup/view.js +94 -0
  33. data/lib/clacky/web/features/billing/store.js +163 -0
  34. data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
  35. data/lib/clacky/web/features/brand/store.js +110 -0
  36. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  37. data/lib/clacky/web/features/channels/store.js +103 -0
  38. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  39. data/lib/clacky/web/features/creator/store.js +81 -0
  40. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  41. data/lib/clacky/web/features/mcp/store.js +158 -0
  42. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  43. data/lib/clacky/web/features/model-tester/store.js +77 -0
  44. data/lib/clacky/web/features/model-tester/view.js +7 -0
  45. data/lib/clacky/web/features/profile/store.js +170 -0
  46. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  47. data/lib/clacky/web/features/share/store.js +145 -0
  48. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  49. data/lib/clacky/web/features/skills/store.js +303 -0
  50. data/lib/clacky/web/features/skills/view.js +550 -0
  51. data/lib/clacky/web/features/tasks/store.js +135 -0
  52. data/lib/clacky/web/features/tasks/view.js +241 -0
  53. data/lib/clacky/web/features/trash/store.js +242 -0
  54. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  55. data/lib/clacky/web/features/version/store.js +165 -0
  56. data/lib/clacky/web/features/version/view.js +323 -0
  57. data/lib/clacky/web/features/workspace/store.js +99 -0
  58. data/lib/clacky/web/features/workspace/view.js +305 -0
  59. data/lib/clacky/web/i18n.js +56 -6
  60. data/lib/clacky/web/index.html +117 -58
  61. data/lib/clacky/web/sessions.js +221 -25
  62. data/lib/clacky/web/settings.js +118 -22
  63. data/lib/clacky/web/skills.js +3 -863
  64. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  65. data/lib/clacky.rb +1 -0
  66. metadata +45 -20
  67. data/lib/clacky/web/backup.js +0 -119
  68. data/lib/clacky/web/model-tester.js +0 -66
  69. data/lib/clacky/web/tasks.js +0 -373
  70. data/lib/clacky/web/version.js +0 -449
  71. data/lib/clacky/web/workspace.js +0 -316
  72. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  73. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  74. /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
  75. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  76. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -25,6 +25,7 @@
25
25
  <!-- ── TOP HEADER ──────────────────────────────────────────────────────── -->
26
26
  <header id="top-header">
27
27
  <div id="header-left">
28
+ <span id="ext-slot-header-left" data-slot="header.left"></span>
28
29
  <button id="btn-toggle-sidebar" class="sidebar-toggle-btn" title="Toggle sidebar">
29
30
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
30
31
  <rect width="18" height="18" x="3" y="3" rx="2"/>
@@ -51,6 +52,7 @@
51
52
  </button>
52
53
  </div>
53
54
  <div id="header-right">
55
+ <span id="ext-slot-header-right" data-slot="header.right"></span>
54
56
  <button id="share-toggle-header" class="theme-toggle-btn" data-i18n-title="share.tooltip" title="Share">
55
57
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
56
58
  <circle cx="18" cy="5" r="3"/>
@@ -269,10 +271,14 @@
269
271
  </div>
270
272
  </div>
271
273
  </div>
274
+
275
+ <!-- Extension nav slot: extensions add custom menu items / entry points to whole new workspaces here. -->
276
+ <div id="ext-slot-sidebar-nav" data-slot="sidebar.nav"></div>
272
277
  </div>
273
278
 
274
279
  <!-- Bottom Settings -->
275
280
  <div id="sidebar-footer">
281
+ <span id="ext-slot-sidebar-footer" data-slot="sidebar.footer"></span>
276
282
  <div class="sidebar-nav-row">
277
283
  <button id="btn-settings" class="sidebar-nav-btn" title="Settings">
278
284
  <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -297,6 +303,9 @@
297
303
  <!-- ── MAIN ─────────────────────────────────────────────────────────── -->
298
304
  <main id="main">
299
305
 
306
+ <!-- Extension workspace slot: extensions mount entirely new custom panels here. -->
307
+ <div id="ext-slot-main-workspace" data-slot="main.workspace"></div>
308
+
300
309
 
301
310
  <!-- Onboard panel: kept as empty shell (soul_setup auto-launches /onboard session) -->
302
311
  <div id="onboard-panel" style="display:none"></div>
@@ -354,6 +363,8 @@
354
363
 
355
364
  <!-- Chat column (messages + info bar + input) -->
356
365
  <div id="chat-main">
366
+ <!-- Banner slot: agent-scoped notices/toolbars above the message stream. -->
367
+ <div id="ext-slot-session-banner" class="session-banner" data-slot="session.banner"></div>
357
368
  <div id="messages" class="chat-messages-scroll"></div>
358
369
  <!-- New message notification banner -->
359
370
  <div id="new-message-banner" class="new-message-banner" style="display:none">
@@ -403,6 +414,8 @@
403
414
  </span>
404
415
  </div>
405
416
 
417
+ <!-- Composer slot: agent-scoped controls just above the input bar. -->
418
+ <div id="ext-slot-session-composer" class="session-composer" data-slot="session.composer"></div>
406
419
  <div id="input-area">
407
420
  <div id="ws-disconnect-hint" style="display:none"></div>
408
421
  <!-- Skill autocomplete dropdown (shown when user types /xxx) -->
@@ -441,33 +454,23 @@
441
454
  </div>
442
455
  </div><!-- /#chat-main -->
443
456
 
444
- <!-- ── WORKSPACE PANEL (right) ──────────────────────────────────── -->
445
- <aside id="workspace-panel" class="collapsed">
446
- <div id="workspace-resize-handle"></div>
447
- <div id="workspace-header">
448
- <span id="workspace-title" data-i18n="workspace.title">Workspace</span>
449
- <div class="workspace-header-actions">
450
- <button id="btn-workspace-refresh" class="workspace-icon-btn" data-i18n-title="workspace.refresh" title="Refresh">
451
- <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
452
- <path d="M23 4v6h-6"/><path d="M1 20v-6h6"/>
453
- <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
454
- </svg>
455
- </button>
456
- <button id="btn-workspace-close" class="workspace-icon-btn" data-i18n-title="workspace.collapse" title="Collapse">
457
- <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
458
- <path d="M9 18l6-6-6-6"/>
459
- </svg>
460
- </button>
461
- </div>
462
- </div>
463
- <div id="workspace-tree" role="tree"></div>
464
- </aside>
457
+ <!-- ── SESSION ASIDE (right) ────────────────────────────────────────
458
+ One resizable / collapsible right column. The tab bar and tab bodies
459
+ are rendered into #ext-slot-session-aside by Clacky.ext (tabbed slot);
460
+ the chrome here (resize handle, collapse button) is host-owned and
461
+ sits outside the slot container so re-renders never clobber it. -->
462
+ <div id="session-aside" class="session-aside">
463
+ <div id="session-aside-resize" class="session-aside-resize"></div>
464
+ <button id="btn-aside-collapse" class="session-aside-collapse" data-i18n-title="aside.collapse" title="Collapse">
465
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
466
+ </button>
467
+ <div id="ext-slot-session-aside" class="session-aside-slot" data-slot="session.aside"></div>
468
+ </div>
465
469
 
466
470
  <!-- Collapsed-state opener tab -->
467
- <button id="btn-workspace-open" data-i18n-title="workspace.expand" title="Open workspace" style="display:none">
468
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
469
- <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"/>
470
- </svg>
471
+ <div id="workspace-overlay"></div>
472
+ <button id="btn-aside-open" class="session-aside-opener" data-i18n-title="aside.expand" title="Open panel" style="display:none">
473
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
471
474
  </button>
472
475
  </div>
473
476
 
@@ -741,7 +744,10 @@
741
744
  <div id="profile-soul-body" class="profile-markdown"></div>
742
745
  <div class="profile-pane-footer">
743
746
  <p class="profile-pane-footer-hint" data-i18n="profile.soul.curateHint">Not quite right? Let the assistant curate this through a short conversation.</p>
744
- <button id="btn-profile-curate-soul" class="btn-profile-update" data-i18n="profile.soul.curateBtn">Have the assistant curate this</button>
747
+ <div class="profile-pane-footer-actions">
748
+ <button id="btn-profile-curate-soul" class="btn-profile-update" data-i18n="profile.soul.curateBtn">Have the assistant curate this</button>
749
+ <button id="btn-profile-edit-soul" class="btn-profile-edit" data-i18n="profile.soul.editBtn">Edit directly</button>
750
+ </div>
745
751
  </div>
746
752
  </section>
747
753
 
@@ -757,7 +763,10 @@
757
763
  <div id="profile-user-body" class="profile-markdown"></div>
758
764
  <div class="profile-pane-footer">
759
765
  <p class="profile-pane-footer-hint" data-i18n="profile.user.curateHint">Changed jobs? Picked up new interests? Let the assistant update your profile.</p>
760
- <button id="btn-profile-curate-user" class="btn-profile-update" data-i18n="profile.user.curateBtn">Have the assistant update this</button>
766
+ <div class="profile-pane-footer-actions">
767
+ <button id="btn-profile-curate-user" class="btn-profile-update" data-i18n="profile.user.curateBtn">Have the assistant update this</button>
768
+ <button id="btn-profile-edit-user" class="btn-profile-edit" data-i18n="profile.user.editBtn">Edit directly</button>
769
+ </div>
761
770
  </div>
762
771
  </section>
763
772
 
@@ -797,9 +806,14 @@
797
806
  <button class="settings-tab" data-tab="ui" data-i18n="settings.tabs.ui">UI</button>
798
807
  <button class="settings-tab" data-tab="general" data-i18n="settings.tabs.general">General</button>
799
808
  <button class="settings-tab" data-tab="about" data-i18n="settings.tabs.about">About</button>
809
+ <!-- Extension tab slot: extensions add custom settings tabs here (button needs data-tab="<id>"). -->
810
+ <span id="ext-slot-settings-tabs" data-slot="settings.tabs"></span>
800
811
  </div>
801
812
 
802
813
  <div id="settings-body">
814
+ <!-- Extension tab-content slot: matching panels go here (root needs data-tab-content="<id>"). -->
815
+ <span id="ext-slot-settings-body" data-slot="settings.body"></span>
816
+
803
817
 
804
818
  <!-- ══ Tab: Models ══ -->
805
819
  <div class="settings-tab-content active" data-tab-content="models">
@@ -821,6 +835,19 @@
821
835
  Optional. Image / video / audio / vision models.
822
836
  </div>
823
837
  <div id="media-rows"></div>
838
+
839
+ <!-- Output directory: scoped under media section -->
840
+ <div class="settings-subsection" id="media-output-dir-section">
841
+ <p class="settings-subsection-desc" data-i18n="settings.media.output_dir.desc">Where generated images, videos and audio are saved (optional)</p>
842
+ <div class="settings-network-url">
843
+ <div class="settings-network-url-row">
844
+ <input type="text" id="settings-media-output-dir" class="field-input" placeholder="" autocomplete="off" spellcheck="false" readonly>
845
+ <button type="button" id="btn-browse-media-output-dir" class="btn-settings-action" data-i18n="settings.media.output_dir.browse">Browse…</button>
846
+ <button type="button" id="btn-clear-media-output-dir" class="btn-settings-action" data-i18n="settings.media.output_dir.clear">Clear</button>
847
+ </div>
848
+ <div id="settings-media-output-dir-status" class="model-test-result"></div>
849
+ </div>
850
+ </div>
824
851
  </section>
825
852
  </div>
826
853
 
@@ -934,22 +961,24 @@
934
961
  </div>
935
962
  <p class="settings-section-desc" data-i18n="settings.backup.desc">Back up your ~/.clacky directory (config, skills, memories, tasks, sessions). Regenerable caches and logs are excluded.</p>
936
963
 
937
- <div class="backup-auto-row">
938
- <label class="toggle-switch">
939
- <input type="checkbox" id="backup-auto-toggle">
940
- <span class="toggle-slider"></span>
964
+ <div class="backup-auto-card">
965
+ <div class="backup-auto-row">
966
+ <label class="toggle-switch">
967
+ <input type="checkbox" id="backup-auto-toggle">
968
+ <span class="toggle-slider"></span>
969
+ </label>
970
+ <span class="backup-auto-label" data-i18n="settings.backup.autoLabel">Automatic backup</span>
971
+ <span class="backup-auto-hint" data-i18n="settings.backup.autoHint">Daily at 03:00, keeps the latest 7</span>
972
+ </div>
973
+
974
+ <label class="backup-option backup-option-nested">
975
+ <input type="checkbox" id="backup-include-sessions">
976
+ <span data-i18n="settings.backup.includeSessions">Include session history (larger archive)</span>
941
977
  </label>
942
- <span class="backup-auto-label" data-i18n="settings.backup.autoLabel">Automatic backup</span>
943
- <span class="backup-auto-hint" data-i18n="settings.backup.autoHint">Daily at 03:00, keeps the latest 7</span>
944
978
  </div>
945
979
 
946
- <label class="backup-option">
947
- <input type="checkbox" id="backup-include-sessions">
948
- <span data-i18n="settings.backup.includeSessions">Include session history (larger archive)</span>
949
- </label>
950
-
951
980
  <div class="backup-actions">
952
- <button id="btn-backup-now" class="btn-settings-action" data-i18n="settings.backup.runNow">💾 Download backup</button>
981
+ <button id="btn-backup-now" class="btn-settings-action" data-i18n="settings.backup.runNow">Download backup (full snapshot)</button>
953
982
  <span id="backup-status" class="model-test-result"></span>
954
983
  </div>
955
984
  </section>
@@ -1441,38 +1470,68 @@
1441
1470
 
1442
1471
  <div id="tooltip" style="display:none"></div>
1443
1472
 
1444
- <script src="/marked.min.js"></script>
1473
+ <script src="/vendor/codemirror/codemirror.min.js"></script>
1474
+ <script src="/components/code-editor.js"></script>
1475
+ <script src="/vendor/marked/marked.min.js"></script>
1445
1476
  <script src="/vendor/hljs/highlight.min.js"></script>
1446
1477
  <script src="/vendor/katex/katex.min.js"></script>
1447
1478
  <script src="/vendor/katex/auto-render.min.js"></script>
1448
1479
  <script src="/utils.js"></script>
1480
+ <script src="/core/ext.js"></script>
1481
+ <script src="/core/aside.js"></script>
1449
1482
  <script src="/i18n.js"></script>
1450
1483
  <script src="/auth.js"></script>
1451
1484
  <script src="/theme.js"></script>
1452
- <script src="/notify.js"></script>
1485
+ <script src="/components/notify.js"></script>
1453
1486
  <script src="/ws.js"></script>
1454
1487
  <script src="/ws-dispatcher.js"></script>
1455
1488
  <script src="/sessions.js"></script>
1456
- <script src="/workspace.js"></script>
1457
- <script src="/datepicker.js"></script>
1458
- <script src="/tasks.js"></script>
1489
+ <script src="/features/workspace/store.js"></script>
1490
+ <script src="/features/workspace/view.js"></script>
1491
+ <script src="/components/datepicker.js"></script>
1492
+ <script src="/features/tasks/store.js"></script>
1493
+ <script src="/features/tasks/view.js"></script>
1494
+ <script src="/features/skills/store.js"></script>
1495
+ <script src="/features/skills/view.js"></script>
1496
+ <script src="/features/channels/store.js"></script>
1497
+ <script src="/features/channels/view.js"></script>
1459
1498
  <script src="/skills.js"></script>
1460
- <script src="/channels.js"></script>
1461
- <script src="/mcp.js"></script>
1462
- <script src="/backup.js"></script>
1463
- <script src="/model-tester.js"></script>
1499
+ <script src="/features/mcp/store.js"></script>
1500
+ <script src="/features/mcp/view.js"></script>
1501
+ <script src="/features/backup/store.js"></script>
1502
+ <script src="/features/backup/view.js"></script>
1503
+ <script src="/features/model-tester/store.js"></script>
1504
+ <script src="/features/model-tester/view.js"></script>
1464
1505
  <script src="/settings.js"></script>
1465
- <script src="/billing.js"></script>
1466
- <script src="/onboard.js"></script>
1467
- <script src="/brand.js"></script>
1468
- <script src="/creator.js"></script>
1469
- <script src="/trash.js"></script>
1470
- <script src="/profile.js"></script>
1471
- <script src="/version.js"></script>
1472
- <script src="/sidebar.js"></script>
1506
+ <script src="/features/billing/store.js"></script>
1507
+ <script src="/features/billing/view.js"></script>
1508
+ <script src="/components/onboard.js"></script>
1509
+ <script src="/features/brand/store.js"></script>
1510
+ <script src="/features/brand/view.js"></script>
1511
+ <script src="/features/creator/store.js"></script>
1512
+ <script src="/features/creator/view.js"></script>
1513
+ <script src="/features/trash/store.js"></script>
1514
+ <script src="/features/trash/view.js"></script>
1515
+ <script src="/features/profile/store.js"></script>
1516
+ <script src="/features/profile/view.js"></script>
1517
+ <script src="/features/version/store.js"></script>
1518
+ <script src="/features/version/view.js"></script>
1519
+ <script src="/components/sidebar.js"></script>
1473
1520
  <script src="/vendor/qrcode/qrcode.min.js"></script>
1474
- <script src="/share.js"></script>
1521
+ <script src="/features/share/store.js"></script>
1522
+ <script src="/features/share/view.js"></script>
1475
1523
  <script src="/app.js"></script>
1476
- <script>Tooltip.init();</script>
1524
+ {{EXT_SCRIPTS}}
1525
+ <script>
1526
+ Tooltip.init();
1527
+ // Render every host-declared slot once extensions have registered.
1528
+ // Each [data-slot] element is a named injection point; renderSlot isolates
1529
+ // failing extensions so a crash degrades only that slot.
1530
+ (function () {
1531
+ document.querySelectorAll("[data-slot]").forEach(function (el) {
1532
+ Clacky.ext.renderSlot(el.getAttribute("data-slot"), el, {});
1533
+ });
1534
+ })();
1535
+ </script>
1477
1536
  </body>
1478
1537
  </html>
@@ -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;
@@ -1353,8 +1359,13 @@ const Sessions = (() => {
1353
1359
  }
1354
1360
  bubbleHtml += escapeHtml(ev.content || "");
1355
1361
  el.innerHTML = bubbleHtml;
1362
+ if (ev.created_at) el.dataset.createdAt = ev.created_at;
1356
1363
  _appendMsgTime(el, ev.created_at);
1357
- container.appendChild(el);
1364
+ const wrap = document.createElement("div");
1365
+ wrap.className = "msg-user-wrap";
1366
+ wrap.appendChild(el);
1367
+ _appendUserActionBar(el, wrap);
1368
+ container.appendChild(wrap);
1358
1369
  break;
1359
1370
  }
1360
1371
 
@@ -1694,6 +1705,151 @@ const Sessions = (() => {
1694
1705
  el.appendChild(span);
1695
1706
  }
1696
1707
 
1708
+ // ── User message action bar (copy + edit) ───────────────────────────────
1709
+
1710
+ const COPY_SVG = `<svg class="msg-user-copy-icon" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
1711
+ `<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"/>` +
1712
+ `</svg>` +
1713
+ `<svg class="msg-user-copy-icon-check" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
1714
+ `<path fill="currentColor" d="M13.5 3.5 6 11 2.5 7.5 1 9l5 5 9-9z"/>` +
1715
+ `</svg>`;
1716
+
1717
+ const EDIT_SVG = `<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
1718
+ `<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"/>` +
1719
+ `</svg>`;
1720
+
1721
+ function _appendUserActionBar(el, wrap) {
1722
+ el.dataset.originalHtml = el.innerHTML;
1723
+
1724
+ const bar = document.createElement("div");
1725
+ bar.className = "msg-user-actions";
1726
+
1727
+ const copyBtn = document.createElement("button");
1728
+ copyBtn.type = "button";
1729
+ copyBtn.className = "msg-user-action-btn";
1730
+ copyBtn.setAttribute("aria-label", I18n.t("chat.copy"));
1731
+ copyBtn.title = I18n.t("chat.copy");
1732
+ copyBtn.innerHTML = COPY_SVG;
1733
+ copyBtn.addEventListener("click", (e) => {
1734
+ e.stopPropagation();
1735
+ const text = _extractUserBubbleText(el);
1736
+ _copyTextAndFlash(copyBtn, text);
1737
+ });
1738
+
1739
+ const editBtn = document.createElement("button");
1740
+ editBtn.type = "button";
1741
+ editBtn.className = "msg-user-action-btn";
1742
+ editBtn.setAttribute("aria-label", I18n.t("chat.edit"));
1743
+ editBtn.title = I18n.t("chat.edit");
1744
+ editBtn.innerHTML = EDIT_SVG;
1745
+ editBtn.addEventListener("click", (e) => {
1746
+ e.stopPropagation();
1747
+ _enterEditMode(el);
1748
+ });
1749
+
1750
+ bar.appendChild(copyBtn);
1751
+ bar.appendChild(editBtn);
1752
+ wrap.appendChild(bar);
1753
+ }
1754
+
1755
+ function _extractUserBubbleText(el) {
1756
+ const clone = el.cloneNode(true);
1757
+ clone.querySelectorAll(".msg-user-actions, .msg-time").forEach(n => n.remove());
1758
+ return (clone.textContent || "").trim();
1759
+ }
1760
+
1761
+ function _enterEditMode(el) {
1762
+ if (el.classList.contains("editing")) return;
1763
+ el.classList.add("editing");
1764
+
1765
+ const originalHtml = el.dataset.originalHtml || "";
1766
+ const originalText = (() => {
1767
+ const tmp = document.createElement("div");
1768
+ tmp.innerHTML = originalHtml;
1769
+ tmp.querySelectorAll(".msg-user-actions, .msg-time, .msg-pdf-badge, img").forEach(n => n.remove());
1770
+ return (tmp.textContent || "").trim();
1771
+ })();
1772
+
1773
+ el.innerHTML = "";
1774
+
1775
+ const wrap = document.createElement("div");
1776
+ wrap.className = "msg-user-edit-wrap";
1777
+
1778
+ const textarea = document.createElement("textarea");
1779
+ textarea.className = "msg-user-edit-textarea";
1780
+ textarea.value = originalText;
1781
+ textarea.rows = 1;
1782
+
1783
+ const actions = document.createElement("div");
1784
+ actions.className = "msg-user-edit-actions";
1785
+
1786
+ const cancelBtn = document.createElement("button");
1787
+ cancelBtn.type = "button";
1788
+ cancelBtn.className = "msg-user-edit-cancel";
1789
+ cancelBtn.textContent = I18n.t("chat.cancel");
1790
+ cancelBtn.addEventListener("click", () => _exitEditMode(el));
1791
+
1792
+ const sendBtn = document.createElement("button");
1793
+ sendBtn.type = "button";
1794
+ sendBtn.className = "msg-user-edit-send";
1795
+ sendBtn.textContent = I18n.t("chat.send");
1796
+ sendBtn.addEventListener("click", () => _submitEdit(el, textarea.value.trim()));
1797
+
1798
+ textarea.addEventListener("keydown", (e) => {
1799
+ if (e.key === "Enter" && !e.shiftKey) {
1800
+ e.preventDefault();
1801
+ _submitEdit(el, textarea.value.trim());
1802
+ }
1803
+ if (e.key === "Escape") _exitEditMode(el);
1804
+ });
1805
+
1806
+ textarea.addEventListener("input", () => {
1807
+ textarea.style.height = "auto";
1808
+ textarea.style.height = textarea.scrollHeight + "px";
1809
+ });
1810
+
1811
+ actions.appendChild(cancelBtn);
1812
+ actions.appendChild(sendBtn);
1813
+ wrap.appendChild(textarea);
1814
+ wrap.appendChild(actions);
1815
+ el.appendChild(wrap);
1816
+
1817
+ requestAnimationFrame(() => {
1818
+ textarea.style.height = textarea.scrollHeight + "px";
1819
+ textarea.focus();
1820
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
1821
+ });
1822
+ }
1823
+
1824
+ function _exitEditMode(el) {
1825
+ el.classList.remove("editing");
1826
+ el.innerHTML = el.dataset.originalHtml || "";
1827
+ }
1828
+
1829
+ function _submitEdit(el, newContent) {
1830
+ if (!newContent) return;
1831
+ if (!Sessions.activeId) return;
1832
+
1833
+ const createdAt = el.dataset.createdAt || null;
1834
+
1835
+ const messages = el.closest("#messages, .messages");
1836
+ if (messages) {
1837
+ const wrap = el.parentElement;
1838
+ let sibling = wrap ? wrap.nextSibling : el.nextSibling;
1839
+ while (sibling) {
1840
+ const next = sibling.nextSibling;
1841
+ sibling.remove();
1842
+ sibling = next;
1843
+ }
1844
+ }
1845
+
1846
+ _exitEditMode(el);
1847
+
1848
+ WS.send({ type: "edit_message", session_id: Sessions.activeId, content: newContent, created_at: createdAt });
1849
+
1850
+ if (messages) messages.scrollTop = messages.scrollHeight;
1851
+ }
1852
+
1697
1853
  // ── Copy button for assistant messages ──────────────────────────────────
1698
1854
  //
1699
1855
  // Each assistant bubble gets a small copy button in its top-right corner.
@@ -2017,7 +2173,41 @@ const Sessions = (() => {
2017
2173
  get all() { return _sessions; },
2018
2174
  get activeId() { return _activeId; },
2019
2175
  get searchOpen() { return _searchOpen; },
2020
- find: id => _sessions.find(s => s.id === id),
2176
+ find: id => _sessions.find(s => s.id === id)
2177
+ || _extraSessions.find(s => s.id === id),
2178
+
2179
+ // Async variant of `find`: when not found in memory, falls back to
2180
+ // GET /api/sessions/:id which returns the on-disk session merged with
2181
+ // any live in-memory state (see SessionRegistry#snapshot in
2182
+ // session_registry.rb). Resolved rows are cached in `_extraSessions`
2183
+ // so subsequent synchronous `find` calls hit too. Returns null on
2184
+ // 404 / network error.
2185
+ //
2186
+ // Use this in code paths where missing-id should NOT silently fail
2187
+ // (Router navigation: search clicks, URL deep links, share links,
2188
+ // browser back/forward, notification jumps). For tight synchronous
2189
+ // paths (WS dispatch, status updates) keep using `find`.
2190
+ async findOrFetch(id) {
2191
+ if (!id) return null;
2192
+ const local = _sessions.find(s => s.id === id)
2193
+ || _extraSessions.find(s => s.id === id);
2194
+ if (local) return local;
2195
+ try {
2196
+ const resp = await fetch(`/api/sessions/${encodeURIComponent(id)}`);
2197
+ if (!resp.ok) return null;
2198
+ const data = await resp.json();
2199
+ if (!data || !data.session) return null;
2200
+ // Race guard: another caller may have hydrated meanwhile.
2201
+ if (!_sessions.find(s => s.id === id)
2202
+ && !_extraSessions.find(s => s.id === id)) {
2203
+ _extraSessions.push(data.session);
2204
+ }
2205
+ return data.session;
2206
+ } catch (e) {
2207
+ console.error("Sessions.findOrFetch failed:", e);
2208
+ return null;
2209
+ }
2210
+ },
2021
2211
 
2022
2212
  // Composer entry point — called by Skill autocomplete keydown handler
2023
2213
  // (in app.js) when the user presses Enter without an active completion.
@@ -3265,26 +3455,31 @@ const Sessions = (() => {
3265
3455
  }
3266
3456
  if (type === "user" && time) _appendMsgTime(el, time);
3267
3457
 
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);
3458
+ if (type === "user") {
3459
+ const wrap = document.createElement("div");
3460
+ wrap.className = "msg-user-wrap";
3461
+ wrap.appendChild(el);
3462
+ _appendUserActionBar(el, wrap);
3463
+ messages.appendChild(wrap);
3464
+ } else {
3465
+ // For error messages, add a retry button
3466
+ if (type === "error") {
3467
+ const retryBtn = document.createElement("button");
3468
+ retryBtn.className = "retry-btn";
3469
+ retryBtn.textContent = I18n.t("chat.retry");
3470
+ retryBtn.onclick = () => {
3471
+ if (!_activeId) return;
3472
+ WS.send({
3473
+ type: "message",
3474
+ session_id: _activeId,
3475
+ content: I18n.t("chat.continue")
3476
+ });
3477
+ retryBtn.disabled = true;
3478
+ };
3479
+ el.appendChild(retryBtn);
3480
+ }
3481
+ messages.appendChild(el);
3285
3482
  }
3286
-
3287
- messages.appendChild(el);
3288
3483
  // User messages: force scroll to bottom (user just sent a message)
3289
3484
  // Assistant/info: conditional scroll (preserve position if user is viewing history)
3290
3485
  if (type === "user") {
@@ -4418,7 +4613,7 @@ const Sessions = (() => {
4418
4613
  // ── Tree-based directory picker ─────────────────────────────────────────
4419
4614
  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>';
4420
4615
  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>';
4421
- function showDirectoryPicker(currentDir, sessionId) {
4616
+ function showDirectoryPicker(currentDir, sessionId, titleText) {
4422
4617
  return new Promise((resolve) => {
4423
4618
  const t = (key, fallback) => {
4424
4619
  const s = I18n.t(key);
@@ -4570,9 +4765,10 @@ const Sessions = (() => {
4570
4765
  // Title
4571
4766
  const title = document.createElement("div");
4572
4767
  title.className = "modal-title";
4573
- title.textContent = sessionLess
4574
- ? t("sessions.modal.dirpicker.title", "选择工作目录")
4575
- : t("sib.dir.changePrompt", "切换工作目录");
4768
+ title.textContent = titleText
4769
+ || (sessionLess
4770
+ ? t("sessions.modal.dirpicker.title", "选择工作目录")
4771
+ : t("sib.dir.changePrompt", "切换工作目录"));
4576
4772
  modal.appendChild(title);
4577
4773
 
4578
4774
  // Modal body