@oar-sh/oar 0.9.19 → 0.9.21

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 (190) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  3. package/CHANGELOG.md +529 -0
  4. package/README.md +14 -14
  5. package/bin/oar.js +392 -98
  6. package/package.json +4 -2
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -1
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
  10. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  11. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  12. package/server/claude-worker/claude-session-process.mjs +10 -0
  13. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  14. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  15. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  16. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  17. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  18. package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
  19. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  20. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  21. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  22. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  23. package/server/db-schema.mjs +18 -0
  24. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  25. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  26. package/server/grok-worker/grok-session-worker.mjs +12 -1
  27. package/server/grok-worker/grok-turn-runner.mjs +47 -6
  28. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  29. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  30. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  31. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  32. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  33. package/server/migrations/0005-remote-relays.mjs +5 -7
  34. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  35. package/server/migrations/0007-claude-cloud.mjs +5 -7
  36. package/server/migrations/0008-message-pins.mjs +5 -7
  37. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  38. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  39. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  40. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  41. package/server/migrations/0013-previews.mjs +5 -7
  42. package/server/migrations/0014-conversation-groups.mjs +5 -7
  43. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  44. package/server/migrations/0016-share-access-log.mjs +5 -7
  45. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  46. package/server/migrations/0018-background-task-rows.mjs +5 -7
  47. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  48. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  49. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  50. package/server/migrations/0022-relay-schedules.mjs +5 -7
  51. package/server/migrations/0023-relay-asks.mjs +5 -7
  52. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  53. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  54. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  55. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  56. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  57. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  58. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  59. package/server/migrations/0031-queue-provider.mjs +5 -7
  60. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  61. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  62. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  63. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  64. package/server/migrations/0036-token-usage.mjs +267 -0
  65. package/server/public/app/about-ui.js +1 -1
  66. package/server/public/app/about-view.mjs +106 -3
  67. package/server/public/app/action-confirmations.js +1 -1
  68. package/server/public/app/api-client.js +6 -3
  69. package/server/public/app/ask-user-view.js +20 -6
  70. package/server/public/app/attachments-view.js +1 -1
  71. package/server/public/app/back-guard.mjs +31 -5
  72. package/server/public/app/bootstrap.js +611 -70
  73. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  74. package/server/public/app/chat-header-meta.mjs +27 -0
  75. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  76. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  77. package/server/public/app/composer-control-state.mjs +12 -2
  78. package/server/public/app/composer-settings-label.mjs +90 -0
  79. package/server/public/app/composer-settings-ui.js +442 -0
  80. package/server/public/app/context-menu.mjs +36 -5
  81. package/server/public/app/context-usage-view.mjs +16 -0
  82. package/server/public/app/conversation-deep-link.mjs +15 -11
  83. package/server/public/app/conversation-group-drag.mjs +12 -2
  84. package/server/public/app/conversation-group-order.mjs +9 -2
  85. package/server/public/app/conversation-group-state.mjs +47 -10
  86. package/server/public/app/conversation-origin-ui.js +17 -1
  87. package/server/public/app/conversation-view.js +20 -2
  88. package/server/public/app/cwd-menu-placement.mjs +18 -6
  89. package/server/public/app/cwd-picker.js +36 -6
  90. package/server/public/app/emoji-view.js +1 -1
  91. package/server/public/app/font-scaling.js +26 -0
  92. package/server/public/app/header-priority-actions.mjs +234 -0
  93. package/server/public/app/hidden-messages-view.mjs +4 -0
  94. package/server/public/app/inbox-ui.js +50 -2
  95. package/server/public/app/inbox-view.mjs +141 -5
  96. package/server/public/app/journal-view.js +311 -58
  97. package/server/public/app/phone-landscape-ui.js +225 -0
  98. package/server/public/app/phone-landscape.mjs +99 -0
  99. package/server/public/app/phone-typing-state.mjs +104 -0
  100. package/server/public/app/pinned-messages-view.mjs +5 -0
  101. package/server/public/app/plan-board-shared.mjs +49 -0
  102. package/server/public/app/plan-usage-view.mjs +1 -1
  103. package/server/public/app/push-settings.js +2 -2
  104. package/server/public/app/pwa-install.js +24 -3
  105. package/server/public/app/relay-board-inline.mjs +4 -1
  106. package/server/public/app/relay-board-view.js +38 -10
  107. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  108. package/server/public/app/relay-event-view.mjs +42 -0
  109. package/server/public/app/relay-restart-ui.js +2 -2
  110. package/server/public/app/relay-schedule-view.mjs +32 -6
  111. package/server/public/app/relay-schedules-ui.js +69 -20
  112. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  113. package/server/public/app/session-history-ui.js +250 -0
  114. package/server/public/app/session-history.mjs +599 -0
  115. package/server/public/app/settings-modal.js +61 -6
  116. package/server/public/app/settings-tabs.js +12 -0
  117. package/server/public/app/share-state.js +1 -1
  118. package/server/public/app/shared-conversations-settings.js +2 -2
  119. package/server/public/app/socket-handlers.js +15 -4
  120. package/server/public/app/status-view.mjs +18 -2
  121. package/server/public/app/store.js +87 -16
  122. package/server/public/app/subagent-viewer.mjs +27 -2
  123. package/server/public/app/tab-strip-fade.mjs +101 -0
  124. package/server/public/app/tap-actions.js +66 -0
  125. package/server/public/app/usage-limit-ui.js +63 -2
  126. package/server/public/app/viewer-history.mjs +13 -2
  127. package/server/public/index.html +2258 -1585
  128. package/server/public/sw.js +8 -0
  129. package/server/relay-heap-guard.mjs +14 -1
  130. package/server/relay-self-restart.mjs +83 -2
  131. package/server/relay-tools.md +5 -3
  132. package/server/repositories/message-repository.mjs +54 -1
  133. package/server/repositories/remote-relay-repository.mjs +10 -0
  134. package/server/repositories/session-repository.mjs +25 -1
  135. package/server/routes/claude-cloud-routes.mjs +19 -0
  136. package/server/routes/messages-routes.mjs +144 -1
  137. package/server/routes/relay-ask-routes.mjs +138 -8
  138. package/server/routes/relay-board-routes.mjs +55 -40
  139. package/server/routes/sessions-routes.mjs +144 -14
  140. package/server/server-runtime.mjs +322 -51
  141. package/server/services/about-service.mjs +81 -53
  142. package/server/services/cache-rebuild-service.mjs +7 -0
  143. package/server/services/claude-billing-swap-service.mjs +7 -20
  144. package/server/services/claude-cloud-session-service.mjs +47 -5
  145. package/server/services/conversation-preferences-service.mjs +31 -0
  146. package/server/services/database-file.mjs +413 -0
  147. package/server/services/db-backup-service.mjs +809 -0
  148. package/server/services/deepseek-usage-import.mjs +6 -9
  149. package/server/services/delete-archive-service.mjs +3 -0
  150. package/server/services/earlier-database-service.mjs +69 -126
  151. package/server/services/oar-cli-helpers.mjs +214 -12
  152. package/server/services/oar-cli-menu.mjs +1 -0
  153. package/server/services/oar-state-migration-service.mjs +33 -13
  154. package/server/services/package-data-rescue.mjs +4 -2
  155. package/server/services/plan-usage-claude-api.mjs +14 -15
  156. package/server/services/plan-usage-contract.mjs +6 -2
  157. package/server/services/plan-usage-deepseek.mjs +1 -1
  158. package/server/services/plan-usage-tokens.mjs +248 -0
  159. package/server/services/push-dispatch-service.mjs +134 -9
  160. package/server/services/relay-cli-launcher-service.mjs +3 -0
  161. package/server/services/relay-event-service.mjs +30 -0
  162. package/server/services/relay-question-context.mjs +11 -0
  163. package/server/services/relay-restart-picture.mjs +67 -22
  164. package/server/services/remote-inbox-watch-service.mjs +204 -0
  165. package/server/services/remote-relay-caller-context.mjs +80 -7
  166. package/server/services/remote-relay-dispatcher.mjs +101 -19
  167. package/server/services/restart-resume-service.mjs +5 -2
  168. package/server/services/session-activity-tracker.mjs +1 -1
  169. package/server/services/session-launch-mutex-service.mjs +23 -0
  170. package/server/services/session-worker-launch-service.mjs +12 -3
  171. package/server/services/session-worker-registry-service.mjs +36 -0
  172. package/server/services/session-worker-stop-service.mjs +27 -6
  173. package/server/services/session-worker-websocket-service.mjs +40 -16
  174. package/server/services/token-usage-service.mjs +430 -0
  175. package/server/services/update-install-service.mjs +13 -0
  176. package/server/services/usage-limit-pause-service.mjs +8 -1
  177. package/server/services/worker-recycle-service.mjs +772 -0
  178. package/server/services/worker-replacement.mjs +197 -0
  179. package/server/services/worker-scope.mjs +77 -0
  180. package/shared/build-id.mjs +128 -0
  181. package/shared/plan-board-actions.mjs +130 -0
  182. package/shared/plan-only.mjs +43 -0
  183. package/shared/relay-asks.mjs +3 -0
  184. package/shared/relay-events.mjs +38 -4
  185. package/shared/remote-relay-contract.mjs +66 -11
  186. package/shared/turn-token-usage.mjs +144 -0
  187. package/shared/worker-runtime/heartbeat.mjs +5 -0
  188. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  189. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  190. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -42,12 +42,24 @@ export function summarizeGroupMembers(states = []) {
42
42
  export function groupSummaryText(summary) {
43
43
  const count = Number(summary?.count || 0);
44
44
  if (!count) return '';
45
+ const activity = groupActivityText(summary);
46
+ if (!activity) return groupSessionCountText(count);
47
+ return `${count} · ${activity}`;
48
+ }
49
+
50
+ /** "1 waiting for you, 2 running", or "" while nothing waits, runs or failed. */
51
+ export function groupActivityText(summary) {
45
52
  const parts = [];
46
- if (summary.waiting > 0) parts.push(`${summary.waiting} waiting for you`);
47
- if (summary.running > 0) parts.push(`${summary.running} running`);
48
- if (summary.error > 0) parts.push(`${summary.error} failed`);
49
- if (!parts.length) return `${count} session${count === 1 ? '' : 's'}`;
50
- return `${count} · ${parts.join(', ')}`;
53
+ if (Number(summary?.waiting) > 0) parts.push(`${summary.waiting} waiting for you`);
54
+ if (Number(summary?.running) > 0) parts.push(`${summary.running} running`);
55
+ if (Number(summary?.error) > 0) parts.push(`${summary.error} failed`);
56
+ return parts.join(', ');
57
+ }
58
+
59
+ /** "1 session", "4 sessions". */
60
+ export function groupSessionCountText(count) {
61
+ const n = Number(count) || 0;
62
+ return `${n} session${n === 1 ? '' : 's'}`;
51
63
  }
52
64
 
53
65
  /** Whether a group has something going on: a member runs or waits. */
@@ -55,14 +67,39 @@ export function groupIsActive(summary) {
55
67
  return Number(summary?.running || 0) > 0 || Number(summary?.waiting || 0) > 0;
56
68
  }
57
69
 
70
+ /**
71
+ * A member's colour in its group's name: 'waiting', 'running', 'ready' (idle
72
+ * with a live worker, the darker green) or 'offline'. A failed member does
73
+ * not colour the name; the pill says so.
74
+ */
75
+ export function memberTone({ memberState = 'idle', visualState = '' } = {}) {
76
+ if (memberState === 'waiting' || memberState === 'running') return memberState;
77
+ return String(visualState || '').trim().toLowerCase() === 'ready' ? 'ready' : 'offline';
78
+ }
79
+
80
+ /**
81
+ * The colour of a group's name from its members' tones: red while one waits
82
+ * for the user, bright green while one works, the darker green while one is
83
+ * ready, else the plain title colour. Returns 'question' | 'working' |
84
+ * 'ready' | 'offline'.
85
+ */
86
+ export function groupTitleTone(tones = []) {
87
+ const list = Array.isArray(tones) ? tones : [];
88
+ if (list.includes('waiting')) return 'question';
89
+ if (list.includes('running')) return 'working';
90
+ if (list.includes('ready')) return 'ready';
91
+ return 'offline';
92
+ }
93
+
58
94
  /**
59
95
  * Open or folded. The user's choice ('open' | 'folded') wins; otherwise a
60
- * group is open while active and folded once everything is finished. The
61
- * group that holds the open conversation, and every group under a title
62
- * filter, is always open.
96
+ * group is open while active and folded once everything is finished. Every
97
+ * group under a title filter is always open. A folded group that holds the
98
+ * open conversation still shows that row (arrangeConversationList's
99
+ * keepVisibleId).
63
100
  */
64
- export function resolveGroupFold({ choice = null, active = false, holdsOpenConversation = false, filterActive = false } = {}) {
65
- if (holdsOpenConversation || filterActive) return true;
101
+ export function resolveGroupFold({ choice = null, active = false, filterActive = false } = {}) {
102
+ if (filterActive) return true;
66
103
  if (choice === 'open') return true;
67
104
  if (choice === 'folded') return false;
68
105
  return active === true;
@@ -6,7 +6,23 @@
6
6
  // marker is all there is), and neither has one a person started.
7
7
 
8
8
  import { conversations, currentConvId, IS_SHARED_VIEW } from './store.js';
9
- import { renderConversationHeaderOriginHtml } from './remote-relay-origin-view.mjs';
9
+ import { conversationOriginInfo, renderConversationHeaderOriginHtml } from './remote-relay-origin-view.mjs';
10
+
11
+ /**
12
+ * The ︙ entry that stands in for the line where the meta line is hidden
13
+ * (phone landscape): `{ label: 'Started by an agent in “title”',
14
+ * conversationId }` while the line is a button that opens that conversation,
15
+ * else null.
16
+ */
17
+ export function conversationOriginMenuEntry() {
18
+ if (IS_SHARED_VIEW) return null;
19
+ const id = String(currentConvId || '').trim();
20
+ const conversation = id ? (conversations[id] || null) : null;
21
+ if (!conversation) return null;
22
+ const info = conversationOriginInfo(conversation, { conversations, linkable: true });
23
+ if (!info || info.kind !== 'local' || !info.linkable || !info.conversationId) return null;
24
+ return { label: info.label, conversationId: info.conversationId };
25
+ }
10
26
 
11
27
  let renderedHtml = '';
12
28
 
@@ -155,6 +155,7 @@ import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
155
155
  import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
156
156
  import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
157
157
  import { conversationPayloadCache } from './conversation-payload-cache.mjs';
158
+ import { noteConversationShown } from './session-history-ui.js';
158
159
 
159
160
  const CONVERSATION_HISTORY_PAGE_SIZE = 20;
160
161
  const HISTORY_LOAD_MORE_ID = 'history-load-more';
@@ -612,6 +613,15 @@ function steeringHoldForConversation(conversationId) {
612
613
  return { held: false, reason: null };
613
614
  }
614
615
 
616
+ // Plan mode on one side, the writing modes on the other (a turn's mode not yet
617
+ // known crosses nothing).
618
+ function modesCrossPlan(turnMode, composerMode) {
619
+ const turn = String(turnMode || '').trim().toLowerCase();
620
+ const composer = String(composerMode || '').trim().toLowerCase();
621
+ if (!turn || !composer) return false;
622
+ return (turn === 'plan') !== (composer === 'plan');
623
+ }
624
+
615
625
  function syncSendButtonState() {
616
626
  const btn = document.getElementById('send-btn');
617
627
  if (!btn) return;
@@ -631,6 +641,7 @@ function syncSendButtonState() {
631
641
  steeringSupported: conversationSupportsSteering(currentConvId),
632
642
  steeringHeld: steeringHold.held,
633
643
  steeringHoldReason: steeringHold.reason,
644
+ crossesPlanMode: modesCrossPlan(currentTurn?.mode, document.getElementById('mode-select')?.value),
634
645
  archived: conversations[String(currentConvId || '').trim()]?.archived === true,
635
646
  setupRequired: !String(currentConvId || '').trim() && isProviderSetupRequired(),
636
647
  });
@@ -1202,10 +1213,15 @@ function setConversationTurnState(conversationId, state = null) {
1202
1213
  syncSendButtonState();
1203
1214
  return;
1204
1215
  }
1216
+ const messageId = String(state.messageId || '').trim();
1217
+ const previous = activeTurnsByConversation.get(conversationKey);
1205
1218
  activeTurnsByConversation.set(conversationKey, {
1206
- messageId: String(state.messageId || '').trim(),
1219
+ messageId,
1207
1220
  status: String(state.status || 'processing').trim().toLowerCase() || 'processing',
1208
1221
  cancelRequested: state.cancelRequested === true,
1222
+ // The live turn's relay mode (the in-flight snapshot carries it); a status
1223
+ // update for the same turn keeps the one already known.
1224
+ mode: String(state.mode || (previous?.messageId === messageId ? previous.mode : '') || '').trim().toLowerCase(),
1209
1225
  });
1210
1226
  syncSendButtonState();
1211
1227
  }
@@ -2293,7 +2309,7 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
2293
2309
  const liveStreamSeq = relayStreamStateByMessageId.get(messageId)?.seq || 0;
2294
2310
  const liveStreamText = relayStreamTextByMessageId.get(messageId);
2295
2311
  clearRelayStreamState();
2296
- setConversationTurnState(currentConvId, { messageId, status: 'processing' });
2312
+ setConversationTurnState(currentConvId, { messageId, status: 'processing', mode: inFlight?.mode });
2297
2313
  const activities = mergeRelayActivityTexts(
2298
2314
  relayActivities.get(messageId) || [],
2299
2315
  Array.isArray(inFlight.activities) ? inFlight.activities : [],
@@ -4740,6 +4756,8 @@ export async function sendMessage() {
4740
4756
  const adoptNewConversation = viewingSendConversation();
4741
4757
  if (adoptNewConversation) {
4742
4758
  setCurrentConv(r.conversationId);
4759
+ // The new chat gets its history entry, as one opened from the list does.
4760
+ noteConversationShown(r.conversationId);
4743
4761
  composerConversationId = r.conversationId;
4744
4762
  nameOwnSendConversation(send, r.conversationId);
4745
4763
  }
@@ -2,6 +2,7 @@
2
2
  // Kept DOM-free so they can run under `node --test` without a browser.
3
3
 
4
4
  const FLIP_THRESHOLD_PX = 180;
5
+ const FALLBACK_WIDTH_PX = 260;
5
6
 
6
7
  function clamp(value, min, max) {
7
8
  if (max < min) return min;
@@ -11,17 +12,28 @@ function clamp(value, min, max) {
11
12
  /**
12
13
  * Decide where the fixed-position picker panel should sit relative to its trigger.
13
14
  * Used on desktop only; the mobile layout is a bottom sheet driven purely by CSS.
15
+ *
16
+ * `gap`, `edge`, `minHeight`, `maxHeight` are design sizes at 100 %; `scale`
17
+ * is the UI zoom (font-scaling.js getUiScale) they are multiplied by, so the
18
+ * panel keeps its proportions next to the scaled stylesheet. The trigger rect
19
+ * and the viewport are measured, never scaled.
14
20
  */
15
21
  export function resolveCwdMenuPlacement({
16
22
  triggerRect,
17
23
  viewportWidth = 0,
18
24
  viewportHeight = 0,
19
25
  panelHeight = 0,
20
- gap = 6,
21
- edge = 8,
22
- minHeight = 140,
23
- maxHeight = 300,
26
+ gap: gapBase = 6,
27
+ edge: edgeBase = 8,
28
+ minHeight: minHeightBase = 140,
29
+ maxHeight: maxHeightBase = 300,
30
+ scale = 1,
24
31
  } = {}) {
32
+ const factor = Number.isFinite(Number(scale)) && Number(scale) > 0 ? Number(scale) : 1;
33
+ const gap = gapBase * factor;
34
+ const edge = edgeBase * factor;
35
+ const minHeight = minHeightBase * factor;
36
+ const maxHeight = maxHeightBase * factor;
25
37
  const top = Number(triggerRect?.top) || 0;
26
38
  const left = Number(triggerRect?.left) || 0;
27
39
  const width = Math.max(0, Number(triggerRect?.width) || 0);
@@ -30,13 +42,13 @@ export function resolveCwdMenuPlacement({
30
42
 
31
43
  const spaceBelow = Math.max(0, viewportHeight - bottom - gap - edge);
32
44
  const spaceAbove = Math.max(0, top - gap - edge);
33
- const flip = spaceBelow < FLIP_THRESHOLD_PX && spaceAbove > spaceBelow;
45
+ const flip = spaceBelow < FLIP_THRESHOLD_PX * factor && spaceAbove > spaceBelow;
34
46
  const available = flip ? spaceAbove : spaceBelow;
35
47
 
36
48
  const desiredHeight = panelHeight > 0 ? Math.min(panelHeight, maxHeight) : maxHeight;
37
49
  const resolvedMaxHeight = clamp(Math.min(desiredHeight, available), Math.min(minHeight, available), maxHeight);
38
50
 
39
- const resolvedWidth = width > 0 ? width : 260;
51
+ const resolvedWidth = width > 0 ? width : FALLBACK_WIDTH_PX * factor;
40
52
  const maxLeft = Math.max(edge, viewportWidth - resolvedWidth - edge);
41
53
  const resolvedLeft = clamp(left, edge, maxLeft);
42
54
  const resolvedTop = flip
@@ -19,11 +19,13 @@ import {
19
19
  resolveCwdMenuPlacement,
20
20
  resolveTypeaheadIndex,
21
21
  } from './cwd-menu-placement.mjs';
22
+ import { FONT_SCALE_CHANGE_EVENT, getUiScale } from './font-scaling.js';
22
23
  import {
23
24
  buildKnownCwdOptions as buildKnownCwdOptionsFromInputs,
24
25
  normalizeKnownCwdPath,
25
26
  } from './known-cwd-options.mjs';
26
27
  import { buildCloudLineModel, cloudHeaderLabel, isClaudeCloudConversation } from './claude-cloud-ui.mjs';
28
+ import { CHAT_META_LONG_CLASS, CHAT_META_SHORT_CLASS, cwdFolderName } from './chat-header-meta.mjs';
27
29
 
28
30
  export { normalizeKnownCwdPath };
29
31
 
@@ -163,6 +165,7 @@ function positionChangeCwdMenu() {
163
165
  viewportWidth,
164
166
  viewportHeight,
165
167
  panelHeight: panel.scrollHeight,
168
+ scale: getUiScale(),
166
169
  });
167
170
  panel.style.left = `${Math.round(placement.left)}px`;
168
171
  panel.style.top = `${Math.round(placement.top)}px`;
@@ -176,6 +179,8 @@ function bindRepositionListeners() {
176
179
  // Capture phase so scrolling inside .summary-body (which does not bubble) counts.
177
180
  window.addEventListener('scroll', positionChangeCwdMenu, true);
178
181
  window.addEventListener('resize', positionChangeCwdMenu);
182
+ // A zoom moves the trigger and rescales the gap and the panel's sizes.
183
+ window.addEventListener(FONT_SCALE_CHANGE_EVENT, positionChangeCwdMenu);
179
184
  window.visualViewport?.addEventListener?.('resize', positionChangeCwdMenu);
180
185
  window.visualViewport?.addEventListener?.('scroll', positionChangeCwdMenu);
181
186
  }
@@ -185,6 +190,7 @@ function unbindRepositionListeners() {
185
190
  repositionBound = false;
186
191
  window.removeEventListener('scroll', positionChangeCwdMenu, true);
187
192
  window.removeEventListener('resize', positionChangeCwdMenu);
193
+ window.removeEventListener(FONT_SCALE_CHANGE_EVENT, positionChangeCwdMenu);
188
194
  window.visualViewport?.removeEventListener?.('resize', positionChangeCwdMenu);
189
195
  window.visualViewport?.removeEventListener?.('scroll', positionChangeCwdMenu);
190
196
  }
@@ -231,7 +237,7 @@ function openChangeCwdMenu() {
231
237
  bindRepositionListeners();
232
238
  }
233
239
 
234
- function closeChangeCwdMenu({ focusTrigger = false } = {}) {
240
+ export function closeChangeCwdMenu({ focusTrigger = false } = {}) {
235
241
  const { trigger, panel, backdrop } = getPickerEls();
236
242
  menuOpen = false;
237
243
  typeaheadBuffer = '';
@@ -470,6 +476,7 @@ export function syncChatHeaderWorkspaceLabel() {
470
476
  const label = cloudHeaderLabel(conversation.cloud);
471
477
  labelEl.hidden = !label;
472
478
  labelEl.textContent = label;
479
+ labelEl.classList.remove('chat-title-cwd-expanded');
473
480
  if (label) labelEl.title = buildCloudLineModel(conversation.cloud)?.repoUrl || label;
474
481
  else labelEl.removeAttribute('title');
475
482
  return;
@@ -479,11 +486,34 @@ export function syncChatHeaderWorkspaceLabel() {
479
486
  labelEl.hidden = true;
480
487
  labelEl.textContent = '';
481
488
  labelEl.removeAttribute('title');
489
+ labelEl.classList.remove('chat-title-cwd-expanded');
482
490
  return;
483
491
  }
484
492
  labelEl.hidden = false;
485
- labelEl.textContent = cwd;
486
493
  labelEl.title = cwd;
494
+ // The whole path, and its last folder for the phone's meta row
495
+ // (chat-header-meta.mjs); a tap there shows the whole path.
496
+ const longEl = labelEl.querySelector(`.${CHAT_META_LONG_CLASS}`);
497
+ const shortEl = labelEl.querySelector(`.${CHAT_META_SHORT_CLASS}`);
498
+ const folder = cwdFolderName(cwd) || cwd;
499
+ if (longEl && shortEl && longEl.textContent === cwd && shortEl.textContent === folder) return;
500
+ if (longEl?.textContent !== cwd) labelEl.classList.remove('chat-title-cwd-expanded');
501
+ const long = document.createElement('span');
502
+ long.className = CHAT_META_LONG_CLASS;
503
+ long.textContent = cwd;
504
+ const short = document.createElement('span');
505
+ short.className = CHAT_META_SHORT_CLASS;
506
+ short.textContent = folder;
507
+ labelEl.replaceChildren(long, short);
508
+ if (labelEl.dataset.cwdToggleBound !== '1') {
509
+ labelEl.dataset.cwdToggleBound = '1';
510
+ labelEl.addEventListener('click', () => {
511
+ // Only the two-label folder line toggles (not a cloud repo or the
512
+ // status view's text); on a desktop the class changes nothing.
513
+ if (!labelEl.querySelector(`.${CHAT_META_SHORT_CLASS}`)) return;
514
+ labelEl.classList.toggle('chat-title-cwd-expanded');
515
+ });
516
+ }
487
517
  }
488
518
 
489
519
  function getCurrentLaunchableSessionId() {
@@ -513,14 +543,14 @@ export function openChangeCwdModal() {
513
543
  subtitle: 'Select a known launch directory',
514
544
  kind: 'change-cwd',
515
545
  bodyHtml: `
516
- <p style="margin-bottom:10px;color:var(--muted);line-height:1.45">
546
+ <p style="margin-bottom:calc(5px * var(--ui-scale));color:var(--muted);line-height:1.45">
517
547
  Pick the selected session's launch directory. An idle CLI restarts in the selected CWD; active turns must finish first.
518
548
  </p>
519
- <div style="display:grid;gap:4px;margin-bottom:10px;font-size:0.78rem;color:var(--muted)">
549
+ <div style="display:grid;gap:calc(4px * var(--ui-scale));margin-bottom:calc(5px * var(--ui-scale));font-size:0.78rem;color:var(--muted)">
520
550
  <div><strong style="color:var(--text)">Current CWD:</strong> ${escHtml(currentCwd || 'Unknown')}</div>
521
551
  <div><strong style="color:var(--text)">Next launch:</strong> ${escHtml(nextLaunchCwd || currentCwd || 'Unknown')}</div>
522
552
  </div>
523
- <div class="change-cwd-picker" style="margin-bottom:10px">
553
+ <div class="change-cwd-picker" style="margin-bottom:calc(5px * var(--ui-scale))">
524
554
  <label class="change-cwd-picker-label" for="change-cwd-manual-path">Manual path</label>
525
555
  <div class="cwd-browse-row">
526
556
  <input id="change-cwd-manual-path" class="change-cwd-manual-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="Manual path">
@@ -539,7 +569,7 @@ export function openChangeCwdModal() {
539
569
  ${menuItemsHtml}
540
570
  </div>
541
571
  </div>
542
- <div id="change-cwd-details" style="margin-top:10px;font-size:0.78rem;color:var(--muted);line-height:1.45;word-break:break-word"></div>
572
+ <div id="change-cwd-details" style="margin-top:calc(5px * var(--ui-scale));font-size:0.78rem;color:var(--muted);line-height:1.45;word-break:break-word"></div>
543
573
  <div class="summary-modal-actions" id="change-cwd-actions">
544
574
  <button class="summary-btn" type="button" onclick="confirmChangeCwd()">🗂️ Save next-launch CWD</button>
545
575
  <button class="summary-btn" type="button" ${launchableSessionId ? 'onclick="confirmChangeCwdAndLaunch()"' : 'disabled data-keep-disabled="1"'} title="${escHtml(launchDisabledReason || 'Set the CWD and (re)launch the current session worker')}">🚀 Set new CWD and (re)launch</button>
@@ -23,7 +23,7 @@ function updateEmojiButton() {
23
23
  button.textContent = getLastEmoji();
24
24
  }
25
25
 
26
- function closeEmojiPicker() {
26
+ export function closeEmojiPicker() {
27
27
  const popup = document.getElementById('emoji-popup');
28
28
  if (!popup) return;
29
29
  popup.classList.remove('visible');
@@ -8,6 +8,9 @@ const FONT_SCALE_WHEEL_STEP_MIN = 0.01;
8
8
  const FONT_SCALE_WHEEL_STEP_MAX = 0.2;
9
9
  const FONT_SCALE_PILL_VISIBLE_MS = 900;
10
10
 
11
+ /** Fired on window after --font-scale changed; detail: { scale }. */
12
+ export const FONT_SCALE_CHANGE_EVENT = 'oar:font-scale-change';
13
+
11
14
  let fontScaleValue = FONT_SCALE_DEFAULT;
12
15
  let fontScaleIndicatorTimer = null;
13
16
  let fontScalePinchState = {
@@ -46,6 +49,25 @@ function readStoredFontScale() {
46
49
  return clampFontScale(readLocalStorage(FONT_SCALE_STORAGE_KEY));
47
50
  }
48
51
 
52
+ /**
53
+ * The factor the stylesheet multiplies its px sizes by (--ui-scale, which
54
+ * follows --font-scale). JS that places popovers with px gaps and margins
55
+ * of its own multiplies them by this, so they match the scaled CSS. Read
56
+ * per call: the inline value (set before first paint and on every change),
57
+ * else the computed one; 1 outside a browser.
58
+ */
59
+ export function getUiScale() {
60
+ if (typeof document === 'undefined' || !document.documentElement) return FONT_SCALE_DEFAULT;
61
+ let raw = '';
62
+ try { raw = document.documentElement.style.getPropertyValue('--font-scale'); } catch {}
63
+ if (!String(raw || '').trim() && typeof getComputedStyle === 'function') {
64
+ try { raw = getComputedStyle(document.documentElement).getPropertyValue('--font-scale'); } catch {}
65
+ }
66
+ const numeric = Number(String(raw || '').trim());
67
+ if (!Number.isFinite(numeric) || numeric <= 0) return FONT_SCALE_DEFAULT;
68
+ return clampFontScale(numeric);
69
+ }
70
+
49
71
  function normalizeFontScaleSelectValue(raw = '') {
50
72
  const text = String(raw || '').trim();
51
73
  if (!text) return null;
@@ -90,6 +112,10 @@ export function setFontScale(nextScale, { persist = true, preserveMessageAnchor
90
112
  document.documentElement.style.setProperty('--font-scale-percent', `${Math.round(normalized * 100)}%`);
91
113
  if (persist) writeLocalStorage(FONT_SCALE_STORAGE_KEY, String(normalized));
92
114
  syncFontScaleSelect();
115
+ // Layout that measures itself (the header's priority+ actions) re-runs.
116
+ if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') {
117
+ window.dispatchEvent(new CustomEvent(FONT_SCALE_CHANGE_EVENT, { detail: { scale: normalized } }));
118
+ }
93
119
  if (anchor) {
94
120
  requestAnimationFrame(() => {
95
121
  restoreTranscriptAnchor(document.getElementById('messages'), anchor);
@@ -0,0 +1,234 @@
1
+ // The chat header's priority+ actions: which icon buttons sit in the header
2
+ // and which fall back into the ︙ menu when the header runs out of room.
3
+ //
4
+ // Every action exists twice in the markup: an inline header button and a twin
5
+ // entry in #chat-actions-menu. Nothing moves in the DOM; a pass toggles
6
+ // `hidden` on the pair, so listeners are bound once and the menu keeps its
7
+ // order. This module is DOM-free: bootstrap.js measures, calls
8
+ // fitHeaderPriorityActions and writes the result.
9
+ //
10
+ // Order is priority: the first entry is the LAST to overflow. Once one action
11
+ // does not fit, it and every later one go into the menu, so a lower priority
12
+ // action never "jumps" a higher one back into the header.
13
+
14
+ export const HEADER_PRIORITY_ACTIONS = Object.freeze([
15
+ Object.freeze({ key: 'pins', inlineId: 'pinned-messages-btn', menuId: 'chat-menu-pins' }),
16
+ Object.freeze({ key: 'hidden', inlineId: 'hidden-messages-btn', menuId: 'chat-menu-hidden' }),
17
+ Object.freeze({ key: 'search', inlineId: 'message-search-btn', menuId: 'chat-menu-search' }),
18
+ Object.freeze({ key: 'context', inlineId: 'context-btn', menuId: 'chat-menu-context' }),
19
+ Object.freeze({ key: 'usage', inlineId: 'usage-btn', menuId: 'chat-menu-usage' }),
20
+ Object.freeze({ key: 'theme', inlineId: 'theme-toggle-btn', menuId: 'chat-menu-theme-toggle' }),
21
+ Object.freeze({ key: 'fullscreen', inlineId: 'fullscreen-btn', menuId: 'chat-menu-fullscreen' }),
22
+ ]);
23
+
24
+ export const HEADER_PRIORITY_ACTION_KEYS = Object.freeze(HEADER_PRIORITY_ACTIONS.map((action) => action.key));
25
+
26
+ /** The twin menu entries, in priority order. */
27
+ export const HEADER_PRIORITY_MENU_IDS = Object.freeze(HEADER_PRIORITY_ACTIONS.map((action) => action.menuId));
28
+
29
+ /** The width the title keeps before any action is shown inline, in rem. */
30
+ export const HEADER_TITLE_MIN_REM = 8;
31
+
32
+ /** The window event that asks for a new pass (counts, availability). */
33
+ export const HEADER_ACTIONS_CHANGE_EVENT = 'oar:header-actions-change';
34
+
35
+ /**
36
+ * Where the pending-question banner has a row of its own (the phone's header
37
+ * CSS): there it takes nothing from the first row.
38
+ */
39
+ export const HEADER_BANNER_OWN_ROW_QUERY = '(max-width: 680px)';
40
+
41
+ /**
42
+ * The share of the header's content width the question banner may take on
43
+ * the first row (its `max-width: 40%` in index.html): the title keeps the rest.
44
+ */
45
+ export const HEADER_BANNER_MAX_SHARE = 0.4;
46
+
47
+ /**
48
+ * The width of everything on the header's first row that never overflows:
49
+ * its padding, ☰, the title's minimum, ︙, the question banner and the flex
50
+ * gaps between them. Widths of 0 are elements that do not show. The banner
51
+ * counts only while it shares the first row (`bannerOwnRow` false), and only
52
+ * with its compact width ("❓ N"): the rest of it gives way before an action
53
+ * does (resolveQuestionBannerMode).
54
+ *
55
+ * @param {object} input
56
+ * @param {number} [input.padding] left plus right padding
57
+ * @param {number} [input.titleMin] the width the title keeps
58
+ * @param {number} [input.toggle] ☰
59
+ * @param {number} [input.banner] the question banner's compact chip
60
+ * @param {number} [input.menu] ︙ (its wrapper)
61
+ * @param {number} [input.gap] the header's column gap
62
+ * @param {boolean} [input.bannerOwnRow] the banner sits on a row of its own
63
+ * @returns {number}
64
+ */
65
+ export function headerReservedWidth({
66
+ padding = 0,
67
+ titleMin = 0,
68
+ toggle = 0,
69
+ banner = 0,
70
+ menu = 0,
71
+ gap = 0,
72
+ bannerOwnRow = false,
73
+ } = {}) {
74
+ const size = (value) => (Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : 0);
75
+ let width = size(padding) + size(titleMin) + size(menu);
76
+ let items = 2; // the title and #chat-header-actions
77
+ if (size(toggle)) {
78
+ width += size(toggle);
79
+ items += 1;
80
+ }
81
+ if (size(banner) && !bannerOwnRow) {
82
+ width += size(banner);
83
+ items += 1;
84
+ }
85
+ return width + size(gap) * (items - 1);
86
+ }
87
+
88
+ /**
89
+ * How the question banner shows once the actions have their places:
90
+ * - 'hidden': no banner;
91
+ * - 'row': a row of its own (a phone held upright, or a header whose first
92
+ * row cannot hold even the chip next to ☰, the title's minimum and ︙),
93
+ * the whole text;
94
+ * - 'compact': the chip "❓ N" on the first row (always on a phone on its
95
+ * side; elsewhere where the room cannot even hold "❓ N open questions");
96
+ * - 'full': the whole text on the first row, ending in an ellipsis where
97
+ * the room is short.
98
+ * The room is what the first row leaves it (the compact width the fit
99
+ * reserved plus whatever the inline actions did not take), capped at
100
+ * `maxShare` of the header's content width.
101
+ *
102
+ * @param {object} input
103
+ * @param {boolean} [input.visible] the banner shows
104
+ * @param {boolean} [input.ownRow] it sits on a row of its own
105
+ * @param {boolean} [input.phoneLandscape] :root[data-phone-landscape]
106
+ * @param {number} [input.room] the width the first row leaves it
107
+ * @param {number} [input.compact] the chip's width: a room below it is 'row'
108
+ * @param {number} [input.contentWidth] the header's width minus its padding
109
+ * @param {number} [input.maxShare] HEADER_BANNER_MAX_SHARE
110
+ * @param {number} [input.fullMin] "❓ N open questions" with the banner's padding and border
111
+ * @returns {'hidden'|'row'|'compact'|'full'}
112
+ */
113
+ export function resolveQuestionBannerMode({
114
+ visible = false,
115
+ ownRow = false,
116
+ phoneLandscape = false,
117
+ room = 0,
118
+ compact = 0,
119
+ contentWidth = Infinity,
120
+ maxShare = HEADER_BANNER_MAX_SHARE,
121
+ fullMin = 0,
122
+ } = {}) {
123
+ if (!visible) return 'hidden';
124
+ if (ownRow) return 'row';
125
+ if (phoneLandscape) return 'compact';
126
+ // Not even the chip fits on the first row: it would push ︙ off the
127
+ // screen, so the banner takes a row of its own (an unmeasured room does not).
128
+ if (Number.isFinite(Number(room)) && Number(room) < (Number(compact) || 0)) return 'row';
129
+ const share = Number(maxShare);
130
+ const width = Number(contentWidth);
131
+ const cap = Number.isFinite(share) && share > 0 && Number.isFinite(width) ? width * share : Infinity;
132
+ // An unmeasured room (NaN) is the chip: it is the width the fit reserved.
133
+ const space = Math.min(Number(room), cap);
134
+ return space >= Math.max(0, Number(fullMin) || 0) ? 'full' : 'compact';
135
+ }
136
+
137
+ /**
138
+ * Whether each action may show at all, in the header or in the menu. Distinct
139
+ * from the fit question, which is only about WHERE an available action shows.
140
+ *
141
+ * @param {object} input
142
+ * @param {boolean} [input.sharedMode] the shared read-only view: Day/Night only
143
+ * @param {number} [input.pinCount] pins of the open conversation
144
+ * @param {number} [input.hiddenCount] messages hidden from shared viewers
145
+ * @param {boolean} [input.fullscreenAvailable] pwa-install.js's verdict (a phone, not installed)
146
+ * @returns {Record<string, boolean>}
147
+ */
148
+ export function resolveHeaderActionAvailability({
149
+ sharedMode = false,
150
+ pinCount = 0,
151
+ hiddenCount = 0,
152
+ fullscreenAvailable = false,
153
+ } = {}) {
154
+ if (sharedMode) {
155
+ return { pins: false, hidden: false, search: false, context: false, usage: false, theme: true, fullscreen: false };
156
+ }
157
+ return {
158
+ pins: Number(pinCount) > 0,
159
+ hidden: Number(hiddenCount) > 0,
160
+ search: true,
161
+ context: true,
162
+ usage: true,
163
+ theme: true,
164
+ fullscreen: !!fullscreenAvailable,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * The fit decision. `available` is the width the inline actions may use in
170
+ * total; each inline action costs its width plus `gap`. An action whose width
171
+ * is unknown (not a finite number) counts as 0. An `available` that is not a
172
+ * finite number means the header could not be measured: every available
173
+ * action then goes into the menu, which always reaches all of them.
174
+ *
175
+ * @param {object} input
176
+ * @param {Record<string, number>} input.widths the inline buttons' widths, by key
177
+ * @param {number} [input.gap] the gap after each inline button
178
+ * @param {number} input.available the width left for the inline actions
179
+ * @param {Record<string, boolean>} input.availability from resolveHeaderActionAvailability
180
+ * @param {string[]} [input.order] priority order (default: HEADER_PRIORITY_ACTION_KEYS)
181
+ * @returns {{ inline: string[], menu: string[], unavailable: string[], used: number }}
182
+ * `used` is the width the inline actions take, their gaps included
183
+ */
184
+ export function fitHeaderPriorityActions({
185
+ widths = {},
186
+ gap = 0,
187
+ available,
188
+ availability = {},
189
+ order = HEADER_PRIORITY_ACTION_KEYS,
190
+ } = {}) {
191
+ const inline = [];
192
+ const menu = [];
193
+ const unavailable = [];
194
+ const room = Number(available);
195
+ const step = Math.max(0, Number(gap) || 0);
196
+ let overflowing = !Number.isFinite(room);
197
+ let used = 0;
198
+ let inlineUsed = 0;
199
+ for (const key of order) {
200
+ if (!availability[key]) {
201
+ // Spends no width, so it never pushes an available action out.
202
+ unavailable.push(key);
203
+ continue;
204
+ }
205
+ const width = Number(widths[key]);
206
+ used += (Number.isFinite(width) && width > 0 ? width : 0) + step;
207
+ if (!overflowing && used <= room) {
208
+ inline.push(key);
209
+ inlineUsed = used;
210
+ } else {
211
+ overflowing = true;
212
+ menu.push(key);
213
+ }
214
+ }
215
+ return { inline, menu, unavailable, used: inlineUsed };
216
+ }
217
+
218
+ /** The menu ids of the keys a fit put into the menu. */
219
+ export function headerPriorityMenuIdsFor(keys) {
220
+ const wanted = new Set(keys || []);
221
+ return HEADER_PRIORITY_ACTIONS.filter((action) => wanted.has(action.key)).map((action) => action.menuId);
222
+ }
223
+
224
+ /**
225
+ * Asks for a new pass. Views that change an action's availability (pin and
226
+ * hidden counts, fullscreen) call this; a no-op outside a browser.
227
+ */
228
+ export function notifyHeaderActionsChanged() {
229
+ const target = typeof window !== 'undefined' ? window : null;
230
+ if (!target || typeof target.dispatchEvent !== 'function' || typeof CustomEvent !== 'function') return;
231
+ try {
232
+ target.dispatchEvent(new CustomEvent(HEADER_ACTIONS_CHANGE_EVENT));
233
+ } catch {}
234
+ }
@@ -19,6 +19,7 @@ import {
19
19
  } from './store.js';
20
20
  import { unhideAllMessagesFromShares, updateMessageShareVisibility } from './api-client.js';
21
21
  import { jumpToPinnedMessage } from './pinned-messages-view.mjs';
22
+ import { notifyHeaderActionsChanged } from './header-priority-actions.mjs';
22
23
 
23
24
  export const HIDDEN_MESSAGES_MODAL_KIND = 'hidden-messages';
24
25
 
@@ -113,12 +114,15 @@ function syncHiddenHeaderButton() {
113
114
  const button = document.getElementById('hidden-messages-btn');
114
115
  if (!button) return;
115
116
  const count = getConversationHiddenMessages(currentConversationId).length;
117
+ // Available or not; the priority+ pass places it (header or ︙).
116
118
  button.hidden = IS_SHARED_VIEW || !count;
119
+ button.dataset.count = IS_SHARED_VIEW ? '0' : String(count);
117
120
  const countEl = button.querySelector('.header-icon-count');
118
121
  if (countEl) countEl.textContent = count ? String(count) : '';
119
122
  const label = count ? `Hidden from shared viewers (${count})` : 'Hidden from shared viewers';
120
123
  button.setAttribute('title', label);
121
124
  button.setAttribute('aria-label', label);
125
+ notifyHeaderActionsChanged();
122
126
  }
123
127
 
124
128
  function hiddenModalContent() {