@oar-sh/oar 0.9.20 → 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 (95) hide show
  1. package/CHANGELOG.md +212 -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 +22 -1
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-session-process.mjs +7 -0
  9. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +74 -1
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  12. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  13. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  14. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  15. package/server/grok-worker/grok-session-worker.mjs +12 -1
  16. package/server/grok-worker/grok-turn-runner.mjs +3 -0
  17. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  18. package/server/public/app/about-ui.js +1 -1
  19. package/server/public/app/about-view.mjs +19 -0
  20. package/server/public/app/action-confirmations.js +1 -1
  21. package/server/public/app/api-client.js +4 -2
  22. package/server/public/app/ask-user-view.js +20 -6
  23. package/server/public/app/attachments-view.js +1 -1
  24. package/server/public/app/back-guard.mjs +31 -5
  25. package/server/public/app/bootstrap.js +556 -68
  26. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  27. package/server/public/app/chat-header-meta.mjs +27 -0
  28. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  29. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  30. package/server/public/app/composer-settings-label.mjs +90 -0
  31. package/server/public/app/composer-settings-ui.js +442 -0
  32. package/server/public/app/context-menu.mjs +36 -5
  33. package/server/public/app/conversation-deep-link.mjs +15 -11
  34. package/server/public/app/conversation-group-drag.mjs +12 -2
  35. package/server/public/app/conversation-group-order.mjs +9 -2
  36. package/server/public/app/conversation-group-state.mjs +47 -10
  37. package/server/public/app/conversation-origin-ui.js +17 -1
  38. package/server/public/app/conversation-view.js +3 -0
  39. package/server/public/app/cwd-menu-placement.mjs +18 -6
  40. package/server/public/app/cwd-picker.js +36 -6
  41. package/server/public/app/emoji-view.js +1 -1
  42. package/server/public/app/font-scaling.js +26 -0
  43. package/server/public/app/header-priority-actions.mjs +234 -0
  44. package/server/public/app/hidden-messages-view.mjs +4 -0
  45. package/server/public/app/inbox-ui.js +18 -2
  46. package/server/public/app/journal-view.js +311 -58
  47. package/server/public/app/phone-landscape-ui.js +225 -0
  48. package/server/public/app/phone-landscape.mjs +99 -0
  49. package/server/public/app/phone-typing-state.mjs +104 -0
  50. package/server/public/app/pinned-messages-view.mjs +5 -0
  51. package/server/public/app/push-settings.js +2 -2
  52. package/server/public/app/pwa-install.js +24 -3
  53. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  54. package/server/public/app/relay-event-view.mjs +38 -0
  55. package/server/public/app/relay-restart-ui.js +2 -2
  56. package/server/public/app/relay-schedule-view.mjs +32 -6
  57. package/server/public/app/relay-schedules-ui.js +69 -20
  58. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  59. package/server/public/app/session-history-ui.js +250 -0
  60. package/server/public/app/session-history.mjs +599 -0
  61. package/server/public/app/settings-modal.js +61 -6
  62. package/server/public/app/settings-tabs.js +12 -0
  63. package/server/public/app/share-state.js +1 -1
  64. package/server/public/app/shared-conversations-settings.js +2 -2
  65. package/server/public/app/socket-handlers.js +5 -2
  66. package/server/public/app/status-view.mjs +18 -2
  67. package/server/public/app/store.js +87 -16
  68. package/server/public/app/subagent-viewer.mjs +27 -2
  69. package/server/public/app/tab-strip-fade.mjs +101 -0
  70. package/server/public/app/tap-actions.js +66 -0
  71. package/server/public/app/usage-limit-ui.js +63 -2
  72. package/server/public/app/viewer-history.mjs +13 -2
  73. package/server/public/index.html +2249 -1590
  74. package/server/relay-tools.md +1 -0
  75. package/server/repositories/message-repository.mjs +23 -0
  76. package/server/repositories/session-repository.mjs +19 -0
  77. package/server/routes/claude-cloud-routes.mjs +19 -0
  78. package/server/routes/messages-routes.mjs +57 -1
  79. package/server/routes/sessions-routes.mjs +41 -7
  80. package/server/server-runtime.mjs +190 -40
  81. package/server/services/about-service.mjs +22 -53
  82. package/server/services/claude-billing-swap-service.mjs +7 -20
  83. package/server/services/claude-cloud-session-service.mjs +47 -5
  84. package/server/services/remote-relay-dispatcher.mjs +61 -14
  85. package/server/services/session-activity-tracker.mjs +1 -1
  86. package/server/services/session-launch-mutex-service.mjs +23 -0
  87. package/server/services/session-worker-registry-service.mjs +36 -0
  88. package/server/services/session-worker-websocket-service.mjs +40 -16
  89. package/server/services/worker-recycle-service.mjs +772 -0
  90. package/server/services/worker-replacement.mjs +197 -0
  91. package/shared/build-id.mjs +128 -0
  92. package/shared/worker-runtime/heartbeat.mjs +5 -0
  93. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  94. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  95. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -107,6 +107,7 @@ import {
107
107
  openOriginConversation,
108
108
  openConversationGroupPicker,
109
109
  toggleConversationGroupFold,
110
+ toggleAllConversationGroupFolds,
110
111
  isConversationWorking,
111
112
  } from './journal-view.js';
