@oar-sh/oar 0.9.19 → 0.9.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  3. package/CHANGELOG.md +529 -0
  4. package/README.md +14 -14
  5. package/bin/oar.js +392 -98
  6. package/package.json +4 -2
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -1
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
  10. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  11. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  12. package/server/claude-worker/claude-session-process.mjs +10 -0
  13. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  14. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  15. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  16. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  17. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  18. package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
  19. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  20. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  21. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  22. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  23. package/server/db-schema.mjs +18 -0
  24. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  25. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  26. package/server/grok-worker/grok-session-worker.mjs +12 -1
  27. package/server/grok-worker/grok-turn-runner.mjs +47 -6
  28. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  29. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  30. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  31. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  32. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  33. package/server/migrations/0005-remote-relays.mjs +5 -7
  34. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  35. package/server/migrations/0007-claude-cloud.mjs +5 -7
  36. package/server/migrations/0008-message-pins.mjs +5 -7
  37. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  38. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  39. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  40. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  41. package/server/migrations/0013-previews.mjs +5 -7
  42. package/server/migrations/0014-conversation-groups.mjs +5 -7
  43. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  44. package/server/migrations/0016-share-access-log.mjs +5 -7
  45. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  46. package/server/migrations/0018-background-task-rows.mjs +5 -7
  47. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  48. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  49. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  50. package/server/migrations/0022-relay-schedules.mjs +5 -7
  51. package/server/migrations/0023-relay-asks.mjs +5 -7
  52. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  53. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  54. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  55. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  56. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  57. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  58. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  59. package/server/migrations/0031-queue-provider.mjs +5 -7
  60. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  61. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  62. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  63. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  64. package/server/migrations/0036-token-usage.mjs +267 -0
  65. package/server/public/app/about-ui.js +1 -1
  66. package/server/public/app/about-view.mjs +106 -3
  67. package/server/public/app/action-confirmations.js +1 -1
  68. package/server/public/app/api-client.js +6 -3
  69. package/server/public/app/ask-user-view.js +20 -6
  70. package/server/public/app/attachments-view.js +1 -1
  71. package/server/public/app/back-guard.mjs +31 -5
  72. package/server/public/app/bootstrap.js +611 -70
  73. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  74. package/server/public/app/chat-header-meta.mjs +27 -0
  75. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  76. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  77. package/server/public/app/composer-control-state.mjs +12 -2
  78. package/server/public/app/composer-settings-label.mjs +90 -0
  79. package/server/public/app/composer-settings-ui.js +442 -0
  80. package/server/public/app/context-menu.mjs +36 -5
  81. package/server/public/app/context-usage-view.mjs +16 -0
  82. package/server/public/app/conversation-deep-link.mjs +15 -11
  83. package/server/public/app/conversation-group-drag.mjs +12 -2
  84. package/server/public/app/conversation-group-order.mjs +9 -2
  85. package/server/public/app/conversation-group-state.mjs +47 -10
  86. package/server/public/app/conversation-origin-ui.js +17 -1
  87. package/server/public/app/conversation-view.js +20 -2
  88. package/server/public/app/cwd-menu-placement.mjs +18 -6
  89. package/server/public/app/cwd-picker.js +36 -6
  90. package/server/public/app/emoji-view.js +1 -1
  91. package/server/public/app/font-scaling.js +26 -0
  92. package/server/public/app/header-priority-actions.mjs +234 -0
  93. package/server/public/app/hidden-messages-view.mjs +4 -0
  94. package/server/public/app/inbox-ui.js +50 -2
  95. package/server/public/app/inbox-view.mjs +141 -5
  96. package/server/public/app/journal-view.js +311 -58
  97. package/server/public/app/phone-landscape-ui.js +225 -0
  98. package/server/public/app/phone-landscape.mjs +99 -0
  99. package/server/public/app/phone-typing-state.mjs +104 -0
  100. package/server/public/app/pinned-messages-view.mjs +5 -0
  101. package/server/public/app/plan-board-shared.mjs +49 -0
  102. package/server/public/app/plan-usage-view.mjs +1 -1
  103. package/server/public/app/push-settings.js +2 -2
  104. package/server/public/app/pwa-install.js +24 -3
  105. package/server/public/app/relay-board-inline.mjs +4 -1
  106. package/server/public/app/relay-board-view.js +38 -10
  107. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  108. package/server/public/app/relay-event-view.mjs +42 -0
  109. package/server/public/app/relay-restart-ui.js +2 -2
  110. package/server/public/app/relay-schedule-view.mjs +32 -6
  111. package/server/public/app/relay-schedules-ui.js +69 -20
  112. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  113. package/server/public/app/session-history-ui.js +250 -0
  114. package/server/public/app/session-history.mjs +599 -0
  115. package/server/public/app/settings-modal.js +61 -6
  116. package/server/public/app/settings-tabs.js +12 -0
  117. package/server/public/app/share-state.js +1 -1
  118. package/server/public/app/shared-conversations-settings.js +2 -2
  119. package/server/public/app/socket-handlers.js +15 -4
  120. package/server/public/app/status-view.mjs +18 -2
  121. package/server/public/app/store.js +87 -16
  122. package/server/public/app/subagent-viewer.mjs +27 -2
  123. package/server/public/app/tab-strip-fade.mjs +101 -0
  124. package/server/public/app/tap-actions.js +66 -0
  125. package/server/public/app/usage-limit-ui.js +63 -2
  126. package/server/public/app/viewer-history.mjs +13 -2
  127. package/server/public/index.html +2258 -1585
  128. package/server/public/sw.js +8 -0
  129. package/server/relay-heap-guard.mjs +14 -1
  130. package/server/relay-self-restart.mjs +83 -2
  131. package/server/relay-tools.md +5 -3
  132. package/server/repositories/message-repository.mjs +54 -1
  133. package/server/repositories/remote-relay-repository.mjs +10 -0
  134. package/server/repositories/session-repository.mjs +25 -1
  135. package/server/routes/claude-cloud-routes.mjs +19 -0
  136. package/server/routes/messages-routes.mjs +144 -1
  137. package/server/routes/relay-ask-routes.mjs +138 -8
  138. package/server/routes/relay-board-routes.mjs +55 -40
  139. package/server/routes/sessions-routes.mjs +144 -14
  140. package/server/server-runtime.mjs +322 -51
  141. package/server/services/about-service.mjs +81 -53
  142. package/server/services/cache-rebuild-service.mjs +7 -0
  143. package/server/services/claude-billing-swap-service.mjs +7 -20
  144. package/server/services/claude-cloud-session-service.mjs +47 -5
  145. package/server/services/conversation-preferences-service.mjs +31 -0
  146. package/server/services/database-file.mjs +413 -0
  147. package/server/services/db-backup-service.mjs +809 -0
  148. package/server/services/deepseek-usage-import.mjs +6 -9
  149. package/server/services/delete-archive-service.mjs +3 -0
  150. package/server/services/earlier-database-service.mjs +69 -126
  151. package/server/services/oar-cli-helpers.mjs +214 -12
  152. package/server/services/oar-cli-menu.mjs +1 -0
  153. package/server/services/oar-state-migration-service.mjs +33 -13
  154. package/server/services/package-data-rescue.mjs +4 -2
  155. package/server/services/plan-usage-claude-api.mjs +14 -15
  156. package/server/services/plan-usage-contract.mjs +6 -2
  157. package/server/services/plan-usage-deepseek.mjs +1 -1
  158. package/server/services/plan-usage-tokens.mjs +248 -0
  159. package/server/services/push-dispatch-service.mjs +134 -9
  160. package/server/services/relay-cli-launcher-service.mjs +3 -0
  161. package/server/services/relay-event-service.mjs +30 -0
  162. package/server/services/relay-question-context.mjs +11 -0
  163. package/server/services/relay-restart-picture.mjs +67 -22
  164. package/server/services/remote-inbox-watch-service.mjs +204 -0
  165. package/server/services/remote-relay-caller-context.mjs +80 -7
  166. package/server/services/remote-relay-dispatcher.mjs +101 -19
  167. package/server/services/restart-resume-service.mjs +5 -2
  168. package/server/services/session-activity-tracker.mjs +1 -1
  169. package/server/services/session-launch-mutex-service.mjs +23 -0
  170. package/server/services/session-worker-launch-service.mjs +12 -3
  171. package/server/services/session-worker-registry-service.mjs +36 -0
  172. package/server/services/session-worker-stop-service.mjs +27 -6
  173. package/server/services/session-worker-websocket-service.mjs +40 -16
  174. package/server/services/token-usage-service.mjs +430 -0
  175. package/server/services/update-install-service.mjs +13 -0
  176. package/server/services/usage-limit-pause-service.mjs +8 -1
  177. package/server/services/worker-recycle-service.mjs +772 -0
  178. package/server/services/worker-replacement.mjs +197 -0
  179. package/server/services/worker-scope.mjs +77 -0
  180. package/shared/build-id.mjs +128 -0
  181. package/shared/plan-board-actions.mjs +130 -0
  182. package/shared/plan-only.mjs +43 -0
  183. package/shared/relay-asks.mjs +3 -0
  184. package/shared/relay-events.mjs +38 -4
  185. package/shared/remote-relay-contract.mjs +66 -11
  186. package/shared/turn-token-usage.mjs +144 -0
  187. package/shared/worker-runtime/heartbeat.mjs +5 -0
  188. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  189. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  190. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -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,11 +238,26 @@ 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,
