@bitseek/hermes-webui 0.1.0-beta.0 → 0.1.0

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 (99) hide show
  1. package/package.json +2 -2
  2. package/vendor/agent-frontend-shell/.bitseek-source.json +2 -2
  3. package/vendor/agent-frontend-shell/CHANGELOG.md +178 -1
  4. package/vendor/agent-frontend-shell/CONTRIBUTORS.md +5 -5
  5. package/vendor/agent-frontend-shell/api/agent_health.py +134 -0
  6. package/vendor/agent-frontend-shell/api/config.py +145 -104
  7. package/vendor/agent-frontend-shell/api/gateway_chat.py +56 -12
  8. package/vendor/agent-frontend-shell/api/helpers.py +4 -2
  9. package/vendor/agent-frontend-shell/api/models.py +202 -20
  10. package/vendor/agent-frontend-shell/api/paths.py +77 -0
  11. package/vendor/agent-frontend-shell/api/plugins.py +185 -0
  12. package/vendor/agent-frontend-shell/api/profiles.py +95 -16
  13. package/vendor/agent-frontend-shell/api/routes.py +831 -30
  14. package/vendor/agent-frontend-shell/api/run_journal.py +1 -0
  15. package/vendor/agent-frontend-shell/api/state_sync.py +5 -4
  16. package/vendor/agent-frontend-shell/api/streaming.py +211 -56
  17. package/vendor/agent-frontend-shell/api/todo_state.py +122 -0
  18. package/vendor/agent-frontend-shell/api/updates.py +30 -3
  19. package/vendor/agent-frontend-shell/api/upload.py +251 -18
  20. package/vendor/agent-frontend-shell/api/workspace.py +323 -65
  21. package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_EN.docx +0 -0
  22. package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_ZH.docx +0 -0
  23. package/vendor/agent-frontend-shell/bitseek_docs/en/00-Installation.md +174 -0
  24. package/vendor/agent-frontend-shell/bitseek_docs/en/01-Overview.md +128 -0
  25. package/vendor/agent-frontend-shell/bitseek_docs/en/02-Page-Operations.md +461 -0
  26. package/vendor/agent-frontend-shell/bitseek_docs/en/README.md +61 -0
  27. package/vendor/agent-frontend-shell/bitseek_docs/en/images/ai-colleagues.png +0 -0
  28. package/vendor/agent-frontend-shell/bitseek_docs/en/images/chat-area.png +0 -0
  29. package/vendor/agent-frontend-shell/bitseek_docs/en/images/kanban.png +0 -0
  30. package/vendor/agent-frontend-shell/bitseek_docs/en/images/main-page.png +0 -0
  31. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-notes.png +0 -0
  32. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-overview.png +0 -0
  33. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-profile.png +0 -0
  34. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-soul.png +0 -0
  35. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory.png +0 -0
  36. package/vendor/agent-frontend-shell/bitseek_docs/en/images/navigation-bar.png +0 -0
  37. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-appearance.png +0 -0
  38. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-conversation.png +0 -0
  39. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-overview.png +0 -0
  40. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-plugins.png +0 -0
  41. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-preferences.png +0 -0
  42. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-providers.png +0 -0
  43. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-system.png +0 -0
  44. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings.png +0 -0
  45. package/vendor/agent-frontend-shell/bitseek_docs/en/images/sidebar.png +0 -0
  46. package/vendor/agent-frontend-shell/bitseek_docs/en/images/skills.png +0 -0
  47. package/vendor/agent-frontend-shell/bitseek_docs/en/images/tasks.png +0 -0
  48. package/vendor/agent-frontend-shell/bitseek_docs/en/images/workspace-panel.png +0 -0
  49. package/vendor/agent-frontend-shell/bitseek_docs/md_to_docx.py +351 -0
  50. package/vendor/agent-frontend-shell/bitseek_docs/zh/00-/345/256/211/350/243/205/345/220/257/345/212/250.md +174 -0
  51. package/vendor/agent-frontend-shell/bitseek_docs/zh/01-/346/225/264/344/275/223/346/246/202/350/247/210.md +128 -0
  52. package/vendor/agent-frontend-shell/bitseek_docs/zh/02-/351/241/265/351/235/242/346/223/215/344/275/234.md +463 -0
  53. package/vendor/agent-frontend-shell/bitseek_docs/zh/README.md +61 -0
  54. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/ai-colleagues.png +0 -0
  55. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/chat-area.png +0 -0
  56. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/kanban.png +0 -0
  57. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/main-page.png +0 -0
  58. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-notes.png +0 -0
  59. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-overview.png +0 -0
  60. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-profile.png +0 -0
  61. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-soul.png +0 -0
  62. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory.png +0 -0
  63. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/navigation-bar.png +0 -0
  64. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-appearance.png +0 -0
  65. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-conversation.png +0 -0
  66. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-overview.png +0 -0
  67. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-plugins.png +0 -0
  68. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-preferences.png +0 -0
  69. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-providers.png +0 -0
  70. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-system.png +0 -0
  71. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings.png +0 -0
  72. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/sidebar.png +0 -0
  73. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/skills.png +0 -0
  74. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/tasks.png +0 -0
  75. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/workspace-panel.png +0 -0
  76. package/vendor/agent-frontend-shell/build-release.sh +62 -0
  77. package/vendor/agent-frontend-shell/ctl.sh +1 -0
  78. package/vendor/agent-frontend-shell/docker-compose.local.yml +33 -0
  79. package/vendor/agent-frontend-shell/docker-compose.yml +8 -0
  80. package/vendor/agent-frontend-shell/docker_init.bash +1 -0
  81. package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +74 -15
  82. package/vendor/agent-frontend-shell/extensions/common/index.css +6 -0
  83. package/vendor/agent-frontend-shell/extensions/manifest.json +6 -0
  84. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +60 -14
  85. package/vendor/agent-frontend-shell/readme-simple.md +103 -0
  86. package/vendor/agent-frontend-shell/requirements.txt +5 -0
  87. package/vendor/agent-frontend-shell/server.py +7 -0
  88. package/vendor/agent-frontend-shell/static/boot.js +53 -1
  89. package/vendor/agent-frontend-shell/static/commands.js +20 -10
  90. package/vendor/agent-frontend-shell/static/i18n.js +1142 -1016
  91. package/vendor/agent-frontend-shell/static/index.html +13 -3
  92. package/vendor/agent-frontend-shell/static/messages.js +48 -3
  93. package/vendor/agent-frontend-shell/static/panels.js +199 -30
  94. package/vendor/agent-frontend-shell/static/sessions.js +249 -39
  95. package/vendor/agent-frontend-shell/static/style.css +46 -2
  96. package/vendor/agent-frontend-shell/static/ui.js +323 -79
  97. package/vendor/agent-frontend-shell/static/workspace.js +185 -7
  98. package/vendor/agent-frontend-shell/README-CUSTOM.md +0 -76
  99. package/vendor/agent-frontend-shell/docker-compose.custom.yml +0 -26