112
113
  import {
@@ -153,8 +154,8 @@ import {
153
154
  import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
154
155
  import { backgroundTasksSnapshotMark } from './background-tasks-view.mjs';
155
156
  import { newerPageReachesEnd } from './transcript-island.mjs';
156
- import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
157
- import { initHiddenMessagesView, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
157
+ import { initPinnedMessagesView, openPinnedMessagesModal, setPinsConversation } from './pinned-messages-view.mjs';
158
+ import { initHiddenMessagesView, openHiddenMessagesModal, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
158
159
  import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
159
160
  import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
160
161
  import { handleAttachmentInput, retryAttachmentUpload, openAnnotateEditorForPending, openAnnotateEditorForPreview, handleComposerPaste, handleComposerDrop, refreshComposerAttachmentWarning, removeAttachment, clearAttachments, openUploadedAttachmentViewer, setFilePreviewMode, toggleFilePreviewHtml, copyFilePreviewImage, closeFilePreview, goBackFilePreview, openWorkspaceFilePreview, openWorkspaceFilePreviewFromRepo, setRepoBrowserRoot, setRepoBrowserViewMode, toggleRepoBrowserHidden, toggleRepoBrowserHeavy, refreshRepoBrowser, focusRepoTree, setRepoCurrentPath, confirmRepoBrowserCwdPick, startRepoNewFolder } from './attachments-view.js';
@@ -237,6 +238,20 @@ import {
237
238
  isInstalledAppMode,
238
239
  } from './pwa-install.js';
239
240
  import { armBackGuardOnFirstInteraction } from './back-guard.mjs';
241
+ import {
242
+ armSessionHistoryOnFirstInteraction,
243
+ initSessionHistory,
244
+ sessionHistoryConversation,
245
+ sessionHistoryEntryAgain,
246
+ syncSessionDocumentTitle,
247
+ } from './session-history-ui.js';
248
+ // What back closes, beyond the closers imported elsewhere in this file.
249
+ import { closeAnnotateEditor } from './annotate-editor.js';
250
+ import { closeEmojiPicker } from './emoji-view.js';
251
+ import { closeChangeCwdMenu } from './cwd-picker.js';
252
+ import { closeRelayWatchPanel } from './relay-event-subscriptions-ui.js';
253
+ import { closeRelaySchedulePanel } from './relay-schedules-ui.js';
254
+ import { isInboxViewActive } from './inbox-ui.js';
240
255
  import { initScrollbarHover } from './scrollbar-hover.mjs';
241
256
  import {
242
257
  autoCompactWindowNotes,
@@ -255,7 +270,8 @@ import {
255
270
  formatAutoCompactWindowLabel,
256
271
  } from './auto-compact-window-options.mjs';
257
272
  import { renderPlanUsageHtml, planUsageSubtitle, normalizeUsageProvider } from './plan-usage-view.mjs';
258
- import { initFontScaling, updateFontScaleFromSelect } from './font-scaling.js';
273
+ import { revealSelectedTabs, watchTabStripFades } from './tab-strip-fade.mjs';
274
+ import { FONT_SCALE_CHANGE_EVENT, getUiScale, initFontScaling, updateFontScaleFromSelect } from './font-scaling.js';
259
275
  import { initClientDiagnostics, recordStatusEvent } from './status-store.mjs';
260
276
  import { isStatusViewActive, toggleStatusView } from './status-view.mjs';
261
277
  import { installExternalLinkPolicy, openExternalNavigation } from './external-link-policy.mjs';
@@ -384,6 +400,8 @@ import {
384
400
  import { applyPendingActionsFromStatus, initHostSuspendUi } from './host-suspend-ui.js';
385
401
  import { applyUsageLimitFromStatus, initUsageLimitUi } from './usage-limit-ui.js';
386
402
  import { initClaudeBillingToggle, renderClaudeBillingToggle } from './claude-billing-toggle-ui.js';
403
+ import { closeComposerSettings, initComposerSettings, scheduleComposerSettingsSync } from './composer-settings-ui.js';
404
+ import { initPhoneLandscape } from './phone-landscape-ui.js';
387
405
  import {
388
406
  startClaudeRelogin,
389
407
  submitClaudeLoginCodeFromInput,
@@ -411,16 +429,43 @@ import {
411
429
  import { isCurrentConversationClaudeCloud, openCurrentCloudSession, syncClaudeCloudConversationUi } from './claude-cloud-conversation-ui.js';
412
430
  import {
413
431
  CHAT_ACTIONS_MENU_ENTRY_IDS,
432
+ CHAT_ACTIONS_MENU_META_CHIP_IDS,
414
433
  CHAT_ACTIONS_MENU_META_IDS,
434
+ chatActionsMenuMetaChipLabels,
435
+ resolveChatActionsMenuPlacement,
415
436
  resolveChatActionsMenuState,
416
437
  } from './chat-actions-menu-state.mjs';
438
+ import {
439
+ HEADER_ACTIONS_CHANGE_EVENT,
440
+ HEADER_BANNER_OWN_ROW_QUERY,
441
+ HEADER_PRIORITY_ACTIONS,
442
+ HEADER_TITLE_MIN_REM,
443
+ fitHeaderPriorityActions,
444
+ headerPriorityMenuIdsFor,
445
+ headerReservedWidth,
446
+ resolveHeaderActionAvailability,
447
+ resolveQuestionBannerMode,
448
+ } from './header-priority-actions.mjs';
449
+ import { PHONE_LANDSCAPE_ATTRIBUTE, PHONE_LANDSCAPE_CHANGE_EVENT } from './phone-landscape.mjs';
417
450
  import { subscribeProviderReadiness, updateProviderReadinessInputs } from './provider-readiness.mjs';
418
451
  import { hasActiveShare, refreshShares, subscribeShares } from './share-state.js';
419
452
  import { openStopSharingConfirmation } from './shared-conversations-settings.js';
420
453
  import { showAbout, syncAboutMenuLabel } from './about-ui.js';
421
- import { syncConversationOriginHeader } from './conversation-origin-ui.js';
422
- import { loadRelayEventSubscriptions, onRelayEventSubscriptionsChanged, syncRelayEventHeader } from './relay-event-subscriptions-ui.js';
423
- import { loadRelaySchedules, onRelaySchedulesChanged, syncRelayScheduleHeader } from './relay-schedules-ui.js';
454
+ import { conversationOriginMenuEntry, syncConversationOriginHeader } from './conversation-origin-ui.js';
455
+ import {
456
+ loadRelayEventSubscriptions,
457
+ onRelayEventSubscriptionsChanged,
458
+ openRelayWatchPanel,
459
+ relayWatchingChipText,
460
+ syncRelayEventHeader,
461
+ } from './relay-event-subscriptions-ui.js';
462
+ import {
463
+ loadRelaySchedules,
464
+ onRelaySchedulesChanged,
465
+ openRelaySchedulePanel,
466
+ relayScheduleChipText,
467
+ syncRelayScheduleHeader,
468
+ } from './relay-schedules-ui.js';
424
469
  import { leaveInboxView, startInbox, toggleInboxView } from './inbox-ui.js';
425
470
  import {
426
471
  previewAgentSessionsMaxWait,
@@ -816,8 +861,9 @@ function initPushNotificationClientHooks() {
816
861
  });
817
862
  }
818
863
 
819
- // ?push_conv=<id> (a notification tap) and ?conv=<id> (another relay's
820
- // provenance badge links here) both open that conversation once.
864
+ // ?push_conv=<id> (a notification tap, consumed) and ?conv=<id> (the open
865
+ // session's address: a reload, a copied link, another relay's provenance
866
+ // badge) open that conversation at load.
821
867
  function consumePushConversationDeepLink() {
822
868
  return consumeConversationDeepLink();
823
869
  }
@@ -931,27 +977,58 @@ function initNetworkLifecycleHandling() {
931
977
  }
932
978
 
933
979
  function syncThemeMenuLabel() {
980
+ const isLight = document.documentElement.getAttribute('data-theme') === 'light';
934
981
  const button = document.getElementById('chat-menu-theme-toggle');
935
- if (!button) return;
982
+ if (button) button.textContent = isLight ? '🌙 Night mode' : '☀️ Day mode';
983
+ // The header twin: the glyph alone, the words in its tooltip.
984
+ const inline = document.getElementById('theme-toggle-btn');
985
+ if (inline) {
986
+ const label = isLight ? 'Night mode' : 'Day mode';
987
+ inline.textContent = isLight ? '🌙' : '☀️';
988
+ inline.title = label;
989
+ inline.setAttribute('aria-label', label);
990
+ }
991
+ }
992
+
993
+ function toggleThemeFromHeader() {
936
994
  const isLight = document.documentElement.getAttribute('data-theme') === 'light';
937
- button.textContent = isLight ? '🌙 Night mode' : '☀️ Day mode';
995
+ updateTheme(isLight ? 'dark' : 'light');
996
+ syncThemeMenuLabel();
997
+ }
998
+
999
+ // One priority+ action (header-priority-actions.mjs): its header button and
1000
+ // its ︙ twin run the same handler. A header button that already carries its
1001
+ // own binding (an inline onclick, a view module's listener) passes
1002
+ // `inline: false`, and only the twin is bound here.
1003
+ function bindHeaderPriorityAction(key, run, { inline = true } = {}) {
1004
+ const action = HEADER_PRIORITY_ACTIONS.find((entry) => entry.key === key);
1005
+ if (!action) return;
1006
+ const inlineBtn = inline ? document.getElementById(action.inlineId) : null;
1007
+ if (inlineBtn && inlineBtn.dataset.headerActionBound !== '1') {
1008
+ inlineBtn.dataset.headerActionBound = '1';
1009
+ inlineBtn.addEventListener('click', (event) => {
1010
+ event.preventDefault();
1011
+ event.stopPropagation();
1012
+ run(event);
1013
+ });
1014
+ }
1015
+ const twin = document.getElementById(action.menuId);
1016
+ if (twin && twin.dataset.headerActionBound !== '1') {
1017
+ twin.dataset.headerActionBound = '1';
1018
+ bindMenuAction(twin, (event) => {
1019
+ event.preventDefault();
1020
+ event.stopPropagation();
1021
+ lockChatActionsMenuShield(350);
1022
+ closeChatActionsMenu();
1023
+ run(event);
1024
+ });
1025
+ }
938
1026
  }
939
1027
 
940
1028
  function initThemeMenuToggle() {
941
- const themeBtn = document.getElementById('chat-menu-theme-toggle');
942
- if (!themeBtn) return;
943
- themeBtn.hidden = false;
944
1029
  syncThemeMenuLabel();
945
- if (themeBtn.dataset.bound === '1') return;
946
- themeBtn.dataset.bound = '1';
947
- bindMenuAction(themeBtn, (event) => {
948
- event.preventDefault();
949
- event.stopPropagation();
950
- const isLight = document.documentElement.getAttribute('data-theme') === 'light';
951
- updateTheme(isLight ? 'dark' : 'light');
952
- syncThemeMenuLabel();
953
- closeChatActionsMenu();
954
- });
1030
+ // Day/Night is the shared view's one action too, so it is bound in both.
1031
+ bindHeaderPriorityAction('theme', () => toggleThemeFromHeader());
955
1032
  }
956
1033
 
957
1034
  function disableSharedControl(element, {
@@ -1025,18 +1102,16 @@ function applySharedReaderUi() {
1025
1102
  if (effortSelect) effortSelect.hidden = true;
1026
1103
  if (modeSelect) modeSelect.hidden = true;
1027
1104
  // One source for the menu: the resolver's shared branch (Day/Night only,
1028
- // trigger usable). The loop below stays as a guard for entries the resolver
1029
- // does not know.
1105
+ // in the header or in ︙, whichever fits). The loop stays as a guard for
1106
+ // entries the resolver does not know; the sync after it decides Day/Night
1107
+ // and the trigger.
1030
1108
  applyChatActionsMenuState(resolveChatActionsMenuState({ sharedMode: true }));
1031
1109
  if (chatMenu && themeBtn) {
1032
1110
  for (const child of Array.from(chatMenu.children)) {
1033
1111
  if (child !== themeBtn) hideSharedMenuEntry(child);
1034
1112
  }
1035
- themeBtn.hidden = false;
1036
- themeBtn.removeAttribute('aria-hidden');
1037
- themeBtn.disabled = false;
1038
- themeBtn.tabIndex = 0;
1039
1113
  syncThemeMenuLabel();
1114
+ syncChatActionsMenuState();
1040
1115
  } else if (chatMenuBtn) {
1041
1116
  chatMenuBtn.hidden = true;
1042
1117
  }
@@ -1460,6 +1535,8 @@ function syncSessionLockNote({ pinnedModel = '' } = {}) {
1460
1535
  }
1461
1536
  // The billing toggle sits under the note and follows the same chat.
1462
1537
  renderClaudeBillingToggle();
1538
+ // The composer's label shows the lock and the model.
1539
+ scheduleComposerSettingsSync();
1463
1540
  }
1464
1541
 
1465
1542
  function applyModelMetadataHardFail(message = '') {
@@ -2223,6 +2300,8 @@ function applyConversationPreferences({
2223
2300
  suppressConversationPreferenceSync = false;
2224
2301
  }
2225
2302
  syncModeSelectPlanOnlyHint();
2303
+ // Values set here fire no change event: the composer's label reads them.
2304
+ scheduleComposerSettingsSync();
2226
2305
  // Nothing is written to the shared "last used" storage here. Applying a
2227
2306
  // conversation's stored preferences is not a choice: it runs on every open,
2228
2307
  // on the ~1s live poll, on a catalog refresh and on another client's edit,
@@ -2887,7 +2966,7 @@ function renderModelVariantCatalogBody() {
2887
2966
  }
2888
2967
  const visibleProviderOrder = providerOrder.filter((providerKey) => providerBelongsToTab(providerKey, modelVariantCatalogProviderTab));
2889
2968
  const providerTabButtons = `
2890
- <div class="summary-tab-strip" style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
2969
+ <div class="summary-tab-strip" style="display:flex;flex-wrap:wrap;gap:calc(4px * var(--ui-scale)) calc(5px * var(--ui-scale));align-items:center;">
2891
2970
  <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'copilot' ? ' active' : ''}" data-model-provider-tab="copilot">Copilot</button>
2892
2971
  <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'openai' ? ' active' : ''}" data-model-provider-tab="openai" ${hasOpenAITab ? '' : 'disabled'}>OpenAI</button>
2893
2972
  <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'anthropic' ? ' active' : ''}" data-model-provider-tab="anthropic" ${hasAnthropicTab ? '' : 'disabled'}>Claude SDK</button>
@@ -3006,7 +3085,7 @@ function renderModelVariantCatalogBody() {
3006
3085
  `;
3007
3086
  }).join('');
3008
3087
  const bodyHtml = `
3009
- <div style="display:flex;flex-direction:column;gap:10px">
3088
+ <div style="display:flex;flex-direction:column;gap:calc(5px * var(--ui-scale))">
3010
3089
  ${providerTabButtons}
3011
3090
  <div style="font-size:0.8rem;color:var(--muted)">
3012
3091
  Saved globally for this relay. Refreshed: <strong>${escHtml(refreshedLabel)}</strong>
@@ -3018,12 +3097,12 @@ function renderModelVariantCatalogBody() {
3018
3097
  Each model shows all supported reasoning efforts above its selectable variants.
3019
3098
  </div>
3020
3099
  ${warnings.map((line) => `<div style="font-size:0.78rem;color:var(--warn,#f7c873)">${line}</div>`).join('')}
3021
- <div style="display:grid;gap:10px">${groupsHtml || '<div style="color:var(--muted)">No models discovered yet. Click Refresh.</div>'}</div>
3100
+ <div style="display:grid;gap:calc(5px * var(--ui-scale))">${groupsHtml || '<div style="color:var(--muted)">No models discovered yet. Click Refresh.</div>'}</div>
3022
3101
  </div>
3023
3102
  `;
3024
3103
  renderSummaryModalContent({
3025
3104
  title: '🤗 Select Models',
3026
- subtitle: 'Choose model variants shown in the composer selector',
3105
+ subtitle: "Choose model variants shown under Model in the composer's ⚙ settings",
3027
3106
  bodyHtml,
3028
3107
  refresh: async () => {
3029
3108
  const refreshed = await refreshModelVariantCatalog();
@@ -3277,6 +3356,7 @@ function selectUsageTab(root, provider) {
3277
3356
  for (const panel of root.querySelectorAll('[data-usage-panel]')) {
3278
3357
  panel.hidden = panel.dataset.usagePanel !== target;
3279
3358
  }
3359
+ revealSelectedTabs(root);
3280
3360
  usageTabProviderOverride = target;
3281
3361
  if (!usageCardIsFresh(target)) {
3282
3362
  refreshUsageProvider(target).catch(() => {});
@@ -3336,6 +3416,10 @@ function renderUsageReport(report, account, { updating = '' } = {}) {
3336
3416
  kind: 'usage',
3337
3417
  });
3338
3418
  if (updating) markUsageCardUpdating(updating, true);
3419
+ // A new strip each render: its selected tab in view, its fade marked.
3420
+ watchTabStripFades();
3421
+ const body = document.getElementById('summary-modal-body');
3422
+ if (body) revealSelectedTabs(body);
3339
3423
  return true;
3340
3424
  }
3341
3425
 
@@ -3820,11 +3904,15 @@ async function showUsage() {
3820
3904
  // A fresh open follows the current session's provider again; only a click
3821
3905
  // inside the modal pins a tab (for the Refresh button's sake).
3822
3906
  usageTabProviderOverride = null;
3823
- const btn = document.getElementById('chat-menu-usage') || document.getElementById('usage-btn');
3824
- if (btn) {
3825
- btn.textContent = '⏳';
3826
- btn.disabled = true;
3827
- }
3907
+ // The header button and its ︙ twin both show the wait.
3908
+ const usageButtons = ['chat-menu-usage', 'usage-btn'].map((id) => document.getElementById(id)).filter(Boolean);
3909
+ const setUsageBusy = (busy) => {
3910
+ for (const button of usageButtons) {
3911
+ button.textContent = busy ? '⏳' : (button.id === 'chat-menu-usage' ? '📊 Check Usage' : '📊');
3912
+ button.disabled = busy;
3913
+ }
3914
+ };
3915
+ setUsageBusy(true);
3828
3916
  // What was shown last time is shown again at once; only the open tab's
3829
3917
  // provider is read live, and its card is swapped in when the answer comes.
3830
3918
  const kept = lastUsageReport || readStoredUsageReport();
@@ -3838,10 +3926,7 @@ async function showUsage() {
3838
3926
  });
3839
3927
  try {
3840
3928
  if (kept && renderUsageReport(kept, null, { updating: activeProvider })) {
3841
- if (btn) {
3842
- btn.textContent = btn.id === 'chat-menu-usage' ? '📊 Check Usage' : '📊';
3843
- btn.disabled = false;
3844
- }
3929
+ setUsageBusy(false);
3845
3930
  await refreshUsageProvider(activeProvider);
3846
3931
  return;
3847
3932
  }
@@ -3856,10 +3941,7 @@ async function showUsage() {
3856
3941
  kind: 'usage',
3857
3942
  });
3858
3943
  } finally {
3859
- if (btn) {
3860
- btn.textContent = btn.id === 'chat-menu-usage' ? '📊 Check Usage' : '📊';
3861
- btn.disabled = false;
3862
- }
3944
+ setUsageBusy(false);
3863
3945
  }
3864
3946
  }
3865
3947
 
@@ -3913,7 +3995,7 @@ function renderSessionInstructionDocs(docs) {
3913
3995
  return '<div class="summary-loading">No session instruction files were found.</div>';
3914
3996
  }
3915
3997
 
3916
- return `<div style="display:flex;flex-direction:column;gap:12px">${
3998
+ return `<div style="display:flex;flex-direction:column;gap:calc(6px * var(--ui-scale))">${
3917
3999
  items.map((doc) => {
3918
4000
  const title = String(doc?.title || doc?.sessionId || 'Session instructions').trim();
3919
4001
  const name = String(doc?.name || '').trim();
@@ -3923,13 +4005,13 @@ function renderSessionInstructionDocs(docs) {
3923
4005
  const updatedAt = String(doc?.updatedAt || '').trim();
3924
4006
  const content = escHtml(String(doc?.content || '').trim());
3925
4007
  return `
3926
- <details open style="border:1px solid var(--border);border-radius:10px;background:var(--bg3);padding:10px 12px">
4008
+ <details open style="border:1px solid var(--border);border-radius:calc(10px * var(--ui-scale));background:var(--bg3);padding:calc(5px * var(--ui-scale)) calc(7px * var(--ui-scale))">
3927
4009
  <summary style="cursor:pointer;font-weight:600;outline:none">${escHtml(title)}</summary>
3928
- <div style="margin-top:6px;font-size:0.78rem;color:var(--muted)">
4010
+ <div style="margin-top:calc(3px * var(--ui-scale));font-size:0.78rem;color:var(--muted)">
3929
4011
  ${escHtml(summary || sessionId)}
3930
4012
  ${updatedAt ? ` · ${escHtml(updatedAt)}` : ''}
3931
4013
  </div>
3932
- <pre style="margin-top:10px;white-space:pre-wrap;word-break:break-word">${content}</pre>
4014
+ <pre style="margin-top:calc(5px * var(--ui-scale));white-space:pre-wrap;word-break:break-word">${content}</pre>
3933
4015
  </details>
3934
4016
  `;
3935
4017
  }).join('')
@@ -4501,12 +4583,321 @@ function applyChatActionsMenuState(state) {
4501
4583
  }
4502
4584
  }
4503
4585
 
4504
- // The ⋯ menu as it should look right now: the trigger and every entry,
4586
+ // ── Priority+ header actions ──
4587
+ // Pins, hidden, search, context, Check Usage, Day/Night and fullscreen sit in
4588
+ // the header while it has room and fall back into ︙ in reverse priority
4589
+ // order (header-priority-actions.mjs decides; this measures and applies).
4590
+ // The twin ids the last fit put into ︙; null before the header was measured,
4591
+ // which keeps the menu's old reach (Day/Night, Check Usage).
4592
+ let headerPriorityMenuIds = null;
4593
+ // The inputs the last full fit measured with (headerFitSignature); a pass
4594
+ // whose inputs are the same keeps the placement and measures nothing.
4595
+ let headerPriorityFitSignature = '';
4596
+
4597
+ function headerPriorityActionAvailability() {
4598
+ const pins = document.getElementById('pinned-messages-btn');
4599
+ const hiddenBtn = document.getElementById('hidden-messages-btn');
4600
+ const fullscreen = document.getElementById('fullscreen-btn');
4601
+ return resolveHeaderActionAvailability({
4602
+ sharedMode: isSharedReaderMode(),
4603
+ pinCount: Number(pins?.dataset.count || 0),
4604
+ hiddenCount: Number(hiddenBtn?.dataset.count || 0),
4605
+ fullscreenAvailable: fullscreen?.dataset.headerAvailable === '1',
4606
+ });
4607
+ }
4608
+
4609
+ function cssPixels(value) {
4610
+ const number = parseFloat(value);
4611
+ return Number.isFinite(number) ? number : 0;
4612
+ }
4613
+
4614
+ function isHeaderBannerOnOwnRow() {
4615
+ return typeof window.matchMedia === 'function' && window.matchMedia(HEADER_BANNER_OWN_ROW_QUERY).matches;
4616
+ }
4617
+
4618
+ // The question banner's two widths, measured with its compact chip shown
4619
+ // and then its full text: the chip ("❓ N", what the fit reserves) and the
4620
+ // least the full text needs ("❓ N open questions" plus padding and border;
4621
+ // the "latest in …" tail may end in an ellipsis). Zero when it does not show.
4622
+ // Leaves the banner in its full form; the caller sets the mode.
4623
+ function measureQuestionBanner(banner) {
4624
+ if (!banner) return { visible: false, compact: 0, fullMin: 0 };
4625
+ banner.removeAttribute('data-compact');
4626
+ if (!banner.offsetWidth) return { visible: false, compact: 0, fullMin: 0 };
4627
+ const count = banner.querySelector('.pending-question-count');
4628
+ const style = getComputedStyle(banner);
4629
+ const frame = cssPixels(style.paddingLeft) + cssPixels(style.paddingRight)
4630
+ + cssPixels(style.borderLeftWidth) + cssPixels(style.borderRightWidth);
4631
+ // Rounded up: a fraction short would cut the last letter.
4632
+ const fullMin = Math.ceil((count ? count.getBoundingClientRect().width : banner.scrollWidth) + frame);
4633
+ banner.setAttribute('data-compact', '');
4634
+ const compact = Math.ceil(banner.getBoundingClientRect().width);
4635
+ banner.removeAttribute('data-compact');
4636
+ return { visible: true, compact, fullMin };
4637
+ }
4638
+
4639
+ // The width of everything on the header's first row that never overflows
4640
+ // (headerReservedWidth): its padding, ☰, the title's minimum, ︙, the
4641
+ // question banner's compact chip while it shares that row, and the flex gaps
4642
+ // between them. Read on every full pass: all of it moves with --font-scale.
4643
+ function headerFixedWidth(header, bannerCompactWidth) {
4644
+ const style = getComputedStyle(header);
4645
+ const rootFont = cssPixels(getComputedStyle(document.documentElement).fontSize) || 16;
4646
+ return headerReservedWidth({
4647
+ padding: cssPixels(style.paddingLeft) + cssPixels(style.paddingRight),
4648
+ titleMin: HEADER_TITLE_MIN_REM * rootFont,
4649
+ toggle: document.getElementById('sidebar-toggle')?.offsetWidth || 0,
4650
+ banner: bannerCompactWidth,
4651
+ menu: document.getElementById('chat-actions-menu-wrap')?.offsetWidth || 0,
4652
+ gap: cssPixels(style.columnGap || style.gap),
4653
+ // On a phone the banner has a row of its own (the ≤680px header CSS).
4654
+ bannerOwnRow: isHeaderBannerOnOwnRow(),
4655
+ });
4656
+ }
4657
+
4658
+ // Everything the fit depends on, in one string: the header's width, the
4659
+ // title, the banner, the counts and availability, the theme (the glyphs'
4660
+ // widths), the scale and the layout. Cheap to read: no element is shown or
4661
+ // hidden for it.
4662
+ function headerFitSignature(header, availability) {
4663
+ const root = document.documentElement;
4664
+ const rootStyle = getComputedStyle(root);
4665
+ const title = document.getElementById('chat-title');
4666
+ const banner = document.getElementById('pending-question-banner');
4667
+ return JSON.stringify([
4668
+ header.clientWidth,
4669
+ rootStyle.fontSize,
4670
+ rootStyle.getPropertyValue('--ui-scale'),
4671
+ root.getAttribute('data-theme'),
4672
+ root.hasAttribute(PHONE_LANDSCAPE_ATTRIBUTE),
4673
+ isHeaderBannerOnOwnRow(),
4674
+ document.body.classList.contains('chat-title-editing'),
4675
+ title?.hidden, title?.textContent,
4676
+ banner?.className, banner?.hidden, banner?.textContent,
4677
+ document.getElementById('sidebar-toggle')?.hidden,
4678
+ document.getElementById('chat-actions-menu-btn')?.hidden,
4679
+ document.getElementById('pinned-messages-btn')?.dataset.count,
4680
+ document.getElementById('hidden-messages-btn')?.dataset.count,
4681
+ document.getElementById('fullscreen-btn')?.textContent,
4682
+ document.getElementById('theme-toggle-btn')?.textContent,
4683
+ availability,
4684
+ ]);
4685
+ }
4686
+
4687
+ // The last placement, minus whatever is no longer available. Measures nothing.
4688
+ function keepHeaderPriorityPlacement(availability) {
4689
+ for (const action of HEADER_PRIORITY_ACTIONS) {
4690
+ const inline = document.getElementById(action.inlineId);
4691
+ if (inline && !availability[action.key]) inline.hidden = true;
4692
+ }
4693
+ if (Array.isArray(headerPriorityMenuIds)) {
4694
+ const availableMenuIds = new Set(headerPriorityMenuIdsFor(Object.keys(availability).filter((key) => availability[key])));
4695
+ headerPriorityMenuIds = headerPriorityMenuIds.filter((id) => availableMenuIds.has(id));
4696
+ }
4697
+ return headerPriorityMenuIds;
4698
+ }
4699
+
4700
+ // Shows inline what fits and hides the rest; returns the twin ids for ︙.
4701
+ // A header that cannot be measured (hidden, not laid out) keeps the last
4702
+ // placement, minus whatever is no longer available. Unless `force`, a pass
4703
+ // whose inputs are those of the last one keeps its placement too: the hide
4704
+ // and measure below runs only when something it reads has changed.
4705
+ function syncHeaderPriorityFit({ force = false } = {}) {
4706
+ const availability = headerPriorityActionAvailability();
4707
+ const header = document.getElementById('chat-header');
4708
+ const actions = document.getElementById('chat-header-actions');
4709
+ const measurable = !!(header && actions && header.clientWidth && typeof getComputedStyle === 'function');
4710
+ if (!measurable) return keepHeaderPriorityPlacement(availability);
4711
+ const signature = headerFitSignature(header, availability);
4712
+ if (!force && signature === headerPriorityFitSignature && Array.isArray(headerPriorityMenuIds)) {
4713
+ return keepHeaderPriorityPlacement(availability);
4714
+ }
4715
+ headerPriorityFitSignature = signature;
4716
+ // Un-hide every available button before measuring: a hidden one is 0 wide.
4717
+ // All of this runs in one task, so nothing is painted in between.
4718
+ const widths = {};
4719
+ for (const action of HEADER_PRIORITY_ACTIONS) {
4720
+ const inline = document.getElementById(action.inlineId);
4721
+ if (!inline) continue;
4722
+ inline.hidden = !availability[action.key];
4723
+ if (availability[action.key]) widths[action.key] = inline.offsetWidth;
4724
+ }
4725
+ const gap = cssPixels(getComputedStyle(actions).columnGap || getComputedStyle(actions).gap);
4726
+ // The question banner sits between the title and the actions. The fit
4727
+ // reserves its compact chip only, so an action goes into ︙ before the
4728
+ // banner could push it; then the banner takes what the row has left.
4729
+ const banner = document.getElementById('pending-question-banner');
4730
+ const phoneOwnRow = isHeaderBannerOnOwnRow();
4731
+ const phoneLandscape = document.documentElement.hasAttribute(PHONE_LANDSCAPE_ATTRIBUTE);
4732
+ const bannerSize = measureQuestionBanner(banner);
4733
+ const reserved = headerFixedWidth(header, phoneOwnRow ? 0 : bannerSize.compact);
4734
+ // Where ☰, the title's minimum, the chip and ︙ alone overflow the first
4735
+ // row (a narrow #main at a large zoom), the banner takes a row of its own,
4736
+ // as on a phone, and the fit no longer reserves it.
4737
+ const ownRow = phoneOwnRow || resolveQuestionBannerMode({
4738
+ visible: bannerSize.visible,
4739
+ phoneLandscape,
4740
+ room: header.clientWidth - reserved + bannerSize.compact,
4741
+ compact: bannerSize.compact,
4742
+ }) === 'row';
4743
+ const available = header.clientWidth - (ownRow ? headerFixedWidth(header, 0) : reserved);
4744
+ const fit = fitHeaderPriorityActions({
4745
+ widths,
4746
+ gap,
4747
+ available,
4748
+ availability,
4749
+ });
4750
+ const inlineKeys = new Set(fit.inline);
4751
+ for (const action of HEADER_PRIORITY_ACTIONS) {
4752
+ const inline = document.getElementById(action.inlineId);
4753
+ if (inline) inline.hidden = !inlineKeys.has(action.key);
4754
+ }
4755
+ if (banner) {
4756
+ const headerStyle = getComputedStyle(header);
4757
+ const mode = resolveQuestionBannerMode({
4758
+ visible: bannerSize.visible,
4759
+ ownRow,
4760
+ phoneLandscape,
4761
+ room: available - fit.used + bannerSize.compact,
4762
+ compact: bannerSize.compact,
4763
+ contentWidth: header.clientWidth - cssPixels(headerStyle.paddingLeft) - cssPixels(headerStyle.paddingRight),
4764
+ fullMin: bannerSize.fullMin,
4765
+ });
4766
+ banner.toggleAttribute('data-compact', mode === 'compact');
4767
+ // The phone's header CSS places the banner on its own row by itself; a
4768
+ // wider header needs this attribute for it.
4769
+ banner.toggleAttribute('data-own-row', mode === 'row' && !phoneOwnRow);
4770
+ }
4771
+ headerPriorityMenuIds = headerPriorityMenuIdsFor(fit.menu);
4772
+ // Measured at least once (the e2e helpers wait for it).
4773
+ actions.dataset.headerFit = '1';
4774
+ return headerPriorityMenuIds;
4775
+ }
4776
+
4777
+ // The pins and hidden twins carry the count their header button shows.
4778
+ function syncHeaderPriorityTwinLabels() {
4779
+ const label = (buttonId, twinId, text, icon) => {
4780
+ const twin = document.getElementById(twinId);
4781
+ if (!twin) return;
4782
+ const count = Number(document.getElementById(buttonId)?.dataset.count || 0);
4783
+ twin.textContent = count ? `${icon} ${text} (${count})` : `${icon} ${text}`;
4784
+ };
4785
+ label('pinned-messages-btn', 'chat-menu-pins', 'Pinned messages', '📍');
4786
+ label('hidden-messages-btn', 'chat-menu-hidden', 'Hidden from shared viewers', '🙈');
4787
+ }
4788
+
4789
+ // At most one fit per frame. A pass asked for by an observer of one of the
4790
+ // fit's inputs (`force`) measures; one asked for by a header sync (every list
4791
+ // paint ends in one) measures only if headerFitSignature says something
4792
+ // changed. Either way it runs before the next paint.
4793
+ let headerPriorityFrame = 0;
4794
+ let headerPriorityForce = false;
4795
+ function scheduleHeaderPrioritySync({ force = false } = {}) {
4796
+ if (force) headerPriorityForce = true;
4797
+ if (headerPriorityFrame) return;
4798
+ headerPriorityFrame = requestAnimationFrame(() => {
4799
+ headerPriorityFrame = 0;
4800
+ const forced = headerPriorityForce;
4801
+ headerPriorityForce = false;
4802
+ syncChatActionsMenuState({ fit: forced ? 'force' : 'now' });
4803
+ // The header moved or changed size: the open menu follows it.
4804
+ if (forced) placeChatActionsMenu();
4805
+ });
4806
+ }
4807
+
4808
+ // Every reason the fit can change: the header's own box (window, drawer,
4809
+ // font scale), the title, the banner, the theme (the glyph's width), a view's
4810
+ // counts or fullscreen verdict (HEADER_ACTIONS_CHANGE_EVENT), the font scale
4811
+ // and the phone turning. Conversation switches reach it through
4812
+ // syncChatTitleControls.
4813
+ function initHeaderPriorityActions({ sharedMode = false } = {}) {
4814
+ if (!sharedMode) {
4815
+ bindHeaderPriorityAction('pins', () => openPinnedMessagesModal(), { inline: false });
4816
+ bindHeaderPriorityAction('hidden', () => openHiddenMessagesModal(), { inline: false });
4817
+ bindHeaderPriorityAction('search', () => openMessageSearchModal(), { inline: false });
4818
+ bindHeaderPriorityAction('context', () => { void showContext(); }, { inline: false });
4819
+ bindHeaderPriorityAction('usage', () => {
4820
+ showUsage().catch((error) => {
4821
+ alert(error?.message || 'Failed to load usage');
4822
+ });
4823
+ });
4824
+ bindHeaderPriorityAction('fullscreen', () => { void toggleFullscreen(); }, { inline: false });
4825
+ }
4826
+ if (window.__headerPriorityObserversBound) return;
4827
+ window.__headerPriorityObserversBound = true;
4828
+ const forced = () => scheduleHeaderPrioritySync({ force: true });
4829
+ const header = document.getElementById('chat-header');
4830
+ if (header && typeof ResizeObserver === 'function') {
4831
+ new ResizeObserver(forced).observe(header);
4832
+ } else {
4833
+ window.addEventListener('resize', forced);
4834
+ }
4835
+ // The open ︙ menu stays inside the window (a turn, the keyboard).
4836
+ window.addEventListener('resize', () => placeChatActionsMenu());
4837
+ if (typeof MutationObserver === 'function') {
4838
+ new MutationObserver(forced)
4839
+ .observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
4840
+ const title = document.getElementById('chat-title');
4841
+ if (title) {
4842
+ new MutationObserver(forced)
4843
+ .observe(title, { childList: true, characterData: true, subtree: true });
4844
+ }
4845
+ // The pending-question banner shares the first row on a wide screen: it
4846
+ // takes room when it shows (on a phone it has a row of its own).
4847
+ const banner = document.getElementById('pending-question-banner');
4848
+ if (banner) {
4849
+ new MutationObserver(forced)
4850
+ .observe(banner, { attributes: true, attributeFilter: ['class', 'hidden'], childList: true, characterData: true, subtree: true });
4851
+ }
4852
+ }
4853
+ window.addEventListener(HEADER_ACTIONS_CHANGE_EVENT, forced);
4854
+ window.addEventListener(FONT_SCALE_CHANGE_EVENT, forced);
4855
+ window.addEventListener(PHONE_LANDSCAPE_CHANGE_EVENT, forced);
4856
+ document.addEventListener('fullscreenchange', forced);
4857
+ syncChatActionsMenuState({ fit: 'force' });
4858
+ }
4859
+
4860
+ // The meta line's chips the conversation on screen has, for their ︙
4861
+ // stand-ins in phone landscape (where that line is hidden), with the labels.
4862
+ function chatActionsMenuMetaChips(convId) {
4863
+ if (!convId || isSharedReaderMode()) return { chips: {}, labels: null };
4864
+ const watching = relayWatchingChipText(convId);
4865
+ const schedules = relayScheduleChipText(convId);
4866
+ const origin = conversationOriginMenuEntry();
4867
+ return {
4868
+ chips: { watching: !!watching, schedules: !!schedules, origin: !!origin },
4869
+ labels: chatActionsMenuMetaChipLabels({ watching, origin: origin?.label || '' }),
4870
+ };
4871
+ }
4872
+
4873
+ function syncChatActionsMenuMetaChipLabels(labels) {
4874
+ if (!labels) return;
4875
+ for (const [chip, id] of Object.entries(CHAT_ACTIONS_MENU_META_CHIP_IDS)) {
4876
+ const entry = document.getElementById(id);
4877
+ if (entry && labels[chip] && entry.textContent !== labels[chip]) entry.textContent = labels[chip];
4878
+ }
4879
+ }
4880
+
4881
+ // The ︙ menu as it should look right now: the trigger and every entry,
4505
4882
  // decided in chat-actions-menu-state.mjs from the conversation on screen.
4506
- function syncChatActionsMenuState() {
4883
+ // `fit`: 'schedule' (the default) keeps the twins the last fit chose and asks
4884
+ // for a fit before the next paint; 'now' fits first if any of its inputs
4885
+ // changed; 'force' fits first whatever.
4886
+ function syncChatActionsMenuState({ fit = 'schedule' } = {}) {
4507
4887
  const convId = String(currentConvId || '').trim();
4508
4888
  const conversation = convId ? (conversations[convId] || null) : null;
4509
4889
  const isCloud = !!convId && isCurrentConversationClaudeCloud();
4890
+ let headerMenuIds;
4891
+ if (fit === 'now' || fit === 'force') {
4892
+ headerMenuIds = syncHeaderPriorityFit({ force: fit === 'force' });
4893
+ } else {
4894
+ headerMenuIds = keepHeaderPriorityPlacement(headerPriorityActionAvailability());
4895
+ scheduleHeaderPrioritySync();
4896
+ }
4897
+ syncHeaderPriorityTwinLabels();
4898
+ const metaChips = chatActionsMenuMetaChips(convId);
4899
+ syncChatActionsMenuMetaChipLabels(metaChips.labels);
4900
+ const fullscreenBtn = document.getElementById('fullscreen-btn');
4510
4901
  const state = resolveChatActionsMenuState({
4511
4902
  sharedMode: isSharedReaderMode(),
4512
4903
  conversationId: convId,
@@ -4517,20 +4908,60 @@ function syncChatActionsMenuState() {
4517
4908
  editing: !!convId && chatTitleEditingConversationId === convId,
4518
4909
  suspendVisible: isSuspendHostActionVisible(),
4519
4910
  hasActiveShare: hasActiveShare(convId),
4911
+ headerMenuIds,
4912
+ // As the header button: disabled where the browser cannot go fullscreen.
4913
+ fullscreenSupported: !fullscreenBtn?.disabled,
4914
+ phoneLandscape: document.documentElement.hasAttribute(PHONE_LANDSCAPE_ATTRIBUTE),
4915
+ metaChips: metaChips.chips,
4520
4916
  });
4917
+ const trigger = document.getElementById('chat-actions-menu-btn');
4918
+ const triggerWasHidden = !!trigger?.hidden;
4521
4919
  applyChatActionsMenuState(state);
4920
+ // ︙ coming or going changes the room the fit has (the shared view's menu
4921
+ // empties once Day/Night fits): one more pass settles it.
4922
+ if (trigger && !!trigger.hidden !== triggerWasHidden) scheduleHeaderPrioritySync({ force: true });
4522
4923
  return state;
4523
4924
  }
4524
4925
 
4525
4926
  // The menu follows the relay's share list (made, renewed, stopped anywhere).
4526
4927
  subscribeShares(() => syncChatActionsMenuState());
4527
4928
 
4929
+ // Keeps the open ︙ menu inside the viewport and #main (never off-screen,
4930
+ // never under the sidebar) and caps its height at what is left below it,
4931
+ // where it scrolls (resolveChatActionsMenuPlacement decides).
4932
+ function placeChatActionsMenu() {
4933
+ const menu = document.getElementById('chat-actions-menu');
4934
+ if (!menu || menu.hidden) return;
4935
+ // Measured as CSS places it; where the reader scrolled to stays.
4936
+ const scrollTop = menu.scrollTop;
4937
+ menu.style.right = '';
4938
+ menu.style.maxWidth = '';
4939
+ menu.style.maxHeight = '';
4940
+ const rect = menu.getBoundingClientRect();
4941
+ if (!rect.width && !rect.height) return;
4942
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
4943
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
4944
+ const main = document.getElementById('main')?.getBoundingClientRect?.();
4945
+ const bounds = {
4946
+ left: Math.max(0, main && main.width ? main.left : 0),
4947
+ right: Math.min(viewportWidth, main && main.width ? main.right : viewportWidth),
4948
+ bottom: Math.min(viewportHeight, main && main.height ? main.bottom : viewportHeight),
4949
+ };
4950
+ // 8px from the edges and a 120px floor at 100 %, scaled with the zoom.
4951
+ const scale = getUiScale();
4952
+ const placement = resolveChatActionsMenuPlacement({ rect, bounds, margin: 8 * scale, minHeight: 120 * scale });
4953
+ if (placement.right) menu.style.right = `${placement.right}px`;
4954
+ if (placement.maxWidth !== null) menu.style.maxWidth = `${placement.maxWidth}px`;
4955
+ menu.style.maxHeight = `${placement.maxHeight}px`;
4956
+ if (scrollTop) menu.scrollTop = scrollTop;
4957
+ }
4958
+
4528
4959
  function toggleChatActionsMenu() {
4529
4960
  const menu = document.getElementById('chat-actions-menu');
4530
4961
  const trigger = document.getElementById('chat-actions-menu-btn');
4531
4962
  if (!menu || !trigger) return;
4532
4963
  // The entries match the moment of the tap, as Refresh history already does.
4533
- syncChatActionsMenuState();
4964
+ syncChatActionsMenuState({ fit: 'now' });
4534
4965
  if (trigger.hidden || trigger.disabled) return;
4535
4966
  syncRefreshHistoryMenuState();
4536
4967
  syncThemeMenuLabel();
@@ -4538,6 +4969,10 @@ function toggleChatActionsMenu() {
4538
4969
  const willOpen = !!menu.hidden;
4539
4970
  menu.hidden = !willOpen;
4540
4971
  trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
4972
+ if (willOpen) {
4973
+ placeChatActionsMenu();
4974
+ menu.scrollTop = 0;
4975
+ }
4541
4976
  }
4542
4977
 
4543
4978
  function isConversationLocallyProcessing(conversation) {
@@ -4644,12 +5079,14 @@ function syncChatTitleControls() {
4644
5079
  const editing = convId && chatTitleEditingConversationId === convId;
4645
5080
  document.body.classList.toggle('chat-title-editing', !!editing);
4646
5081
  if (title) title.hidden = editing;
4647
- // The ⋯ trigger and its entries: relay-wide ones stay with no conversation
4648
- // open, so Settings is always one tap away.
5082
+ // The ︙ trigger and its entries: relay-wide ones stay with no conversation
5083
+ // open, so Settings is always one tap away. The header's fit is only asked
5084
+ // for here (once per frame at most, and it measures only when its inputs
5085
+ // changed): every list paint ends in this call.
4649
5086
  syncChatActionsMenuState();
4650
5087
  const archiveBtn = document.getElementById('chat-menu-archive');
4651
5088
  if (archiveBtn) {
4652
- archiveBtn.textContent = conversation?.archived === true ? '📂 Unarchive conversation' : '🗄 Archive conversation';
5089
+ archiveBtn.textContent = conversation?.archived === true ? '📂 Unarchive conversation' : '🗄️ Archive conversation';
4653
5090
  }
4654
5091
  if (editor) {
4655
5092
  editor.hidden = !editing;
@@ -4731,6 +5168,7 @@ function applyConversationTitleUpdate(conversationId, title, updatedAt) {
4731
5168
  if (currentConvId === id) {
4732
5169
  const titleEl = document.getElementById('chat-title');
4733
5170
  if (titleEl) titleEl.textContent = nextTitle;
5171
+ syncSessionDocumentTitle();
4734
5172
  }
4735
5173
  renderConvList();
4736
5174
  }
@@ -4907,6 +5345,11 @@ async function initApp() {
4907
5345
  renderConvList();
4908
5346
  },
4909
5347
  });
5348
+ // The ⚙ panel and the label row above the message box.
5349
+ initComposerSettings({ isSharedReaderMode, showContext: () => showContext() });
5350
+ // A phone turned sideways: its typing mode folds the label row and the
5351
+ // task line into floating buttons while the keyboard is up.
5352
+ initPhoneLandscape({ isSharedReaderMode });
4910
5353
  }
4911
5354
  setupViewportTracking();
4912
5355
  bindChatSelectionGuard();
@@ -4938,24 +5381,37 @@ async function initApp() {
4938
5381
  event.stopPropagation();
4939
5382
  toggleChatActionsMenu();
4940
5383
  });
5384
+ // Header buttons and their ︙ twins (Check Usage among them), and the
5385
+ // passes that decide which of them fit.
5386
+ initHeaderPriorityActions({ sharedMode });
4941
5387
  if (!sharedMode) {
4942
5388
  const chatMenuEditBtn = document.getElementById('chat-menu-edit-title');
4943
- const chatMenuUsageBtn = document.getElementById('chat-menu-usage');
4944
- bindMenuAction(chatMenuUsageBtn, (event) => {
5389
+ bindMenuAction(chatMenuEditBtn, (event) => {
4945
5390
  event.preventDefault();
4946
5391
  event.stopPropagation();
4947
5392
  lockChatActionsMenuShield(350);
4948
5393
  closeChatActionsMenu();
4949
- showUsage().catch((error) => {
4950
- alert(error?.message || 'Failed to load usage');
4951
- });
5394
+ openChatTitleEditor();
4952
5395
  });
4953
- bindMenuAction(chatMenuEditBtn, (event) => {
5396
+ // Phone landscape hides the meta line: these stand in for its chips and
5397
+ // do what a tap on the chip does. The lists open under ︙, the chip
5398
+ // having no box to hang from.
5399
+ const metaChipAction = (id, run) => bindMenuAction(document.getElementById(id), (event) => {
4954
5400
  event.preventDefault();
4955
5401
  event.stopPropagation();
4956
5402
  lockChatActionsMenuShield(350);
4957
5403
  closeChatActionsMenu();
4958
- openChatTitleEditor();
5404
+ run();
5405
+ });
5406
+ metaChipAction(CHAT_ACTIONS_MENU_META_CHIP_IDS.watching, () => {
5407
+ openRelayWatchPanel({ anchor: document.getElementById('chat-actions-menu-btn') });
5408
+ });
5409
+ metaChipAction(CHAT_ACTIONS_MENU_META_CHIP_IDS.schedules, () => {
5410
+ openRelaySchedulePanel({ anchor: document.getElementById('chat-actions-menu-btn') });
5411
+ });
5412
+ metaChipAction(CHAT_ACTIONS_MENU_META_CHIP_IDS.origin, () => {
5413
+ const origin = conversationOriginMenuEntry();
5414
+ if (origin) void openOriginConversation(null, { dataset: { originConversationId: origin.conversationId } });
4959
5415
  });
4960
5416
  const chatMenuCompactBtn = document.getElementById('chat-menu-compact');
4961
5417
  if (chatMenuCompactBtn && !chatMenuCompactBtn.hidden && !chatMenuCompactBtn.disabled) {
@@ -5267,7 +5723,9 @@ async function initApp() {
5267
5723
  initPushNotificationClientHooks();
5268
5724
  // Browsers without Background Sync replay the outbox from the page instead.
5269
5725
  void initOutboxFallbackReplay();
5270
- await loadConversations();
5726
+ // Read before anything opens: the address then follows the open session.
5727
+ const pushConversationId = consumePushConversationDeepLink();
5728
+ await loadConversations({ openLast: !pushConversationId });
5271
5729
  // The sidebar's "watched" badges follow the subscriptions.
5272
5730
  onRelayEventSubscriptionsChanged(() => renderConvList());
5273
5731
  void loadRelayEventSubscriptions();
@@ -5276,9 +5734,8 @@ async function initApp() {
5276
5734
  void loadRelaySchedules();
5277
5735
  // The Inbox's count, and the cards of questions that don't block.
5278
5736
  startInbox();
5279
- const pushConversationId = consumePushConversationDeepLink();
5280
5737
  if (pushConversationId) {
5281
- await openConversation(pushConversationId).catch(() => {});
5738
+ await openConversation(pushConversationId, { history: 'replace' }).catch(() => {});
5282
5739
  }
5283
5740
  await loadRelayQuestions(currentConvId);
5284
5741
  await loadRelayBoards();
@@ -5333,6 +5790,31 @@ async function bootstrap() {
5333
5790
  }
5334
5791
  const urlToken = getTokenFromUrl();
5335
5792
  if (urlToken) stripTokenFromUrl();
5793
+ // Back and forward walk the sessions opened, and back closes an open
5794
+ // overlay first (session-history.mjs).
5795
+ initSessionHistory({
5796
+ openConversation,
5797
+ openSidebar,
5798
+ closeSidebar,
5799
+ closeComposerSettings,
5800
+ closeChatActionsMenu,
5801
+ closeSummaryModal,
5802
+ closeSettingsModal,
5803
+ closeMessageSearchModal,
5804
+ closeRepoBrowser,
5805
+ closeNewConversationModelModal,
5806
+ // Leaving the Inbox goes back to the conversation it was opened from.
5807
+ // Opened from Status it stored none: `returnTo` is the session history stands on.
5808
+ closeInboxView: ({ returnTo = '' } = {}) => { if (isInboxViewActive()) void toggleInboxView({ returnTo }); },
5809
+ closeChangeCwdMenu: () => closeChangeCwdMenu({ focusTrigger: true }),
5810
+ closeGitChangesModal,
5811
+ closeGitDiffViewer,
5812
+ closeTmuxInspector: closeTmuxInspectorView,
5813
+ closeAnnotateEditor,
5814
+ closeRelayWatchPanel,
5815
+ closeRelaySchedulePanel,
5816
+ closeEmojiPicker,
5817
+ });
5336
5818
  // In the installed app the system back button must never close the app;
5337
5819
  // a tab keeps its own back button. After the token is gone from the
5338
5820
  // address, so the entries the guard lays down carry the clean one.
@@ -5341,7 +5823,10 @@ async function bootstrap() {
5341
5823
  enabled: isInstalledAppMode(),
5342
5824
  target: window,
5343
5825
  onFloor: () => showTransientRelayNotice('Press back again to close the app', 2500),
5826
+ entryAgain: sessionHistoryEntryAgain,
5344
5827
  });
5828
+ // Armed after the guard: on a phone the session list lies above its floor.
5829
+ armSessionHistoryOnFirstInteraction(window);
5345
5830
  const persistedToken = consumeLegacyPersistedAuthToken();
5346
5831
  const bootstrapToken = String(urlToken || persistedToken || '').trim();
5347
5832
  const existingSession = await verifyExistingSession(bootstrapToken);
@@ -5480,17 +5965,20 @@ window.deleteConv = deleteConv;
5480
5965
  window.archiveConv = archiveConv;
5481
5966
  window.openConversationGroupPicker = openConversationGroupPicker;
5482
5967
  window.toggleConversationGroupFold = toggleConversationGroupFold;
5968
+ window.toggleAllConversationGroupFolds = toggleAllConversationGroupFolds;
5483
5969
  window.unarchiveConv = unarchiveConv;
5484
5970
  window.toggleArchivedConversations = toggleArchivedConversations;
5485
5971
  window.refreshNewConversationCloudRepoList = refreshNewConversationCloudRepoList;
5486
5972
  window.openConversation = openConversation;
5487
5973
  window.openOriginConversation = openOriginConversation;
5488
5974
  // The Inbox and Status share the transcript's place: opening one leaves the other.
5975
+ // Closed by its button, one opened from the other goes back to the session
5976
+ // history stands on.
5489
5977
  window.toggleStatusView = () => {
5490
5978
  leaveInboxView();
5491
- return toggleStatusView();
5979
+ return toggleStatusView({ returnTo: sessionHistoryConversation() });
5492
5980
  };
5493
- window.toggleInboxView = toggleInboxView;
5981
+ window.toggleInboxView = () => toggleInboxView({ returnTo: sessionHistoryConversation() });
5494
5982
  window.refreshConversations = refreshConversations;
5495
5983
  window.renderConvList = renderConvList;
5496
5984
  window.handleAttachmentInput = handleAttachmentInput;