@oar-sh/oar 0.9.20 → 0.9.22

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 (131) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/README.md +13 -13
  3. package/bin/oar.js +2 -2
  4. package/package.json +1 -1
  5. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
  6. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  9. package/server/claude-worker/claude-session-process.mjs +16 -4
  10. package/server/claude-worker/claude-session-worker-main.mjs +18 -2
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
  13. package/server/cursor-worker/cursor-session-worker.mjs +21 -2
  14. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/grok-background-tasks.mjs +10 -0
  17. package/server/grok-worker/grok-session-worker.mjs +22 -2
  18. package/server/grok-worker/grok-turn-runner.mjs +4 -0
  19. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  20. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  21. package/server/migrations/0038-turn-seq.mjs +92 -0
  22. package/server/public/app/about-ui.js +1 -1
  23. package/server/public/app/about-view.mjs +19 -0
  24. package/server/public/app/action-confirmations.js +1 -1
  25. package/server/public/app/activity-replay-state.mjs +160 -42
  26. package/server/public/app/api-client.js +11 -2
  27. package/server/public/app/ask-user-view.js +24 -9
  28. package/server/public/app/attachments-view.js +1 -1
  29. package/server/public/app/back-guard.mjs +31 -5
  30. package/server/public/app/bootstrap.js +618 -72
  31. package/server/public/app/chat-actions-menu-state.mjs +141 -4
  32. package/server/public/app/chat-header-meta.mjs +27 -0
  33. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  34. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  35. package/server/public/app/composer-settings-label.mjs +90 -0
  36. package/server/public/app/composer-settings-ui.js +442 -0
  37. package/server/public/app/context-menu.mjs +36 -5
  38. package/server/public/app/context-usage-view.mjs +68 -0
  39. package/server/public/app/conversation-deep-link.mjs +15 -11
  40. package/server/public/app/conversation-group-drag.mjs +12 -2
  41. package/server/public/app/conversation-group-order.mjs +9 -2
  42. package/server/public/app/conversation-group-state.mjs +47 -10
  43. package/server/public/app/conversation-origin-ui.js +17 -1
  44. package/server/public/app/conversation-view.js +309 -49
  45. package/server/public/app/cwd-menu-placement.mjs +18 -6
  46. package/server/public/app/cwd-picker.js +36 -6
  47. package/server/public/app/emoji-view.js +1 -1
  48. package/server/public/app/font-scaling.js +26 -0
  49. package/server/public/app/header-priority-actions.mjs +238 -0
  50. package/server/public/app/held-details.mjs +202 -0
  51. package/server/public/app/hidden-messages-view.mjs +4 -0
  52. package/server/public/app/inbox-ui.js +88 -81
  53. package/server/public/app/inbox-view.mjs +35 -19
  54. package/server/public/app/journal-view.js +328 -58
  55. package/server/public/app/live-activity-cap.mjs +103 -0
  56. package/server/public/app/phone-landscape-ui.js +225 -0
  57. package/server/public/app/phone-landscape.mjs +99 -0
  58. package/server/public/app/phone-typing-state.mjs +104 -0
  59. package/server/public/app/pinned-messages-view.mjs +5 -0
  60. package/server/public/app/push-settings.js +2 -2
  61. package/server/public/app/pwa-install.js +24 -3
  62. package/server/public/app/questions-panel-ui.js +634 -0
  63. package/server/public/app/questions-stub-view.mjs +40 -0
  64. package/server/public/app/questions-view.mjs +89 -0
  65. package/server/public/app/relay-board-view.js +12 -2
  66. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  67. package/server/public/app/relay-event-view.mjs +38 -0
  68. package/server/public/app/relay-restart-ui.js +2 -2
  69. package/server/public/app/relay-schedule-view.mjs +32 -6
  70. package/server/public/app/relay-schedules-ui.js +69 -20
  71. package/server/public/app/relay-thoughts.mjs +15 -1
  72. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  73. package/server/public/app/session-history-ui.js +251 -0
  74. package/server/public/app/session-history.mjs +599 -0
  75. package/server/public/app/settings-modal.js +67 -6
  76. package/server/public/app/settings-tabs.js +12 -0
  77. package/server/public/app/share-state.js +1 -1
  78. package/server/public/app/shared-conversations-settings.js +2 -2
  79. package/server/public/app/socket-handlers.js +47 -13
  80. package/server/public/app/status-view.mjs +18 -2
  81. package/server/public/app/store.js +87 -16
  82. package/server/public/app/stream-view-preference.mjs +60 -0
  83. package/server/public/app/subagent-fold.mjs +6 -3
  84. package/server/public/app/subagent-timeline-view.mjs +12 -5
  85. package/server/public/app/subagent-viewer.mjs +35 -8
  86. package/server/public/app/tab-strip-fade.mjs +101 -0
  87. package/server/public/app/tap-actions.js +66 -0
  88. package/server/public/app/thoughts-preference.mjs +79 -0
  89. package/server/public/app/turn-stream-view.mjs +124 -0
  90. package/server/public/app/usage-limit-ui.js +63 -2
  91. package/server/public/app/viewer-history.mjs +13 -2
  92. package/server/public/index.html +2603 -1767
  93. package/server/relay-tools.md +1 -0
  94. package/server/repositories/message-repository.mjs +33 -0
  95. package/server/repositories/question-repository.mjs +51 -7
  96. package/server/repositories/relay-turn-rows.mjs +56 -0
  97. package/server/repositories/session-repository.mjs +19 -0
  98. package/server/routes/ask-user-routes.mjs +34 -5
  99. package/server/routes/claude-cloud-routes.mjs +19 -0
  100. package/server/routes/messages-routes.mjs +92 -15
  101. package/server/routes/relay-ask-routes.mjs +15 -5
  102. package/server/routes/sessions-routes.mjs +117 -12
  103. package/server/server-runtime.mjs +367 -170
  104. package/server/services/about-service.mjs +22 -53
  105. package/server/services/claude-billing-swap-service.mjs +7 -20
  106. package/server/services/claude-cloud-session-service.mjs +47 -5
  107. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  108. package/server/services/idle-timers.mjs +184 -0
  109. package/server/services/queue-wake-hook.mjs +145 -0
  110. package/server/services/queue-waker.mjs +127 -0
  111. package/server/services/relay-ask-service.mjs +64 -5
  112. package/server/services/relay-event-service.mjs +4 -1
  113. package/server/services/remote-relay-dispatcher.mjs +84 -18
  114. package/server/services/session-activity-tracker.mjs +1 -1
  115. package/server/services/session-history-refresh-service.mjs +4 -0
  116. package/server/services/session-launch-mutex-service.mjs +23 -0
  117. package/server/services/session-worker-registry-service.mjs +36 -0
  118. package/server/services/session-worker-supervisor-service.mjs +36 -0
  119. package/server/services/session-worker-websocket-service.mjs +183 -31
  120. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  121. package/server/services/worker-recycle-service.mjs +772 -0
  122. package/server/services/worker-replacement.mjs +197 -0
  123. package/shared/ask-user-bridge.mjs +9 -6
  124. package/shared/build-id.mjs +128 -0
  125. package/shared/relay-asks.mjs +10 -0
  126. package/shared/relay-events.mjs +1 -0
  127. package/shared/worker-runtime/heartbeat.mjs +31 -0
  128. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  129. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  130. package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
  131. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -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,238 @@
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: 'questions', inlineId: 'questions-btn', menuId: 'chat-menu-questions' }),
16
+ Object.freeze({ key: 'pins', inlineId: 'pinned-messages-btn', menuId: 'chat-menu-pins' }),
17
+ Object.freeze({ key: 'hidden', inlineId: 'hidden-messages-btn', menuId: 'chat-menu-hidden' }),
18
+ Object.freeze({ key: 'search', inlineId: 'message-search-btn', menuId: 'chat-menu-search' }),
19
+ Object.freeze({ key: 'context', inlineId: 'context-btn', menuId: 'chat-menu-context' }),
20
+ Object.freeze({ key: 'usage', inlineId: 'usage-btn', menuId: 'chat-menu-usage' }),
21
+ Object.freeze({ key: 'theme', inlineId: 'theme-toggle-btn', menuId: 'chat-menu-theme-toggle' }),
22
+ Object.freeze({ key: 'fullscreen', inlineId: 'fullscreen-btn', menuId: 'chat-menu-fullscreen' }),
23
+ ]);
24
+
25
+ export const HEADER_PRIORITY_ACTION_KEYS = Object.freeze(HEADER_PRIORITY_ACTIONS.map((action) => action.key));
26
+
27
+ /** The twin menu entries, in priority order. */
28
+ export const HEADER_PRIORITY_MENU_IDS = Object.freeze(HEADER_PRIORITY_ACTIONS.map((action) => action.menuId));
29
+
30
+ /** The width the title keeps before any action is shown inline, in rem. */
31
+ export const HEADER_TITLE_MIN_REM = 8;
32
+
33
+ /** The window event that asks for a new pass (counts, availability). */
34
+ export const HEADER_ACTIONS_CHANGE_EVENT = 'oar:header-actions-change';
35
+
36
+ /**
37
+ * Where the pending-question banner has a row of its own (the phone's header
38
+ * CSS): there it takes nothing from the first row.
39
+ */
40
+ export const HEADER_BANNER_OWN_ROW_QUERY = '(max-width: 680px)';
41
+
42
+ /**
43
+ * The share of the header's content width the question banner may take on
44
+ * the first row (its `max-width: 40%` in index.html): the title keeps the rest.
45
+ */
46
+ export const HEADER_BANNER_MAX_SHARE = 0.4;
47
+
48
+ /**
49
+ * The width of everything on the header's first row that never overflows:
50
+ * its padding, ☰, the title's minimum, ︙, the question banner and the flex
51
+ * gaps between them. Widths of 0 are elements that do not show. The banner
52
+ * counts only while it shares the first row (`bannerOwnRow` false), and only
53
+ * with its compact width ("❓ N"): the rest of it gives way before an action
54
+ * does (resolveQuestionBannerMode).
55
+ *
56
+ * @param {object} input
57
+ * @param {number} [input.padding] left plus right padding
58
+ * @param {number} [input.titleMin] the width the title keeps
59
+ * @param {number} [input.toggle] ☰
60
+ * @param {number} [input.banner] the question banner's compact chip
61
+ * @param {number} [input.menu] ︙ (its wrapper)
62
+ * @param {number} [input.gap] the header's column gap
63
+ * @param {boolean} [input.bannerOwnRow] the banner sits on a row of its own
64
+ * @returns {number}
65
+ */
66
+ export function headerReservedWidth({
67
+ padding = 0,
68
+ titleMin = 0,
69
+ toggle = 0,
70
+ banner = 0,
71
+ menu = 0,
72
+ gap = 0,
73
+ bannerOwnRow = false,
74
+ } = {}) {
75
+ const size = (value) => (Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : 0);
76
+ let width = size(padding) + size(titleMin) + size(menu);
77
+ let items = 2; // the title and #chat-header-actions
78
+ if (size(toggle)) {
79
+ width += size(toggle);
80
+ items += 1;
81
+ }
82
+ if (size(banner) && !bannerOwnRow) {
83
+ width += size(banner);
84
+ items += 1;
85
+ }
86
+ return width + size(gap) * (items - 1);
87
+ }
88
+
89
+ /**
90
+ * How the question banner shows once the actions have their places:
91
+ * - 'hidden': no banner;
92
+ * - 'row': a row of its own (a phone held upright, or a header whose first
93
+ * row cannot hold even the chip next to ☰, the title's minimum and ︙),
94
+ * the whole text;
95
+ * - 'compact': the chip "❓ N" on the first row (always on a phone on its
96
+ * side; elsewhere where the room cannot even hold "❓ N open questions");
97
+ * - 'full': the whole text on the first row, ending in an ellipsis where
98
+ * the room is short.
99
+ * The room is what the first row leaves it (the compact width the fit
100
+ * reserved plus whatever the inline actions did not take), capped at
101
+ * `maxShare` of the header's content width.
102
+ *
103
+ * @param {object} input
104
+ * @param {boolean} [input.visible] the banner shows
105
+ * @param {boolean} [input.ownRow] it sits on a row of its own
106
+ * @param {boolean} [input.phoneLandscape] :root[data-phone-landscape]
107
+ * @param {number} [input.room] the width the first row leaves it
108
+ * @param {number} [input.compact] the chip's width: a room below it is 'row'
109
+ * @param {number} [input.contentWidth] the header's width minus its padding
110
+ * @param {number} [input.maxShare] HEADER_BANNER_MAX_SHARE
111
+ * @param {number} [input.fullMin] "❓ N open questions" with the banner's padding and border
112
+ * @returns {'hidden'|'row'|'compact'|'full'}
113
+ */
114
+ export function resolveQuestionBannerMode({
115
+ visible = false,
116
+ ownRow = false,
117
+ phoneLandscape = false,
118
+ room = 0,
119
+ compact = 0,
120
+ contentWidth = Infinity,
121
+ maxShare = HEADER_BANNER_MAX_SHARE,
122
+ fullMin = 0,
123
+ } = {}) {
124
+ if (!visible) return 'hidden';
125
+ if (ownRow) return 'row';
126
+ if (phoneLandscape) return 'compact';
127
+ // Not even the chip fits on the first row: it would push ︙ off the
128
+ // screen, so the banner takes a row of its own (an unmeasured room does not).
129
+ if (Number.isFinite(Number(room)) && Number(room) < (Number(compact) || 0)) return 'row';
130
+ const share = Number(maxShare);
131
+ const width = Number(contentWidth);
132
+ const cap = Number.isFinite(share) && share > 0 && Number.isFinite(width) ? width * share : Infinity;
133
+ // An unmeasured room (NaN) is the chip: it is the width the fit reserved.
134
+ const space = Math.min(Number(room), cap);
135
+ return space >= Math.max(0, Number(fullMin) || 0) ? 'full' : 'compact';
136
+ }
137
+
138
+ /**
139
+ * Whether each action may show at all, in the header or in the menu. Distinct
140
+ * from the fit question, which is only about WHERE an available action shows.
141
+ *
142
+ * @param {object} input
143
+ * @param {boolean} [input.sharedMode] the shared read-only view: Day/Night only
144
+ * @param {number} [input.questionCount] pending questions (📓) of the open conversation
145
+ * @param {number} [input.pinCount] pins of the open conversation
146
+ * @param {number} [input.hiddenCount] messages hidden from shared viewers
147
+ * @param {boolean} [input.fullscreenAvailable] pwa-install.js's verdict (a phone, not installed)
148
+ * @returns {Record<string, boolean>}
149
+ */
150
+ export function resolveHeaderActionAvailability({
151
+ sharedMode = false,
152
+ questionCount = 0,
153
+ pinCount = 0,
154
+ hiddenCount = 0,
155
+ fullscreenAvailable = false,
156
+ } = {}) {
157
+ if (sharedMode) {
158
+ return { questions: false, pins: false, hidden: false, search: false, context: false, usage: false, theme: true, fullscreen: false };
159
+ }
160
+ return {
161
+ questions: Number(questionCount) > 0,
162
+ pins: Number(pinCount) > 0,
163
+ hidden: Number(hiddenCount) > 0,
164
+ search: true,
165
+ context: true,
166
+ usage: true,
167
+ theme: true,
168
+ fullscreen: !!fullscreenAvailable,
169
+ };
170
+ }
171
+
172
+ /**
173
+ * The fit decision. `available` is the width the inline actions may use in
174
+ * total; each inline action costs its width plus `gap`. An action whose width
175
+ * is unknown (not a finite number) counts as 0. An `available` that is not a
176
+ * finite number means the header could not be measured: every available
177
+ * action then goes into the menu, which always reaches all of them.
178
+ *
179
+ * @param {object} input
180
+ * @param {Record<string, number>} input.widths the inline buttons' widths, by key
181
+ * @param {number} [input.gap] the gap after each inline button
182
+ * @param {number} input.available the width left for the inline actions
183
+ * @param {Record<string, boolean>} input.availability from resolveHeaderActionAvailability
184
+ * @param {string[]} [input.order] priority order (default: HEADER_PRIORITY_ACTION_KEYS)
185
+ * @returns {{ inline: string[], menu: string[], unavailable: string[], used: number }}
186
+ * `used` is the width the inline actions take, their gaps included
187
+ */
188
+ export function fitHeaderPriorityActions({
189
+ widths = {},
190
+ gap = 0,
191
+ available,
192
+ availability = {},
193
+ order = HEADER_PRIORITY_ACTION_KEYS,
194
+ } = {}) {
195
+ const inline = [];
196
+ const menu = [];
197
+ const unavailable = [];
198
+ const room = Number(available);
199
+ const step = Math.max(0, Number(gap) || 0);
200
+ let overflowing = !Number.isFinite(room);
201
+ let used = 0;
202
+ let inlineUsed = 0;
203
+ for (const key of order) {
204
+ if (!availability[key]) {
205
+ // Spends no width, so it never pushes an available action out.
206
+ unavailable.push(key);
207
+ continue;
208
+ }
209
+ const width = Number(widths[key]);
210
+ used += (Number.isFinite(width) && width > 0 ? width : 0) + step;
211
+ if (!overflowing && used <= room) {
212
+ inline.push(key);
213
+ inlineUsed = used;
214
+ } else {
215
+ overflowing = true;
216
+ menu.push(key);
217
+ }
218
+ }
219
+ return { inline, menu, unavailable, used: inlineUsed };
220
+ }
221
+
222
+ /** The menu ids of the keys a fit put into the menu. */
223
+ export function headerPriorityMenuIdsFor(keys) {
224
+ const wanted = new Set(keys || []);
225
+ return HEADER_PRIORITY_ACTIONS.filter((action) => wanted.has(action.key)).map((action) => action.menuId);
226
+ }
227
+
228
+ /**
229
+ * Asks for a new pass. Views that change an action's availability (pin and
230
+ * hidden counts, fullscreen) call this; a no-op outside a browser.
231
+ */
232
+ export function notifyHeaderActionsChanged() {
233
+ const target = typeof window !== 'undefined' ? window : null;
234
+ if (!target || typeof target.dispatchEvent !== 'function' || typeof CustomEvent !== 'function') return;
235
+ try {
236
+ target.dispatchEvent(new CustomEvent(HEADER_ACTIONS_CHANGE_EVENT));
237
+ } catch {}
238
+ }
@@ -0,0 +1,202 @@
1
+ // The live poll's diet: the page polls its conversation every 900 ms, and a
2
+ // finished reply's tool lines and thoughts do not change, so the poll names
3
+ // the replies whose details it already holds and the relay leaves those out.
4
+ // Pure: the relay's route uses the param side, the page the store side.
5
+ //
6
+ // The param (`heldDetails`) is `id~version,id~version,…`: an assistant
7
+ // message id and the version of its details the page holds. The version
8
+ // (`detailsVersion` on every assistant message of a full payload) changes
9
+ // with any row that arrives or changes after the turn (a late sub-agent
10
+ // line, a thought update, a history rebuild), and the relay then sends the
11
+ // details again. A held message comes back as `detailsHeld: true` with empty
12
+ // `activities` and `thoughts`, which the page fills from its store.
13
+
14
+ export const HELD_DETAILS_MAX_IDS = 200;
15
+ export const HELD_DETAILS_MAX_BYTES = 8 * 1024;
16
+ const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
17
+ // The shape of a held reply's activities and thoughts. Bump it whenever the
18
+ // relay changes how those arrays are built for a finished reply (a cap, a
19
+ // sanitiser, a new field): every page then holds a stale version and gets
20
+ // each reply in full once.
21
+ export const HELD_DETAILS_SHAPE = 's1';
22
+ // `s1.<tool rows>.<their highest row id>.<thought rows>.<their highest row
23
+ // id>.<fingerprint of the thoughts' text and done>`. Anything else (a version
24
+ // from before this form) is dropped, so the reply goes out in full.
25
+ const VERSION_PATTERN = /^s\d{1,3}\.\d{1,12}\.\d{1,15}\.\d{1,12}\.\d{1,15}\.[0-9a-f]{8}$/;
26
+
27
+ const nonNegative = (value) => Math.max(0, Math.trunc(Number(value) || 0));
28
+
29
+ /**
30
+ * A finished reply's details version. Tool rows are only ever inserted, so
31
+ * their count and highest row id see every change. A thought row is updated
32
+ * in place as its text streams in and when it is done, so the thoughts also
33
+ * carry a fingerprint of their content (`thoughtFingerprint`).
34
+ */
35
+ export function detailsVersion({ activityCount = 0, activityMaxRowId = 0, thoughtCount = 0, thoughtMaxRowId = 0, thoughtHash = '00000000' } = {}) {
36
+ const hash = /^[0-9a-f]{8}$/.test(String(thoughtHash)) ? String(thoughtHash) : '00000000';
37
+ return [HELD_DETAILS_SHAPE, nonNegative(activityCount), nonNegative(activityMaxRowId), nonNegative(thoughtCount), nonNegative(thoughtMaxRowId), hash].join('.');
38
+ }
39
+
40
+ /**
41
+ * The fingerprint of one reply's thought rows: FNV-1a (32 bit) over each
42
+ * row's id, done and text, in row id order. Any change to a thought's text or
43
+ * done changes it, two in the same millisecond included.
44
+ */
45
+ export function thoughtFingerprint(rows) {
46
+ let hash = 0x811c9dc5;
47
+ const mix = (text) => {
48
+ for (let i = 0; i < text.length; i += 1) {
49
+ hash ^= text.charCodeAt(i);
50
+ hash = Math.imul(hash, 0x01000193) >>> 0;
51
+ }
52
+ };
53
+ const ordered = [...(Array.isArray(rows) ? rows : [])].sort((a, b) => Number(a?.rowid) - Number(b?.rowid));
54
+ for (const row of ordered) mix(`${nonNegative(row?.rowid)}\u0001${Number(row?.done) ? 1 : 0}\u0001${String(row?.text ?? '')}\u0002`);
55
+ return hash.toString(16).padStart(8, '0');
56
+ }
57
+
58
+ /**
59
+ * The relay's reading of the param: a Map id → version, or null when it
60
+ * names nothing usable or is too big (over 8 KB or 200 entries: the poll
61
+ * then gets the full payload). Entries that are not `id~version` with an id
62
+ * of [A-Za-z0-9_-]{1,64} are dropped.
63
+ */
64
+ export function parseHeldDetailsParam(raw) {
65
+ if (raw === undefined || raw === null) return null;
66
+ const text = Array.isArray(raw) ? '' : String(raw);
67
+ if (!text || text.length > HELD_DETAILS_MAX_BYTES) return null;
68
+ const parts = text.split(',');
69
+ if (parts.length > HELD_DETAILS_MAX_IDS) return null;
70
+ const held = new Map();
71
+ for (const part of parts) {
72
+ const at = part.indexOf('~');
73
+ if (at <= 0) continue;
74
+ const id = part.slice(0, at);
75
+ const version = part.slice(at + 1);
76
+ if (!ID_PATTERN.test(id) || !VERSION_PATTERN.test(version)) continue;
77
+ held.set(id, version);
78
+ }
79
+ return held.size ? held : null;
80
+ }
81
+
82
+ /**
83
+ * The page's side: what it holds of one open conversation's finished
84
+ * replies. Filled only from conversation payloads fetched over HTTP (never
85
+ * from a socket's reply: the turn-end fallback carries the live list, not
86
+ * the finished one), and only with what it is shown.
87
+ */
88
+ export function createHeldDetailsStore() {
89
+ let conversationId = '';
90
+ // id → { version, activities, thoughts }, in the payload's message order.
91
+ let held = new Map();
92
+ // A poll whose answer named a reply the page does not hold is dropped; the
93
+ // next poll goes without the param. Two such drops in a row and the param
94
+ // stays off until the next full load.
95
+ let fullNext = false;
96
+ let disabled = false;
97
+ let dropStreak = 0;
98
+
99
+ function forConversation(id) {
100
+ const key = String(id || '');
101
+ if (key !== conversationId) {
102
+ conversationId = key;
103
+ held = new Map();
104
+ fullNext = false;
105
+ disabled = false;
106
+ dropStreak = 0;
107
+ }
108
+ }
109
+
110
+ function record(messages) {
111
+ for (const message of Array.isArray(messages) ? messages : []) {
112
+ if (message?.role !== 'assistant' || message.detailsHeld) continue;
113
+ const id = String(message.id || '');
114
+ const version = String(message.detailsVersion || '');
115
+ if (!ID_PATTERN.test(id) || !VERSION_PATTERN.test(version)) continue;
116
+ held.delete(id);
117
+ held.set(id, {
118
+ version,
119
+ activities: Array.isArray(message.activities) ? message.activities : [],
120
+ thoughts: Array.isArray(message.thoughts) ? message.thoughts : [],
121
+ });
122
+ }
123
+ }
124
+
125
+ return {
126
+ /**
127
+ * A payload was applied. `full`: a load of the whole window (opening,
128
+ * reconnecting, a reload), which replaces what is held; otherwise (a
129
+ * poll, a page of older history) its replies are added. `shownIds`, when
130
+ * given, is every message on screen now: entries for others go.
131
+ */
132
+ noteApplied(id, payload, { full = false, shownIds = null } = {}) {
133
+ forConversation(id);
134
+ if (full) {
135
+ held = new Map();
136
+ fullNext = false;
137
+ disabled = false;
138
+ dropStreak = 0;
139
+ }
140
+ record(payload?.messages);
141
+ if (shownIds) {
142
+ const shown = new Set(Array.from(shownIds, (value) => String(value)));
143
+ for (const key of [...held.keys()]) if (!shown.has(key)) held.delete(key);
144
+ }
145
+ },
146
+
147
+ /** The param for the next poll of `id`, or '' to ask for everything. */
148
+ param(id) {
149
+ forConversation(id);
150
+ if (fullNext) {
151
+ fullNext = false;
152
+ return '';
153
+ }
154
+ if (disabled || !held.size) return '';
155
+ // The newest first, as many as fit in 8 KB and 200 entries.
156
+ const parts = [];
157
+ let bytes = 0;
158
+ for (const [key, entry] of [...held.entries()].reverse()) {
159
+ const part = `${key}~${entry.version}`;
160
+ if (parts.length >= HELD_DETAILS_MAX_IDS || bytes + part.length + 1 > HELD_DETAILS_MAX_BYTES) break;
161
+ parts.push(part);
162
+ bytes += part.length + 1;
163
+ }
164
+ return parts.join(',');
165
+ },
166
+
167
+ /**
168
+ * Fill the held replies of a poll's answer from the store, in place.
169
+ * Returns false (the answer must be dropped) when it names one the store
170
+ * does not hold.
171
+ */
172
+ restore(id, payload) {
173
+ forConversation(id);
174
+ const messages = Array.isArray(payload?.messages) ? payload.messages : [];
175
+ const missing = messages.some((message) => message?.detailsHeld && !held.has(String(message.id || '')));
176
+ if (missing) {
177
+ dropStreak += 1;
178
+ if (dropStreak >= 2) disabled = true;
179
+ else fullNext = true;
180
+ return false;
181
+ }
182
+ let restored = 0;
183
+ for (const message of messages) {
184
+ if (!message?.detailsHeld) continue;
185
+ const entry = held.get(String(message.id));
186
+ message.activities = entry.activities;
187
+ message.thoughts = entry.thoughts;
188
+ message.detailsVersion = entry.version;
189
+ delete message.detailsHeld;
190
+ restored += 1;
191
+ }
192
+ // Only a poll the store answered for ends a run of drops; the full
193
+ // poll after a drop does not.
194
+ if (restored) dropStreak = 0;
195
+ return true;
196
+ },
197
+
198
+ /** For tests and the page's own checks. */
199
+ size() { return held.size; },
200
+ has(messageId) { return held.has(String(messageId || '')); },
201
+ };
202
+ }
@@ -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() {