@@ -10,6 +10,7 @@ const ICONS={
10
10
  trash:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M3.5 4.5h9M6.5 4.5V3h3v1.5M4.5 4.5v8.5h7v-8.5"/><line x1="7" y1="7" x2="7" y2="11"/><line x1="9" y1="7" x2="9" y2="11"/></svg>',
11
11
  more:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><circle cx="8" cy="3" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>',
12
12
  edit:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><path d="M11.5 2.5l2 2L5 13H3v-2z"/><path d="M10 4l2 2"/></svg>',
13
+ spark:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><path d="M8 1.8l1.1 3.1 3.1 1.1-3.1 1.1L8 10.2 6.9 7.1 3.8 6l3.1-1.1z"/><path d="M12.5 9.5l.5 1.5 1.5.5-1.5.5-.5 1.5-.5-1.5-1.5-.5 1.5-.5z"/></svg>',
13
14
  link:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><path d="M6.7 9.3a3 3 0 0 1 0-4.2l1.7-1.7a3 3 0 0 1 4.2 4.2l-1 1"/><path d="M9.3 6.7a3 3 0 0 1 0 4.2l-1.7 1.7a3 3 0 0 1-4.2-4.2l1-1"/></svg>',
14
15
  };
15
16
 
@@ -17,6 +18,13 @@ const ICONS={
17
18
  // responses from in-flight requests when the user switches sessions again
18
19
  // before the first request completes (#1060).
19
20
  let _loadingSessionId = null;
21
+ // #3306: Snapshot of S.messages captured by loadSession() right before it
22
+ // clears them on a force-reload of the active session. Consumed by
23
+ // _ensureMessagesLoaded() when calling _carryForwardEphemeralTurnFields so
24
+ // ephemeral fields (_turnUsage, _turnDuration, _turnTps, _gatewayRouting,
25
+ // _statusCard) survive the wholesale replace. null when there is nothing
26
+ // to carry forward (initial load, switch-to-different-session, etc.).
27
+ let _pendingCarryForwardSnapshot = null;
20
28
 
21
29
  // ── Composer draft persistence ────────────────────────────────────────────────
22
30
 
@@ -589,6 +597,7 @@ async function loadSession(sid){
589
597
  }
590
598
  const forceReload = !!opts.force;
591
599
  const currentSid = S.session ? S.session.session_id : null;
600
+ const sameSessionForceReload = forceReload && currentSid===sid;
592
601
  // Clicking the already-open session in the sidebar is a no-op. Reloading it
593
602
  // tears down active pane state and can reset the long-session scroll window
594
603
  // to the top even though the user did not navigate anywhere. Explicit
@@ -611,6 +620,23 @@ async function loadSession(sid){
611
620
  _saveComposerDraftNow(currentSid, ($('msg') || {}).value || '', S.pendingFiles ? [...S.pendingFiles] : []);
612
621
  }
613
622
  if (currentSid !== sid || forceReload) {
623
+ // #3306: When force-reloading the currently-active session (e.g. external
624
+ // poll triggering a refresh), snapshot the existing messages BEFORE we
625
+ // clear them. _ensureMessagesLoaded() runs the ephemeral-field
626
+ // carry-forward (_turnUsage, _turnDuration, _turnTps, _gatewayRouting,
627
+ // _statusCard) against S.messages, but by the time the API fetch returns
628
+ // S.messages has already been reset to [] here and the carry-forward is a
629
+ // no-op. The visible symptom is the token-usage badge vanishing ~10s
630
+ // after each assistant turn completes. Stash the snapshot so the
631
+ // carry-forward call can consume it.
632
+ _pendingCarryForwardSnapshot = (currentSid === sid && forceReload)
633
+ ? (S.messages || []).slice()
634
+ : null;
635
+ // #3239: also capture a reload-width hint BEFORE clearing so the
636
+ // authoritative reload preserves the already-loaded transcript width
637
+ // instead of collapsing a long session back to the default tail window.
638
+ if (sameSessionForceReload) _captureSameSessionForceReloadHint(sid);
639
+ else _clearSameSessionForceReloadHint();
614
640
  S.messages = [];
615
641
  S.toolCalls = [];
616
642
  _messagesTruncated = false;
@@ -652,12 +678,14 @@ async function loadSession(sid){
652
678
  if(typeof showToast==='function') showToast('Failed to load session',3000,'error');
653
679
  }
654
680
  }
681
+ _clearSameSessionForceReloadHint(sid);
655
682
  if (_loadingSessionId === sid) _loadingSessionId = null;
656
683
  return;
657
684
  }
658
685
  // Guard: api() may have redirected (401) and returned undefined; in that case
659
686
  // the browser is already navigating away, so abort the rest of this flow.
