openclacky 1.3.1 → 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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -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 +65 -11
  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/brand_config.rb +1 -1
  11. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  12. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  13. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  14. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  15. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  16. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  17. data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
  18. data/lib/clacky/media/openai_compat.rb +64 -1
  19. data/lib/clacky/media/output_dir.rb +43 -0
  20. data/lib/clacky/message_history.rb +9 -0
  21. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  22. data/lib/clacky/server/git_panel.rb +115 -0
  23. data/lib/clacky/server/http_server.rb +521 -13
  24. data/lib/clacky/server/server_master.rb +6 -4
  25. data/lib/clacky/utils/environment_detector.rb +16 -0
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +512 -60
  28. data/lib/clacky/web/app.js +30 -7
  29. data/lib/clacky/web/components/code-editor.js +197 -0
  30. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  31. data/lib/clacky/web/core/aside.js +112 -0
  32. data/lib/clacky/web/core/ext.js +387 -0
  33. data/lib/clacky/web/features/backup/store.js +92 -0
  34. data/lib/clacky/web/features/backup/view.js +94 -0
  35. data/lib/clacky/web/features/billing/store.js +163 -0
  36. data/lib/clacky/web/{billing.js → features/billing/view.js} +134 -242
  37. data/lib/clacky/web/features/brand/store.js +110 -0
  38. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  39. data/lib/clacky/web/features/channels/store.js +103 -0
  40. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  41. data/lib/clacky/web/features/creator/store.js +81 -0
  42. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  43. data/lib/clacky/web/features/mcp/store.js +158 -0
  44. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  45. data/lib/clacky/web/features/model-tester/store.js +77 -0
  46. data/lib/clacky/web/features/model-tester/view.js +7 -0
  47. data/lib/clacky/web/features/profile/store.js +170 -0
  48. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  49. data/lib/clacky/web/features/share/store.js +145 -0
  50. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  51. data/lib/clacky/web/features/skills/store.js +303 -0
  52. data/lib/clacky/web/features/skills/view.js +550 -0
  53. data/lib/clacky/web/features/tasks/store.js +135 -0
  54. data/lib/clacky/web/features/tasks/view.js +241 -0
  55. data/lib/clacky/web/features/trash/store.js +242 -0
  56. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  57. data/lib/clacky/web/features/version/store.js +165 -0
  58. data/lib/clacky/web/features/version/view.js +323 -0
  59. data/lib/clacky/web/features/workspace/store.js +99 -0
  60. data/lib/clacky/web/features/workspace/view.js +305 -0
  61. data/lib/clacky/web/i18n.js +60 -6
  62. data/lib/clacky/web/index.html +117 -57
  63. data/lib/clacky/web/sessions.js +221 -25
  64. data/lib/clacky/web/settings.js +121 -25
  65. data/lib/clacky/web/skills.js +3 -821
  66. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  67. data/lib/clacky.rb +1 -0
  68. metadata +45 -20
  69. data/lib/clacky/web/backup.js +0 -119
  70. data/lib/clacky/web/model-tester.js +0 -66
  71. data/lib/clacky/web/tasks.js +0 -365
  72. data/lib/clacky/web/version.js +0 -449
  73. data/lib/clacky/web/workspace.js +0 -212
  74. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  75. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  76. /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
  77. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  78. /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,32 +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-header">
447
- <span id="workspace-title" data-i18n="workspace.title">Workspace</span>
448
- <div class="workspace-header-actions">
449
- <button id="btn-workspace-refresh" class="workspace-icon-btn" data-i18n-title="workspace.refresh" title="Refresh">
450
- <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">
451
- <path d="M23 4v6h-6"/><path d="M1 20v-6h6"/>
452
- <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"/>
453
- </svg>
454
- </button>
455
- <button id="btn-workspace-close" class="workspace-icon-btn" data-i18n-title="workspace.collapse" title="Collapse">
456
- <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">
457
- <path d="M9 18l6-6-6-6"/>
458
- </svg>
459
- </button>
460
- </div>
461
- </div>
462
- <div id="workspace-tree" role="tree"></div>
463
- </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>
464
469
 
465
470
  <!-- Collapsed-state opener tab -->
466
- <button id="btn-workspace-open" data-i18n-title="workspace.expand" title="Open workspace" style="display:none">
467
- <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">
468
- <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"/>
469
- </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>
470
474
  </button>