243
258
  renderAutoCompactControlHtml,
244
259
  renderAgentSessionsControlHtml,
260
+ renderPlanOnlyControlHtml,
245
261
  renderAutoCompactNotesHtml,
246
262
  renderContextUsageHtml,
247
263
  renderThinkingControlHtml,
@@ -254,7 +270,8 @@ import {
254
270
  formatAutoCompactWindowLabel,
255
271
  } from './auto-compact-window-options.mjs';
256
272
  import { renderPlanUsageHtml, planUsageSubtitle, normalizeUsageProvider } from './plan-usage-view.mjs';
257
- 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';
258
275
  import { initClientDiagnostics, recordStatusEvent } from './status-store.mjs';
259
276
  import { isStatusViewActive, toggleStatusView } from './status-view.mjs';
260
277
  import { installExternalLinkPolicy, openExternalNavigation } from './external-link-policy.mjs';
@@ -383,6 +400,8 @@ import {
383
400
  import { applyPendingActionsFromStatus, initHostSuspendUi } from './host-suspend-ui.js';
384
401
  import { applyUsageLimitFromStatus, initUsageLimitUi } from './usage-limit-ui.js';
385
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';
386
405
  import {
387
406
  startClaudeRelogin,
388
407
  submitClaudeLoginCodeFromInput,
@@ -410,16 +429,43 @@ import {
410
429
  import { isCurrentConversationClaudeCloud, openCurrentCloudSession, syncClaudeCloudConversationUi } from './claude-cloud-conversation-ui.js';
411
430
  import {
412
431
  CHAT_ACTIONS_MENU_ENTRY_IDS,
432
+ CHAT_ACTIONS_MENU_META_CHIP_IDS,
413
433
  CHAT_ACTIONS_MENU_META_IDS,
434
+ chatActionsMenuMetaChipLabels,
435
+ resolveChatActionsMenuPlacement,
414
436
  resolveChatActionsMenuState,
415
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';
416
450
  import { subscribeProviderReadiness, updateProviderReadinessInputs } from './provider-readiness.mjs';
417
451
  import { hasActiveShare, refreshShares, subscribeShares } from './share-state.js';
418
452
  import { openStopSharingConfirmation } from './shared-conversations-settings.js';
419
453
  import { showAbout, syncAboutMenuLabel } from './about-ui.js';
420
- import { syncConversationOriginHeader } from './conversation-origin-ui.js';
421
- import { loadRelayEventSubscriptions, onRelayEventSubscriptionsChanged, syncRelayEventHeader } from './relay-event-subscriptions-ui.js';
422
- 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';
423
469
  import { leaveInboxView, startInbox, toggleInboxView } from './inbox-ui.js';
424
470
  import {
425
471
  previewAgentSessionsMaxWait,
@@ -441,6 +487,7 @@ import {
441
487
  dismissUpdateOutcome,
442
488
  } from './update-ui.js';
443
489
  import { shouldAutofocusComposerOnLoad } from './composer-keyboard.mjs';
490
+ import { planOnlyModeHint } from './plan-board-shared.mjs';
444
491
 
445
492
  const MODEL_STORAGE_KEY = 'copilot_selected_model';
446
493
  // The New Chat modal used to keep its own model key, so a selection made there
@@ -814,8 +861,9 @@ function initPushNotificationClientHooks() {
814
861
  });
815
862
  }
816
863
 
817
- // ?push_conv=<id> (a notification tap) and ?conv=<id> (another relay's
818
- // 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.
819
867
  function consumePushConversationDeepLink() {
820
868
  return consumeConversationDeepLink();
821
869
  }
@@ -929,27 +977,58 @@ function initNetworkLifecycleHandling() {
929
977
  }
930
978
 
931
979
  function syncThemeMenuLabel() {
980
+ const isLight = document.documentElement.getAttribute('data-theme') === 'light';
932
981
  const button = document.getElementById('chat-menu-theme-toggle');
933
- 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() {
934
994
  const isLight = document.documentElement.getAttribute('data-theme') === 'light';
935
- 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
+ }
936
1026
  }
937
1027
 
938
1028
  function initThemeMenuToggle() {
939
- const themeBtn = document.getElementById('chat-menu-theme-toggle');
940
- if (!themeBtn) return;
941
- themeBtn.hidden = false;
942
1029
  syncThemeMenuLabel();
943
- if (themeBtn.dataset.bound === '1') return;
944
- themeBtn.dataset.bound = '1';
945
- bindMenuAction(themeBtn, (event) => {
946
- event.preventDefault();
947
- event.stopPropagation();
948
- const isLight = document.documentElement.getAttribute('data-theme') === 'light';
949
- updateTheme(isLight ? 'dark' : 'light');
950
- syncThemeMenuLabel();
951
- closeChatActionsMenu();
952
- });
1030
+ // Day/Night is the shared view's one action too, so it is bound in both.
1031
+ bindHeaderPriorityAction('theme', () => toggleThemeFromHeader());
953
1032
  }
954
1033
 
955
1034
  function disableSharedControl(element, {
@@ -1023,18 +1102,16 @@ function applySharedReaderUi() {
1023
1102
  if (effortSelect) effortSelect.hidden = true;
1024
1103
  if (modeSelect) modeSelect.hidden = true;
1025
1104
  // One source for the menu: the resolver's shared branch (Day/Night only,
1026
- // trigger usable). The loop below stays as a guard for entries the resolver
1027
- // 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.
1028
1108
  applyChatActionsMenuState(resolveChatActionsMenuState({ sharedMode: true }));
1029
1109
  if (chatMenu && themeBtn) {
1030
1110
  for (const child of Array.from(chatMenu.children)) {
1031
1111
  if (child !== themeBtn) hideSharedMenuEntry(child);
1032
1112
  }
1033
- themeBtn.hidden = false;
1034
- themeBtn.removeAttribute('aria-hidden');
1035
- themeBtn.disabled = false;
1036
- themeBtn.tabIndex = 0;
1037
1113
  syncThemeMenuLabel();
1114
+ syncChatActionsMenuState();
1038
1115
  } else if (chatMenuBtn) {
1039
1116
  chatMenuBtn.hidden = true;
1040
1117
  }
@@ -1458,6 +1535,8 @@ function syncSessionLockNote({ pinnedModel = '' } = {}) {
1458
1535
  }
1459
1536
  // The billing toggle sits under the note and follows the same chat.
1460
1537
  renderClaudeBillingToggle();
1538
+ // The composer's label shows the lock and the model.
1539
+ scheduleComposerSettingsSync();
1461
1540
  }
1462
1541
 
1463
1542
  function applyModelMetadataHardFail(message = '') {
@@ -2068,6 +2147,21 @@ function updateModeSelectorForProvider() {
2068
2147
  select.value = modes.includes(selectedBefore)
2069
2148
  ? selectedBefore
2070
2149
  : (modes.includes(FALLBACK_MODE) ? FALLBACK_MODE : modes[0]);
2150
+ syncModeSelectPlanOnlyHint();
2151
+ }
2152
+
2153
+ // A plan-only session's mode select says so; the owner may still
2154
+ // pick any mode.
2155
+ function syncModeSelectPlanOnlyHint() {
2156
+ const select = document.getElementById('mode-select');
2157
+ if (!select) return;
2158
+ const planOnly = conversations[String(currentConvId || '').trim()]?.planOnly === true;
2159
+ const hint = planOnlyModeHint(planOnly);
2160
+ select.title = hint.title;
2161
+ if (planOnly) select.dataset.planOnly = 'true';
2162
+ else delete select.dataset.planOnly;
2163
+ const planOption = Array.from(select.options).find((option) => option.value === 'plan');
2164
+ if (planOption) planOption.textContent = planOnly ? hint.planLabel : (RELAY_MODE_LABELS.plan || hint.planLabel);
2071
2165
  }
2072
2166
 
2073
2167
  function modeOptions() {
@@ -2086,6 +2180,8 @@ async function persistCurrentConversationPreferences() {
2086
2180
  if (!modeSelect || !modelSelect) return;
2087
2181
  const mode = String(modeSelect.value || '').trim() || FALLBACK_MODE;
2088
2182
  const model = String(modelSelect.value || '').trim();
2183
+ // Steer or Queue: a mode across plan from the live turn's queues.
2184
+ syncComposerButtonState();
2089
2185
  // The raw value, not selectedReasoningEffortValue(): a model with no reasoning
2090
2186
  // options leaves the selector empty, and that helper's 'none' fallback would
2091
2187
  // record an effort the user never picked when they only changed the mode.
@@ -2203,6 +2299,9 @@ function applyConversationPreferences({
2203
2299
  } finally {
2204
2300
  suppressConversationPreferenceSync = false;
2205
2301
  }
2302
+ syncModeSelectPlanOnlyHint();
2303
+ // Values set here fire no change event: the composer's label reads them.
2304
+ scheduleComposerSettingsSync();
2206
2305
  // Nothing is written to the shared "last used" storage here. Applying a
2207
2306
  // conversation's stored preferences is not a choice: it runs on every open,
2208
2307
  // on the ~1s live poll, on a catalog refresh and on another client's edit,
@@ -2867,7 +2966,7 @@ function renderModelVariantCatalogBody() {
2867
2966
  }
2868
2967
  const visibleProviderOrder = providerOrder.filter((providerKey) => providerBelongsToTab(providerKey, modelVariantCatalogProviderTab));
2869
2968
  const providerTabButtons = `
2870
- <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;">
2871
2970
  <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'copilot' ? ' active' : ''}" data-model-provider-tab="copilot">Copilot</button>
2872
2971
  <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'openai' ? ' active' : ''}" data-model-provider-tab="openai" ${hasOpenAITab ? '' : 'disabled'}>OpenAI</button>
2873
2972
  <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'anthropic' ? ' active' : ''}" data-model-provider-tab="anthropic" ${hasAnthropicTab ? '' : 'disabled'}>Claude SDK</button>
@@ -2986,7 +3085,7 @@ function renderModelVariantCatalogBody() {
2986
3085
  `;
2987
3086
  }).join('');
2988
3087
  const bodyHtml = `
2989
- <div style="display:flex;flex-direction:column;gap:10px">
3088
+ <div style="display:flex;flex-direction:column;gap:calc(5px * var(--ui-scale))">
2990
3089
  ${providerTabButtons}
2991
3090
  <div style="font-size:0.8rem;color:var(--muted)">
2992
3091
  Saved globally for this relay. Refreshed: <strong>${escHtml(refreshedLabel)}</strong>
@@ -2998,12 +3097,12 @@ function renderModelVariantCatalogBody() {
2998
3097
  Each model shows all supported reasoning efforts above its selectable variants.
2999
3098
  </div>
3000
3099
  ${warnings.map((line) => `<div style="font-size:0.78rem;color:var(--warn,#f7c873)">${line}</div>`).join('')}
3001
- <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>
3002
3101
  </div>
3003
3102
  `;
3004
3103
  renderSummaryModalContent({
3005
3104
  title: '🤗 Select Models',
3006
- subtitle: 'Choose model variants shown in the composer selector',
3105
+ subtitle: "Choose model variants shown under Model in the composer's ⚙ settings",
3007
3106
  bodyHtml,
3008
3107
  refresh: async () => {
3009
3108
  const refreshed = await refreshModelVariantCatalog();
@@ -3257,6 +3356,7 @@ function selectUsageTab(root, provider) {
3257
3356
  for (const panel of root.querySelectorAll('[data-usage-panel]')) {
3258
3357
  panel.hidden = panel.dataset.usagePanel !== target;
3259
3358
  }
3359
+ revealSelectedTabs(root);
3260
3360
  usageTabProviderOverride = target;
3261
3361
  if (!usageCardIsFresh(target)) {
3262
3362
  refreshUsageProvider(target).catch(() => {});
@@ -3316,6 +3416,10 @@ function renderUsageReport(report, account, { updating = '' } = {}) {
3316
3416
  kind: 'usage',
3317
3417
  });
3318
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);
3319
3423
  return true;
3320
3424
  }
3321
3425
 
@@ -3536,6 +3640,12 @@ function initAgentSessionsControl() {
3536
3640
  if (!body || body.dataset.agentSessionsControlBound === 'true') return;
3537
3641
  body.dataset.agentSessionsControlBound = 'true';
3538
3642
  body.addEventListener('change', async (event) => {
3643
+ // Plan-only, for every session.
3644
+ const planOnlyBox = event.target?.closest?.('#ctx-plan-only');
3645
+ if (planOnlyBox) {
3646
+ await changePlanOnly(planOnlyBox);
3647
+ return;
3648
+ }
3539
3649
  const box = event.target?.closest?.('#ctx-may-create-sessions');
3540
3650
  if (!box) return;
3541
3651
  const convId = String(box.dataset.conversationId || '').trim();
@@ -3562,6 +3672,31 @@ function initAgentSessionsControl() {
3562
3672
  });
3563
3673
  }
3564
3674
 
3675
+ async function changePlanOnly(box) {
3676
+ const convId = String(box.dataset.conversationId || '').trim();
3677
+ if (!convId || agentSessionsControlUpdateInFlight) return;
3678
+ const wanted = box.checked === true;
3679
+ agentSessionsControlUpdateInFlight = true;
3680
+ box.disabled = true;
3681
+ try {
3682
+ const response = await updateConversationPreferences(convId, { clientId: CLIENT_ID, planOnly: wanted });
3683
+ if (!response) throw new Error('The relay refused the change');
3684
+ const stored = response.planOnly === true;
3685
+ if (conversations[convId]) conversations[convId] = { ...conversations[convId], planOnly: stored };
3686
+ box.checked = stored;
3687
+ if (convId === String(currentConvId || '').trim()) syncModeSelectPlanOnlyHint();
3688
+ showTransientRelayNotice(stored
3689
+ ? 'Plan only: agents can no longer take this session out of plan mode.'
3690
+ : 'Plan only is off: agents may switch this session to another mode.');
3691
+ } catch (error) {
3692
+ box.checked = !wanted;
3693
+ showTransientRelayNotice(`Could not change the setting: ${error?.message || error}`);
3694
+ } finally {
3695
+ box.disabled = false;
3696
+ agentSessionsControlUpdateInFlight = false;
3697
+ }
3698
+ }
3699
+
3565
3700
  function initThinkingControl() {
3566
3701
  const body = document.getElementById('summary-modal-body');
3567
3702
  if (!body || body.dataset.thinkingBound === '1') return;
@@ -3741,9 +3876,11 @@ async function loadContextSummaryAndRender(convId) {
3741
3876
  // sessions? Only meaningful there, so only shown there.
3742
3877
  const settingsConversationId = String(payload.resolvedConversationId || trimmedConvId || '').trim();
3743
3878
  const settingsConversation = settingsConversationId ? conversations[settingsConversationId] : null;
3744
- const agentSessionsHtml = settingsConversation?.origin
3879
+ const agentSessionsHtml = (settingsConversation?.origin
3745
3880
  ? renderAgentSessionsControlHtml({ conversationId: settingsConversationId, mayCreateSessions: settingsConversation.mayCreateSessions === true })
3746
- : '';
3881
+ : '')
3882
+ // Plan-only, for every session.
3883
+ + (settingsConversationId ? renderPlanOnlyControlHtml({ conversationId: settingsConversationId, planOnly: settingsConversation?.planOnly === true }) : '');
3747
3884
  const detailText = String(payload.text || '').trim();
3748
3885
  // The structured breakdown is the answer; the runtime's own text dump stays
3749
3886
  // available underneath for the details it carries that categories don't.
@@ -3767,11 +3904,15 @@ async function showUsage() {
3767
3904
  // A fresh open follows the current session's provider again; only a click
3768
3905
  // inside the modal pins a tab (for the Refresh button's sake).
3769
3906
  usageTabProviderOverride = null;
3770
- const btn = document.getElementById('chat-menu-usage') || document.getElementById('usage-btn');
3771
- if (btn) {
3772
- btn.textContent = '⏳';
3773
- btn.disabled = true;
3774
- }
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);
3775
3916
  // What was shown last time is shown again at once; only the open tab's
3776
3917
  // provider is read live, and its card is swapped in when the answer comes.
3777
3918
  const kept = lastUsageReport || readStoredUsageReport();
@@ -3785,10 +3926,7 @@ async function showUsage() {
3785
3926
  });
3786
3927
  try {
3787
3928
  if (kept && renderUsageReport(kept, null, { updating: activeProvider })) {
3788
- if (btn) {
3789
- btn.textContent = btn.id === 'chat-menu-usage' ? '📊 Check Usage' : '📊';
3790
- btn.disabled = false;
3791
- }
3929
+ setUsageBusy(false);
3792
3930
  await refreshUsageProvider(activeProvider);
3793
3931
  return;
3794
3932
  }
@@ -3803,10 +3941,7 @@ async function showUsage() {
3803
3941
  kind: 'usage',
3804
3942
  });
3805
3943
  } finally {
3806
- if (btn) {
3807
- btn.textContent = btn.id === 'chat-menu-usage' ? '📊 Check Usage' : '📊';
3808
- btn.disabled = false;
3809
- }
3944
+ setUsageBusy(false);
3810
3945
  }
3811
3946
  }
3812
3947
 
@@ -3860,7 +3995,7 @@ function renderSessionInstructionDocs(docs) {
3860
3995
  return '<div class="summary-loading">No session instruction files were found.</div>';
3861
3996
  }
3862
3997
 
3863
- 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))">${
3864
3999
  items.map((doc) => {
3865
4000
  const title = String(doc?.title || doc?.sessionId || 'Session instructions').trim();
3866
4001
  const name = String(doc?.name || '').trim();
@@ -3870,13 +4005,13 @@ function renderSessionInstructionDocs(docs) {
3870
4005
  const updatedAt = String(doc?.updatedAt || '').trim();
3871
4006
  const content = escHtml(String(doc?.content || '').trim());
3872
4007
  return `
3873
- <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))">
3874
4009
  <summary style="cursor:pointer;font-weight:600;outline:none">${escHtml(title)}</summary>
3875
- <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)">
3876
4011
  ${escHtml(summary || sessionId)}
3877
4012
  ${updatedAt ? ` · ${escHtml(updatedAt)}` : ''}
3878
4013
  </div>
3879
- <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>
3880
4015
  </details>
3881
4016
  `;
3882
4017
  }).join('')
@@ -4448,12 +4583,321 @@ function applyChatActionsMenuState(state) {
4448
4583
  }
4449
4584
  }
4450
4585
 
4451
- // 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,
4452
4882
  // decided in chat-actions-menu-state.mjs from the conversation on screen.
4453
- 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' } = {}) {
4454
4887
  const convId = String(currentConvId || '').trim();
4455
4888
  const conversation = convId ? (conversations[convId] || null) : null;
4456
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');
4457
4901
  const state = resolveChatActionsMenuState({
4458
4902
  sharedMode: isSharedReaderMode(),
4459
4903
  conversationId: convId,
@@ -4464,20 +4908,60 @@ function syncChatActionsMenuState() {
4464
4908
  editing: !!convId && chatTitleEditingConversationId === convId,
4465
4909
  suspendVisible: isSuspendHostActionVisible(),
4466
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,
4467
4916
  });
4917
+ const trigger = document.getElementById('chat-actions-menu-btn');
4918
+ const triggerWasHidden = !!trigger?.hidden;
4468
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 });
4469
4923
  return state;
4470
4924
  }
4471
4925
 
4472
4926
  // The menu follows the relay's share list (made, renewed, stopped anywhere).
4473
4927
  subscribeShares(() => syncChatActionsMenuState());
4474
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
+
4475
4959
  function toggleChatActionsMenu() {
4476
4960
  const menu = document.getElementById('chat-actions-menu');
4477
4961
  const trigger = document.getElementById('chat-actions-menu-btn');
4478
4962
  if (!menu || !trigger) return;
4479
4963
  // The entries match the moment of the tap, as Refresh history already does.
4480
- syncChatActionsMenuState();
4964
+ syncChatActionsMenuState({ fit: 'now' });
4481
4965
  if (trigger.hidden || trigger.disabled) return;
4482
4966
  syncRefreshHistoryMenuState();
4483
4967
  syncThemeMenuLabel();
@@ -4485,6 +4969,10 @@ function toggleChatActionsMenu() {
4485
4969
  const willOpen = !!menu.hidden;
4486
4970
  menu.hidden = !willOpen;
4487
4971
  trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
4972
+ if (willOpen) {
4973
+ placeChatActionsMenu();
4974
+ menu.scrollTop = 0;
4975
+ }
4488
4976
  }
4489
4977
 
4490
4978
  function isConversationLocallyProcessing(conversation) {
@@ -4591,12 +5079,14 @@ function syncChatTitleControls() {
4591
5079
  const editing = convId && chatTitleEditingConversationId === convId;
4592
5080
  document.body.classList.toggle('chat-title-editing', !!editing);
4593
5081
  if (title) title.hidden = editing;
4594
- // The ⋯ trigger and its entries: relay-wide ones stay with no conversation
4595
- // 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.
4596
5086
  syncChatActionsMenuState();
4597
5087
  const archiveBtn = document.getElementById('chat-menu-archive');
4598
5088
  if (archiveBtn) {
4599
- archiveBtn.textContent = conversation?.archived === true ? '📂 Unarchive conversation' : '🗄 Archive conversation';
5089
+ archiveBtn.textContent = conversation?.archived === true ? '📂 Unarchive conversation' : '🗄️ Archive conversation';
4600
5090
  }
4601
5091
  if (editor) {
4602
5092
  editor.hidden = !editing;
@@ -4678,6 +5168,7 @@ function applyConversationTitleUpdate(conversationId, title, updatedAt) {
4678
5168
  if (currentConvId === id) {
4679
5169
  const titleEl = document.getElementById('chat-title');
4680
5170
  if (titleEl) titleEl.textContent = nextTitle;
5171
+ syncSessionDocumentTitle();
4681
5172
  }
4682
5173
  renderConvList();
4683
5174
  }
@@ -4854,6 +5345,11 @@ async function initApp() {
4854
5345
  renderConvList();
4855
5346
  },
4856
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 });
4857
5353
  }
4858
5354
  setupViewportTracking();
4859
5355
  bindChatSelectionGuard();
@@ -4885,24 +5381,37 @@ async function initApp() {
4885
5381
  event.stopPropagation();
4886
5382
  toggleChatActionsMenu();
4887
5383
  });
5384
+ // Header buttons and their ︙ twins (Check Usage among them), and the
5385
+ // passes that decide which of them fit.
5386
+ initHeaderPriorityActions({ sharedMode });
4888
5387
  if (!sharedMode) {
4889
5388
  const chatMenuEditBtn = document.getElementById('chat-menu-edit-title');
4890
- const chatMenuUsageBtn = document.getElementById('chat-menu-usage');
4891
- bindMenuAction(chatMenuUsageBtn, (event) => {
5389
+ bindMenuAction(chatMenuEditBtn, (event) => {
4892
5390
  event.preventDefault();
4893
5391
  event.stopPropagation();
4894
5392
  lockChatActionsMenuShield(350);
4895
5393
  closeChatActionsMenu();
4896
- showUsage().catch((error) => {
4897
- alert(error?.message || 'Failed to load usage');
4898
- });
5394
+ openChatTitleEditor();
4899
5395
  });
4900
- 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) => {
4901
5400
  event.preventDefault();
4902
5401
  event.stopPropagation();
4903
5402
  lockChatActionsMenuShield(350);
4904
5403
  closeChatActionsMenu();
4905
- 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 } });
4906
5415
  });
4907
5416
  const chatMenuCompactBtn = document.getElementById('chat-menu-compact');
4908
5417
  if (chatMenuCompactBtn && !chatMenuCompactBtn.hidden && !chatMenuCompactBtn.disabled) {
@@ -5214,7 +5723,9 @@ async function initApp() {
5214
5723
  initPushNotificationClientHooks();
5215
5724
  // Browsers without Background Sync replay the outbox from the page instead.
5216
5725
  void initOutboxFallbackReplay();
5217
- await loadConversations();
5726
+ // Read before anything opens: the address then follows the open session.
5727
+ const pushConversationId = consumePushConversationDeepLink();
5728
+ await loadConversations({ openLast: !pushConversationId });
5218
5729
  // The sidebar's "watched" badges follow the subscriptions.
5219
5730
  onRelayEventSubscriptionsChanged(() => renderConvList());
5220
5731
  void loadRelayEventSubscriptions();
@@ -5223,9 +5734,8 @@ async function initApp() {
5223
5734
  void loadRelaySchedules();
5224
5735
  // The Inbox's count, and the cards of questions that don't block.
5225
5736
  startInbox();
5226
- const pushConversationId = consumePushConversationDeepLink();
5227
5737
  if (pushConversationId) {
5228
- await openConversation(pushConversationId).catch(() => {});
5738
+ await openConversation(pushConversationId, { history: 'replace' }).catch(() => {});
5229
5739
  }
5230
5740
  await loadRelayQuestions(currentConvId);
5231
5741
  await loadRelayBoards();
@@ -5280,6 +5790,31 @@ async function bootstrap() {
5280
5790
  }
5281
5791
  const urlToken = getTokenFromUrl();
5282
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
+ });
5283
5818
  // In the installed app the system back button must never close the app;
5284
5819
  // a tab keeps its own back button. After the token is gone from the
5285
5820
  // address, so the entries the guard lays down carry the clean one.
@@ -5288,7 +5823,10 @@ async function bootstrap() {
5288
5823
  enabled: isInstalledAppMode(),
5289
5824
  target: window,
5290
5825
  onFloor: () => showTransientRelayNotice('Press back again to close the app', 2500),
5826
+ entryAgain: sessionHistoryEntryAgain,
5291
5827
  });
5828
+ // Armed after the guard: on a phone the session list lies above its floor.
5829
+ armSessionHistoryOnFirstInteraction(window);
5292
5830
  const persistedToken = consumeLegacyPersistedAuthToken();
5293
5831
  const bootstrapToken = String(urlToken || persistedToken || '').trim();
5294
5832
  const existingSession = await verifyExistingSession(bootstrapToken);
@@ -5427,17 +5965,20 @@ window.deleteConv = deleteConv;
5427
5965
  window.archiveConv = archiveConv;
5428
5966
  window.openConversationGroupPicker = openConversationGroupPicker;
5429
5967
  window.toggleConversationGroupFold = toggleConversationGroupFold;
5968
+ window.toggleAllConversationGroupFolds = toggleAllConversationGroupFolds;
5430
5969
  window.unarchiveConv = unarchiveConv;
5431
5970
  window.toggleArchivedConversations = toggleArchivedConversations;
5432
5971
  window.refreshNewConversationCloudRepoList = refreshNewConversationCloudRepoList;
5433
5972
  window.openConversation = openConversation;
5434
5973
  window.openOriginConversation = openOriginConversation;
5435
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.
5436
5977
  window.toggleStatusView = () => {
5437
5978
  leaveInboxView();
5438
- return toggleStatusView();
5979
+ return toggleStatusView({ returnTo: sessionHistoryConversation() });
5439
5980
  };
5440
- window.toggleInboxView = toggleInboxView;
5981
+ window.toggleInboxView = () => toggleInboxView({ returnTo: sessionHistoryConversation() });
5441
5982
  window.refreshConversations = refreshConversations;
5442
5983
  window.renderConvList = renderConvList;
5443
5984
  window.handleAttachmentInput = handleAttachmentInput;