660
687
  if (!data) {
688
+ _clearSameSessionForceReloadHint(sid);
661
689
  if (_loadingSessionId === sid) _loadingSessionId = null;
662
690
  return;
663
691
  }
@@ -739,7 +767,7 @@ async function loadSession(sid){
739
767
  // replaying persisted live tools so the compact Activity count survives
740
768
  // switching away from and back to an active chat (#1715).
741
769
  S.activeStreamId=activeStreamId;
742
- syncTopbar();renderMessages();
770
+ syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);
743
771
  const restoredLiveTurn=typeof restoreLiveTurnHtmlForSession==='function'&&restoreLiveTurnHtmlForSession(sid);
744
772
  if(!restoredLiveTurn){
745
773
  appendThinking();
@@ -823,7 +851,7 @@ async function loadSession(sid){
823
851
  updateSendBtn();
824
852
  setStatus('');
825
853
  setComposerStatus('');
826
- syncTopbar();renderMessages();appendThinking();loadDir('.');
854
+ syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);appendThinking();loadDir('.');
827
855
  updateQueueBadge(sid);
828
856
  startApprovalPolling(sid);
829
857
  if(typeof startClarifyPolling==='function') startClarifyPolling(sid);
@@ -837,7 +865,7 @@ async function loadSession(sid){
837
865
  setStatus('');
838
866
  setComposerStatus('');
839
867
  updateQueueBadge(sid);
840
- syncTopbar();renderMessages();
868
+ syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined);
841
869
  if(typeof resumeManualCompressionForSession==='function') resumeManualCompressionForSession(sid);
842
870
  const _dirP=loadDir('.');
843
871
  // Workspace refresh is guarded by session id inside loadDir(); do not
@@ -1317,6 +1345,57 @@ let _messagesTruncated = false;
1317
1345
  // msg_limit (default 30): only fetch the last N messages for fast switching.
1318
1346
  // Older messages are loaded on-demand via _loadOlderMessages().
1319
1347
  const _INITIAL_MSG_LIMIT = 30;
1348
+ let _sameSessionForceReloadHint = null;
1349
+
1350
+ function _currentLoadedRenderableMessageCount(){
1351
+ if(typeof _messageRenderableMessageCount==='function'){
1352
+ try{return Math.max(0,Number(_messageRenderableMessageCount())||0);}
1353
+ catch(_){}
1354
+ }
1355
+ let count=0;
1356
+ for(const m of (S.messages||[])){
1357
+ if(m&&m.role&&m.role!=='tool') count++;
1358
+ }
1359
+ return count;
1360
+ }
1361
+
1362
+ function _captureSameSessionForceReloadHint(sid){
1363
+ const loadedRenderableCount=_currentLoadedRenderableMessageCount();
1364
+ const loadedMessageCount=Array.isArray(S.messages)?S.messages.length:0;
1365
+ const knownMessageCount=Number(S.session&&S.session.session_id===sid&&S.session.message_count)||loadedMessageCount;
1366
+ if(!sid || (loadedRenderableCount<=0 && loadedMessageCount<=0)){
1367
+ _sameSessionForceReloadHint=null;
1368
+ return;
1369
+ }
1370
+ _sameSessionForceReloadHint={
1371
+ session_id:sid,
1372
+ loaded_renderable_count:loadedRenderableCount,
1373
+ loaded_message_count:loadedMessageCount,
1374
+ message_count:knownMessageCount,
1375
+ truncated:!!_messagesTruncated,
1376
+ };
1377
+ }
1378
+
1379
+ function _clearSameSessionForceReloadHint(sid){
1380
+ if(!_sameSessionForceReloadHint) return;
1381
+ if(!sid || _sameSessionForceReloadHint.session_id===sid) _sameSessionForceReloadHint=null;
1382
+ }
1383
+
1384
+ function _messageReloadLimitForSession(sid){
1385
+ const hint=_sameSessionForceReloadHint;
1386
+ if(hint&&hint.session_id===sid){
1387
+ const loadedRenderableCount=Math.max(0,Number(hint.loaded_renderable_count)||0);
1388
+ const loadedMessageCount=Math.max(0,Number(hint.loaded_message_count)||0);
1389
+ if(loadedRenderableCount>0 || loadedMessageCount>0){
1390
+ if(!hint.truncated) return null;
1391
+ const previousMessageCount=Math.max(0,Number(hint.message_count)||0);
1392
+ const currentMessageCount=Math.max(0,Number(S.session&&S.session.session_id===sid&&S.session.message_count)||0);
1393
+ const appendedMessageCount=Math.max(0,currentMessageCount-previousMessageCount);
1394
+ return Math.max(_INITIAL_MSG_LIMIT,loadedRenderableCount,loadedMessageCount+appendedMessageCount);
1395
+ }
1396
+ }
1397
+ return _INITIAL_MSG_LIMIT;
1398
+ }
1320
1399
 
1321
1400
  function _syncToolCallsForLoadedMessages(messages, sessionToolCalls){
1322
1401
  const msgs=Array.isArray(messages)?messages:[];
@@ -1336,10 +1415,18 @@ function _syncToolCallsForLoadedMessages(messages, sessionToolCalls){
1336
1415
  async function _ensureMessagesLoaded(sid) {
1337
1416
  // Already have messages? (e.g. from INFLIGHT restore path, already set)
1338
1417
  if (S.messages && S.messages.length > 0 && S.messages[0] && S.messages[0].role) {
1418
+ _clearSameSessionForceReloadHint(sid);
1339
1419
  return;
1340
1420
  }
1341
1421
  // Fetch session messages with a tail window for fast initial load.
1342
- const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_limit=${_INITIAL_MSG_LIMIT}`);
1422
+ const reloadLimit = _messageReloadLimitForSession(sid); // defaults to _INITIAL_MSG_LIMIT
1423
+ const reloadLimitParam = reloadLimit ? `&msg_limit=${reloadLimit}` : '';
1424
+ let data;
1425
+ try {
1426
+ data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0${reloadLimitParam}`);
1427
+ } finally {
1428
+ _clearSameSessionForceReloadHint(sid);
1429
+ }
1343
1430
  // Guard: api() may have redirected (401) and returned undefined.
1344
1431
  if (!data || !data.session) return;
1345
1432
  _messagesTruncated = !!data.session._messages_truncated;
@@ -1355,11 +1442,21 @@ async function _ensureMessagesLoaded(sid) {
1355
1442
  // #3018: preserve client-side ephemeral turn fields (_turnUsage, _turnDuration,
1356
1443
  // _turnTps, _gatewayRouting, _statusCard) across the loadSession replace.
1357
1444
  if(typeof window._carryForwardEphemeralTurnFields==='function'){
1358
- msgs=window._carryForwardEphemeralTurnFields(S.messages||[], msgs);
1359
- }
1445
+ // #3306: Prefer the pre-clear snapshot stashed by loadSession() on a
1446
+ // force-reload of the active session; S.messages was reset to [] there
1447
+ // and would otherwise yield an empty carry-forward.
1448
+ const _prev = (Array.isArray(_pendingCarryForwardSnapshot) && _pendingCarryForwardSnapshot.length)
1449
+ ? _pendingCarryForwardSnapshot
1450
+ : (S.messages || []);
1451
+ msgs=window._carryForwardEphemeralTurnFields(_prev, msgs);
1452
+ _pendingCarryForwardSnapshot = null;
1453
+ }
1454
+ if(typeof clearVisibleMessageRowCache==='function') clearVisibleMessageRowCache();
1360
1455
  S.messages = msgs;
1361
1456
  if(S.session&&S.session.session_id===sid){
1362
1457
  S.session.message_count=Number(data.session.message_count || msgs.length);
1458
+ if(Object.prototype.hasOwnProperty.call(data.session,'todo_state')) S.session.todo_state=data.session.todo_state;
1459
+ else delete S.session.todo_state;
1363
1460
  S.lastUsage={...(data.session.last_usage||S.lastUsage||{})};
1364
1461
  _setSessionViewedCount(sid, Number(S.session.message_count || msgs.length));
1365
1462
  }
@@ -1512,6 +1609,12 @@ async function _loadOlderMessages() {
1512
1609
  // Use $('messages') — the scrollable container (#msgInner is not scrollable).
1513
1610
  const container = $('messages');
1514
1611
  const prevScrollH = container ? container.scrollHeight : 0;
1612
+ // Carry forward ephemeral turn fields (_turnUsage/_turnDuration/_turnTps/
1613
+ // _gatewayRouting/_statusCard) before the wholesale replace so the badge
1614
+ // does not briefly appear and disappear during older-message expansion.
1615
+ if (typeof window._carryForwardEphemeralTurnFields === 'function') {
1616
+ nextMessages = window._carryForwardEphemeralTurnFields(S.messages || [], nextMessages);
1617
+ }
1515
1618
  S.messages = nextMessages;
1516
1619
  _syncToolCallsForLoadedMessages(nextMessages, responseSession.tool_calls);
1517
1620
  // renderMessages() windows long transcripts from the end. If we do not
@@ -1595,7 +1698,15 @@ async function _ensureAllMessagesLoaded() {
1595
1698
  // prefetch (whose snapshot was taken before this call's mutex
1596
1699
  // acquisition) sees the new value and aborts.
1597
1700
  _bumpMessagesGeneration();
1598
- S.messages = msgs;
1701
+ // #3306: Same ephemeral-field carry-forward as _ensureMessagesLoaded.
1702
+ // Loading older messages also does a wholesale replace of S.messages
1703
+ // and would otherwise drop _turnUsage/_turnDuration/_turnTps/
1704
+ // _gatewayRouting/_statusCard on the existing turns.
1705
+ let _msgsToAssign = msgs;
1706
+ if (typeof window._carryForwardEphemeralTurnFields === 'function') {
1707
+ _msgsToAssign = window._carryForwardEphemeralTurnFields(S.messages || [], msgs);
1708
+ }
1709
+ S.messages = _msgsToAssign;
1599
1710
  _messagesTruncated = false;
1600
1711
  _oldestIdx = 0;
1601
1712
  _syncToolCallsForLoadedMessages(msgs, data.session.tool_calls);
@@ -1991,12 +2102,28 @@ function _positionSessionActionMenu(anchorEl){
1991
2102
  if(left+menuW>window.innerWidth-8) left=window.innerWidth-menuW-8;
1992
2103
  _sessionActionMenu.style.left=left+'px';
1993
2104
  _sessionActionMenu.style.top='8px';
2105
+ // Reset any prior clamp so we measure the menu's natural height.
2106
+ _sessionActionMenu.style.maxHeight='';
1994
2107
  const menuH=_sessionActionMenu.offsetHeight || 0;
2108
+ const margin=8;
2109
+ const maxAvail=window.innerHeight-margin*2;
1995
2110
  let top=rect.bottom+6;
1996
- if(top+menuH>window.innerHeight-8 && rect.top>menuH+12){
2111
+ // Prefer flipping above the row when the menu would overflow the bottom and
2112
+ // there's room above.
2113
+ if(top+menuH>window.innerHeight-margin && rect.top>menuH+12){
1997
2114
  top=rect.top-menuH-6;
1998
2115
  }
1999
- if(top<8) top=8;
2116
+ // If the menu is taller than the viewport, or still overflows after the flip
2117
+ // attempt (e.g. a top-anchored row with a tall menu and no room above), cap
2118
+ // its height to the viewport and let it scroll instead of clipping off-screen.
2119
+ if(menuH>maxAvail){
2120
+ _sessionActionMenu.style.maxHeight=maxAvail+'px';
2121
+ top=margin;
2122
+ } else {
2123
+ // Clamp vertically so the whole menu stays on-screen at both edges.
2124
+ if(top+menuH>window.innerHeight-margin) top=window.innerHeight-margin-menuH;
2125
+ if(top<margin) top=margin;
2126
+ }
2000
2127
  _sessionActionMenu.style.top=top+'px';
2001
2128
  }
2002
2129
 
@@ -2004,12 +2131,17 @@ function _buildSessionAction(label, meta, icon, onSelect, extraClass=''){
2004
2131
  const opt=document.createElement('button');
2005
2132
  opt.type='button';
2006
2133
  opt.className='ws-opt session-action-opt'+(extraClass?` ${extraClass}`:'');
2134
+ // Compact context-menu shape (#3223 redesign, Nathan 2026-06-01): show only
2135
+ // icon + label, matching VS Code / browser / ChatGPT conversation menus. The
2136
+ // descriptive `meta` is preserved as a hover tooltip (title=) so the
2137
+ // information stays discoverable without consuming permanent vertical space —
2138
+ // this also keeps the menu short enough to avoid viewport clipping.
2139
+ if(meta) opt.title=meta;
2007
2140
  opt.innerHTML=
2008
2141
  `<span class="ws-opt-action">`
2009
2142
  + `<span class="ws-opt-icon">${icon}</span>`
2010
2143
  + `<span class="session-action-copy">`
2011
2144
  + `<span class="ws-opt-name">${esc(label)}</span>`
2012
- + (meta?`<span class="session-action-meta">${esc(meta)}</span>`:'')
2013
2145
  + `</span>`
2014
2146
  + `</span>`;
2015
2147
  opt.onclick=async(e)=>{
@@ -2264,6 +2396,39 @@ function _openSessionActionMenu(session, anchorEl){
2264
2396
  }
2265
2397
  ));
2266
2398
  }
2399
+ // Title regeneration matches the backend guard (api/routes.py rejects
2400
+ // read_only OR is_imported with 403). read_only sessions already bailed at
2401
+ // the isReadOnly early-return above; skip imported sessions here so the
2402
+ // action is hidden rather than failing with a 403 toast. This keeps the
2403
+ // is_imported gate scoped to regenerate instead of broadening the shared
2404
+ // _isReadOnlySession() helper (which gates rename/pin/archive/etc.).
2405
+ if(!session.is_imported){
2406
+ menu.appendChild(_buildSessionAction(
2407
+ t('session_title_regenerate'),
2408
+ t('session_title_regenerate_desc'),
2409
+ ICONS.spark,
2410
+ async()=>{
2411
+ closeSessionActionMenu();
2412
+ try{
2413
+ if(typeof showToast==='function') showToast(t('session_title_regenerating'), 1600);
2414
+ const response=await api('/api/session/title/regenerate',{method:'POST',body:JSON.stringify({session_id:session.session_id})});
2415
+ const nextTitle=(response&&response.title)||(response&&response.session&&response.session.title)||'';
2416
+ if(nextTitle){
2417
+ session.title=nextTitle;
2418
+ const cached=(_allSessions||[]).find(item=>item&&item.session_id===session.session_id);
2419
+ if(cached) cached.title=nextTitle;
2420
+ if(S.session&&S.session.session_id===session.session_id){S.session.title=nextTitle;syncTopbar();}
2421
+ renderSessionListFromCache();
2422
+ }
2423
+ if(typeof showToast==='function') showToast(t('session_title_regenerated', nextTitle||t('untitled')), 2400);
2424
+ }catch(err){
2425
+ const msg=t('session_title_regenerate_failed')+(err&&err.message?err.message:String(err));
2426
+ setStatus(msg);
2427
+ if(typeof showToast==='function') showToast(msg,3000,'error');
2428
+ }
2429
+ }
2430
+ ));
2431
+ }
2267
2432
  if(!isExternalSession){
2268
2433
  if(session.worktree_path){
2269
2434
  menu.appendChild(_buildSessionAction(
@@ -2846,7 +3011,14 @@ function startGatewaySSE(){
2846
3011
  const next = res.session.messages.filter(m => m && m.role);
2847
3012
  if (next.length < prev) return;
2848
3013
  if (prev > 0 && !_isCliImportRefreshPrefixMatch(S.messages, next)) return;
2849
- S.messages = next;
3014
+ // Carry forward ephemeral turn fields (_turnUsage/
3015
+ // _turnDuration/_turnTps/_gatewayRouting/_statusCard) so
3016
+ // gateway-driven CLI refreshes do not drop the badge.
3017
+ let _nextToAssign = next;
3018
+ if (typeof window._carryForwardEphemeralTurnFields === 'function') {
3019
+ _nextToAssign = window._carryForwardEphemeralTurnFields(S.messages || [], next);
3020
+ }
3021
+ S.messages = _nextToAssign;
2850
3022
  if(S.session && S.session.session_id === activeSid){
2851
3023
  S.session.message_count = next.length;
2852
3024
  const newest = next.length ? next[next.length - 1] : null;
@@ -3508,7 +3680,11 @@ function _collapseSessionLineageForSidebar(sessions){
3508
3680
  }
3509
3681
 
3510
3682
  function _sessionDisplayTitle(s){
3511
- const title=String((s&&(s.display_title||s._state_db_title||s.title))||'Untitled').trim();
3683
+ const rawTitle=String((s&&(s.display_title||s._state_db_title||s.title))||'Untitled').trim();
3684
+ const strip=(typeof _stripAttachedFilesMarker==='function')
3685
+ ? _stripAttachedFilesMarker
3686
+ : (text)=>String(text||'').replace(/\n\n\[Attached files: [^\]]+\]$/,'').trim();
3687
+ const title=strip(rawTitle);
3512
3688
  return title||'Untitled';
3513
3689
  }
3514
3690
 
@@ -4927,19 +5103,38 @@ function _showProjectPicker(session, anchorEl){
4927
5103
  none.onclick=async()=>{
4928
5104
  picker.remove();
4929
5105
  document.removeEventListener('click',close);
4930
- await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:null})});
4931
- // Sidebar rows are shallow copies of _allSessions entries (see
4932
- // _attachChildSessionsToSidebarRows), so mutating `session` only updates
4933
- // the discarded copy. Write into the authoritative cache so the next
4934
- // renderSessionListFromCache() reflects the move. (#2551)
4935
- const idx=_allSessions.findIndex(s=>s&&s.session_id===session.session_id);
4936
- if(idx>=0) _allSessions[idx].project_id=null;
4937
- renderSessionListFromCache();
4938
- showToast('Removed from project');
5106
+ try {
5107
+ await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:null})});
5108
+ // Sidebar rows are shallow copies of _allSessions entries (see
5109
+ // _attachChildSessionsToSidebarRows), so mutating `session` only updates
5110
+ // the discarded copy. Write into the authoritative cache so the next
5111
+ // renderSessionListFromCache() reflects the move. (#2551)
5112
+ const idx=_allSessions.findIndex(s=>s&&s.session_id===session.session_id);
5113
+ if(idx>=0) _allSessions[idx].project_id=null;
5114
+ renderSessionListFromCache();
5115
+ showToast('Removed from project');
5116
+ } catch(e) {
5117
+ showToast('Unassign failed: '+(e.message||e));
5118
+ }
4939
5119
  };
4940
5120
  picker.appendChild(none);
4941
- // Project options
5121
+ // Project options — only show projects matching the session's profile.
5122
+ // #3331 follow-up (Codex gate): mirror the server's root-alias tolerance —
5123
+ // `_profiles_match` treats the literal 'default' and a renamed-root display
5124
+ // name as equivalent, so a server-approved `profile:'default'` project must
5125
+ // not be hidden for a session stamped with the renamed-root profile (and
5126
+ // vice versa). Only hide when BOTH sides are explicit, distinct, AND neither
5127
+ // is the 'default' alias; let the server's allowlist be authoritative for the
5128
+ // default/renamed-root case.
5129
+ const sessionProfile = session ? (session.profile || undefined) : undefined;
5130
+ const _profileHidesProject = (projProfile) => {
5131
+ if(!sessionProfile || !projProfile) return false;
5132
+ if(projProfile === sessionProfile) return false;
5133
+ if(projProfile === 'default' || sessionProfile === 'default') return false;
5134
+ return true;
5135
+ };
4942
5136
  for(const p of _allProjects){
5137
+ if (_profileHidesProject(p.profile)) continue;
4943
5138
  const item=document.createElement('div');
4944
5139
  item.className='project-picker-item'+(session.project_id===p.project_id?' active':'');
4945
5140
  if(p.color){
@@ -4954,12 +5149,14 @@ function _showProjectPicker(session, anchorEl){
4954
5149
  item.onclick=async()=>{
4955
5150
  picker.remove();
4956
5151
  document.removeEventListener('click',close);
4957
- await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:p.project_id})});
4958
- // See #2551 — write to _allSessions, not the shallow sidebar copy.
4959
- const idx=_allSessions.findIndex(s=>s&&s.session_id===session.session_id);
4960
- if(idx>=0) _allSessions[idx].project_id=p.project_id;
4961
- renderSessionListFromCache();
4962
- showToast('Moved to '+p.name);
5152
+ try{
5153
+ await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:p.project_id})});
5154
+ // See #2551 — write to _allSessions, not the shallow sidebar copy.
5155
+ const idx=_allSessions.findIndex(s=>s&&s.session_id===session.session_id);
5156
+ if(idx>=0) _allSessions[idx].project_id=p.project_id;
5157
+ renderSessionListFromCache();
5158
+ showToast('Moved to '+p.name);
5159
+ }catch(e){showToast('Move failed: '+(e.message||e));}
4963
5160
  };