471
475
  </div>
472
476
 
@@ -740,7 +744,10 @@
740
744
  <div id="profile-soul-body" class="profile-markdown"></div>
741
745
  <div class="profile-pane-footer">
742
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>
743
- <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>
744
751
  </div>
745
752
  </section>
746
753
 
@@ -756,7 +763,10 @@
756
763
  <div id="profile-user-body" class="profile-markdown"></div>
757
764
  <div class="profile-pane-footer">
758
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>
759
- <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>
760
770
  </div>
761
771
  </section>
762
772
 
@@ -796,9 +806,14 @@
796
806
  <button class="settings-tab" data-tab="ui" data-i18n="settings.tabs.ui">UI</button>
797
807
  <button class="settings-tab" data-tab="general" data-i18n="settings.tabs.general">General</button>
798
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>
799
811
  </div>
800
812
 
801
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
+
802
817
 
803
818
  <!-- ══ Tab: Models ══ -->
804
819
  <div class="settings-tab-content active" data-tab-content="models">
@@ -820,6 +835,19 @@
820
835
  Optional. Image / video / audio / vision models.
821
836
  </div>
822
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>
823
851
  </section>
824
852
  </div>
825
853
 
@@ -933,22 +961,24 @@
933
961
  </div>
934
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>
935
963
 
936
- <div class="backup-auto-row">
937
- <label class="toggle-switch">
938
- <input type="checkbox" id="backup-auto-toggle">
939
- <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>
940
977
  </label>
941
- <span class="backup-auto-label" data-i18n="settings.backup.autoLabel">Automatic backup</span>
942
- <span class="backup-auto-hint" data-i18n="settings.backup.autoHint">Daily at 03:00, keeps the latest 7</span>
943
978
  </div>
944
979
 
945
- <label class="backup-option">
946
- <input type="checkbox" id="backup-include-sessions">
947
- <span data-i18n="settings.backup.includeSessions">Include session history (larger archive)</span>
948
- </label>
949
-
950
980
  <div class="backup-actions">
951
- <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>
952
982
  <span id="backup-status" class="model-test-result"></span>
953
983
  </div>
954
984
  </section>
@@ -1440,38 +1470,68 @@
1440
1470
 
1441
1471
  <div id="tooltip" style="display:none"></div>
1442
1472
 
1443
- <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>
1444
1476
  <script src="/vendor/hljs/highlight.min.js"></script>
1445
1477
  <script src="/vendor/katex/katex.min.js"></script>
1446
1478
  <script src="/vendor/katex/auto-render.min.js"></script>
1447
1479
  <script src="/utils.js"></script>
1480
+ <script src="/core/ext.js"></script>
1481
+ <script src="/core/aside.js"></script>
1448
1482
  <script src="/i18n.js"></script>
1449
1483
  <script src="/auth.js"></script>
1450
1484
  <script src="/theme.js"></script>
1451
- <script src="/notify.js"></script>
1485
+ <script src="/components/notify.js"></script>
1452
1486
  <script src="/ws.js"></script>
1453
1487
  <script src="/ws-dispatcher.js"></script>
1454
1488
  <script src="/sessions.js"></script>
1455
- <script src="/workspace.js"></script>
1456
- <script src="/datepicker.js"></script>
1457
- <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>
1458
1498
  <script src="/skills.js"></script>
1459
- <script src="/channels.js"></script>
1460
- <script src="/mcp.js"></script>
1461
- <script src="/backup.js"></script>
1462
- <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>
1463
1505
  <script src="/settings.js"></script>
1464
- <script src="/billing.js"></script>
1465
- <script src="/onboard.js"></script>
1466
- <script src="/brand.js"></script>
1467
- <script src="/creator.js"></script>
1468
- <script src="/trash.js"></script>
1469
- <script src="/profile.js"></script>
1470
- <script src="/version.js"></script>
1471
- <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>
1472
1520
  <script src="/vendor/qrcode/qrcode.min.js"></script>
1473
- <script src="/share.js"></script>
1521
+ <script src="/features/share/store.js"></script>
1522
+ <script src="/features/share/view.js"></script>
1474
1523
  <script src="/app.js"></script>
1475
- <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>
1476
1536
  </body>
1477
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