4964
5161
  picker.appendChild(item);
4965
5162
  }
@@ -4977,7 +5174,8 @@ function _showProjectPicker(session, anchorEl){
4977
5174
  });
4978
5175
  if(!name||!name.trim()) return;
4979
5176
  const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length];
4980
- const res=await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:name.trim(),color})});
5177
+ const profile = session.profile || undefined;
5178
+ const res=await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:name.trim(),color,profile})});
4981
5179
  if(res.project){
4982
5180
  _allProjects.push(res.project);
4983
5181
  // Now move session into it
@@ -5068,9 +5266,13 @@ function _startProjectRename(proj, chip){
5068
5266
  inp.value=proj.name;
5069
5267
  const finish=async(save)=>{
5070
5268
  if(save&&inp.value.trim()&&inp.value.trim()!==proj.name){
5071
- await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:inp.value.trim()})});
5072
- await renderSessionList();
5073
- showToast('Project renamed');
5269
+ try {
5270
+ await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:inp.value.trim()})});
5271
+ await renderSessionList();
5272
+ showToast('Project renamed');
5273
+ } catch(e) {
5274
+ showToast('Rename failed: '+(e.message||e));
5275
+ }
5074
5276
  }else{
5075
5277
  renderSessionListFromCache();
5076
5278
  }
@@ -5121,9 +5323,13 @@ function _showProjectContextMenu(e, proj, chip){
5121
5323
  if(hex===(proj.color||'')) dot.style.outline='2px solid var(--text)';
5122
5324
  dot.onclick=async()=>{
5123
5325
  menu.remove();
5124
- await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:proj.name,color:hex})});
5125
- await renderSessionList();
5126
- showToast('Color updated');
5326
+ try {
5327
+ await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:proj.name,color:hex})});
5328
+ await renderSessionList();
5329
+ showToast('Color updated');
5330
+ } catch(e) {
5331
+ showToast('Color update failed: '+(e.message||e));
5332
+ }
5127
5333
  };
5128
5334
  colorRow.appendChild(dot);
5129
5335
  });
@@ -5153,10 +5359,14 @@ async function _confirmDeleteProject(proj){
5153
5359
  danger:true
5154
5360
  });
5155
5361
  if(!ok){return;}
5156
- await api('/api/projects/delete',{method:'POST',body:JSON.stringify({project_id:proj.project_id})});
5157
- if(_activeProject===proj.project_id) _activeProject=null;
5158
- await renderSessionList();
5159
- showToast('Project deleted');
5362
+ try {
5363
+ await api('/api/projects/delete',{method:'POST',body:JSON.stringify({project_id:proj.project_id})});
5364
+ if(_activeProject===proj.project_id) _activeProject=null;
5365
+ await renderSessionList();
5366
+ showToast('Project deleted');
5367
+ } catch(e) {
5368
+ showToast('Delete failed: '+(e.message||e));
5369
+ }
5160
5370
  }
5161
5371
 
5162
5372
  // Global Escape handler for batch select mode
@@ -1012,7 +1012,7 @@
1012
1012
  .session-action-menu{display:block;position:fixed;left:0;top:0;right:auto;bottom:auto;min-width:220px;max-width:min(280px,calc(100vw - 16px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:999;overflow:hidden;max-height:calc(100vh - 16px);overflow-y:auto;transform-origin:top right;will-change:opacity,transform;}
1013
1013
  .session-action-menu.open-animated{animation:session-menu-in .45s cubic-bezier(.2,.8,.2,1);}
1014
1014
  .session-action-opt{width:100%;background:none;border:none;text-align:left;font:inherit;color:var(--text);flex-direction:row!important;gap:0!important;padding:0!important;}
1015
- .session-action-opt .ws-opt-action{display:flex;flex-direction:row;align-items:center;gap:10px;width:100%;padding:10px 14px;}
1015
+ .session-action-opt .ws-opt-action{display:flex;flex-direction:row;align-items:center;gap:10px;width:100%;padding:8px 14px;}
1016
1016
  .session-action-opt .ws-opt-icon{color:var(--muted);transition:color .12s,opacity .12s;flex-shrink:0;display:flex;align-items:center;width:16px;}
1017
1017
  .session-action-opt:hover .ws-opt-icon{color:var(--text);opacity:1;}
1018
1018
  .session-action-copy{display:flex;flex-direction:column;gap:2px;min-width:0;}
@@ -1561,6 +1561,22 @@
1561
1561
  .img-lightbox-counter{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.5);color:#fff;font-size:14px;padding:4px 14px;border-radius:12px;pointer-events:none;}
1562
1562
  .msg-media-link{display:inline-flex;align-items:center;gap:5px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);border-radius:6px;padding:4px 10px;font-size:13px;color:var(--accent-text);text-decoration:none;}
1563
1563
  .msg-media-link:hover{background:var(--accent-bg-strong);}
1564
+ /* Generated local image artifact (#3220): clean inline image with a
1565
+ hover/focus-revealed Download button overlaid top-right. The image keeps
1566
+ its .msg-media-img lightbox-on-click behavior; no permanent card chrome —
1567
+ matches the ChatGPT/Claude/Gemini pattern of letting the image be the hero. */
1568
+ .msg-artifact-image{position:relative;display:inline-block;width:fit-content;max-width:min(360px,100%);vertical-align:bottom;margin:6px 4px 6px 0;line-height:0;}
1569
+ /* Generated images are the subject of the message, not a small attachment
1570
+ thumbnail — render them prominently at natural aspect ratio (the base
1571
+ .msg-media-img is a cropped 120x90 upload thumbnail). Capped + responsive so
1572
+ they never overflow the message column or a mobile viewport. Lightbox-on-click
1573
+ (cursor:zoom-in) is preserved. */
1574
+ .msg-artifact-image .msg-media-img{margin:0;display:block;width:auto;height:auto;max-width:min(360px,100%);max-height:360px;object-fit:contain;border-radius:8px;cursor:zoom-in;}
1575
+ .msg-artifact-download{position:absolute;top:8px;right:8px;width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;background:rgba(0,0,0,.62);border:1px solid rgba(255,255,255,.25);box-shadow:0 1px 4px rgba(0,0,0,.35);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);color:#fff;text-decoration:none;opacity:0;transform:translateY(-2px);transition:opacity .12s,transform .12s,background .12s;pointer-events:none;}
1576
+ .msg-artifact-image:hover .msg-artifact-download,.msg-artifact-image:focus-within .msg-artifact-download{opacity:1;transform:translateY(0);pointer-events:auto;}
1577
+ .msg-artifact-download:hover{background:rgba(0,0,0,.82);border-color:rgba(255,255,255,.4);}
1578
+ .msg-artifact-download:focus-visible{opacity:1;pointer-events:auto;outline:2px solid var(--accent-bg-strong);outline-offset:1px;}
1579
+ @media (hover:none){.msg-artifact-download{opacity:1;pointer-events:auto;transform:none;}}
1564
1580
 
1565
1581
  /* ── Inline SVG rendering ── */
1566
1582
  .msg-media-svg{display:block;max-width:100%;height:auto;border-radius:6px;margin:6px 0;border:1px solid var(--border);background:var(--surface);}
@@ -1946,6 +1962,7 @@
1946
1962
  .breadcrumb-current{color:var(--text);font-weight:500;}
1947
1963
  .breadcrumb-sep{color:var(--border);margin:0 1px;font-size:11px;}
1948
1964
  .file-tree{flex:1;overflow-y:auto;padding:8px;}
1965
+ .file-tree.drag-over-upload{outline:2px dashed var(--blue,#3b82f6);outline-offset:-2px;border-radius:6px;}
1949
1966
  .file-item{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;cursor:pointer;font-size:12px;color:var(--muted);transition:all .12s;min-width:0;}
1950
1967
  .file-item:hover{background:var(--hover-bg);color:var(--text);}
1951
1968
  .file-item.active{background:var(--accent-bg);color:var(--accent-text);}
@@ -1957,6 +1974,14 @@
1957
1974
  .preview-area.visible{display:flex;opacity:1;}
1958
1975
  .preview-path{font-size:11px;color:var(--muted);padding-bottom:8px;border-bottom:1px solid var(--border);flex-shrink:0;}
1959
1976
  .preview-code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word;color:var(--pre-text);}
1977
+ /* Workspace preview syntax highlighting (#3337): Prism's prism-tomorrow theme
1978
+ styles BOTH pre[class*="language-"] and code[class*="language-"] with its own
1979
+ gray background + padding. Prism propagates the language-* class onto the
1980
+ <pre> (.preview-code), so without overriding the <pre> too you get a gray
1981
+ prism frame around the navy var(--code-bg) <code> — a jarring two-tone block
1982
+ on dark themes. Mirror the chat-code-block fix (.msg-body pre[class*=...]):
1983
+ force BOTH surfaces to var(--code-bg) so the code panel reads as one tone. */
1984
+ .preview-code[class*="language-"],.preview-code code[class*="language-"]{background:var(--code-bg) !important;}
1960
1985
  /* Image preview */
1961
1986
  .preview-img-wrap{display:flex;align-items:center;justify-content:center;flex:1;padding:8px 0;min-height:0;}
1962
1987
  .preview-img{max-width:100%;max-height:100%;object-fit:contain;border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,.4);}
@@ -3074,7 +3099,7 @@ main.main > #mainWorkspaces,
3074
3099
  main.main > #mainProfiles,
3075
3100
  main.main > #mainInsights,
3076
3101
  main.main > #mainLogs{display:none;}
3077
- main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-kanban):not(.showing-workspaces):not(.showing-profiles):not(.showing-insights):not(.showing-logs) > #mainChat{display:flex;}
3102
+ main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-kanban):not(.showing-workspaces):not(.showing-profiles):not(.showing-insights):not(.showing-logs):not(.showing-plugin) > #mainChat{display:flex;}
3078
3103
  main.main.showing-settings > #mainSettings{display:flex;overflow-y:auto;}
3079
3104
  main.main.showing-skills > #mainSkills{display:flex;}
3080
3105
  main.main.showing-memory > #mainMemory{display:flex;}
@@ -3083,6 +3108,10 @@ main.main.showing-kanban > #mainKanban{display:flex;overflow-y:auto;}
3083
3108
  main.main.showing-workspaces > #mainWorkspaces{display:flex;}
3084
3109
  main.main.showing-profiles > #mainProfiles{display:flex;}
3085
3110
  main.main.showing-logs > #mainLogs{display:flex;}
3111
+ main.main.showing-plugin > #mainPlugin{display:flex;}
3112
+ main.main > #mainPlugin{display:none;}
3113
+ #mainPlugin .main-view-body{flex:1;display:flex;flex-direction:column;overflow:hidden;}
3114
+ #pluginPageContainer{flex:1;display:flex;flex-direction:column;min-height:0;}
3086
3115
  #mainSettings{overflow-y:auto;}
3087
3116
 
3088
3117
  /* Sidebar menu (lives in the left sidebar under the cog panel) */
@@ -3457,6 +3486,21 @@ main.main.showing-logs > #mainLogs{display:flex;}
3457
3486
  .plugin-hook-list{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;}
3458
3487
  .plugin-hook-badge{display:inline-flex;align-items:center;border:1px solid var(--border2);background:var(--code-bg);color:var(--text);border-radius:999px;padding:3px 8px;font-size:11px;font-family:var(--font-mono);}
3459
3488
  .plugin-hook-empty{font-size:12px;color:var(--muted);font-style:italic;}
3489
+ .plugin-card-footer{margin-top:10px;padding-top:10px;border-top:1px solid var(--border);}
3490
+ .plugin-card-footer-row{display:flex;align-items:center;justify-content:space-between;margin-top:8px;padding-top:8px;border-top:1px solid var(--border);}
3491
+ .plugin-toggle-label{font-size:12px;color:var(--muted);}
3492
+ .plugin-toggle-switch{position:relative;display:inline-block;width:32px;height:18px;flex-shrink:0;}
3493
+ .plugin-toggle-switch input{opacity:0;width:0;height:0;}
3494
+ .plugin-toggle-slider{position:absolute;cursor:pointer;inset:0;background:var(--border2);border-radius:9px;transition:background .2s;}
3495
+ .plugin-toggle-slider::before{content:'';position:absolute;height:12px;width:12px;left:3px;bottom:3px;background:#fff;border-radius:50%;transition:transform .2s;box-shadow:0 1px 2px rgba(0,0,0,.45);}
3496
+ .plugin-toggle-switch input:checked+.plugin-toggle-slider{background:var(--accent);}
3497
+ .plugin-toggle-switch input:checked+.plugin-toggle-slider::before{transform:translateX(14px);}
3498
+ /* Ghost/outline button: --accent-text is not guaranteed to contrast against
3499
+ --accent in every theme (e.g. it equals --accent in the default gold theme,
3500
+ which renders invisible text on a filled block). Use the accent as the text +
3501
+ border colour on the card's own surface, which is always legible. */
3502
+ .plugin-open-btn{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;background:transparent;color:var(--accent);border:1px solid var(--accent);border-radius:7px;font-size:13px;font-weight:600;cursor:pointer;text-decoration:none;}
3503
+ .plugin-open-btn:hover{background:var(--accent);color:var(--bg);}
3460
3504
 
3461
3505
  /* ── Provider model tags ── */
3462
3506
  .provider-card-models{