@oar-sh/oar 0.9.20 → 0.9.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/README.md +13 -13
  3. package/bin/oar.js +2 -2
  4. package/package.json +1 -1
  5. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
  6. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  9. package/server/claude-worker/claude-session-process.mjs +16 -4
  10. package/server/claude-worker/claude-session-worker-main.mjs +18 -2
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
  13. package/server/cursor-worker/cursor-session-worker.mjs +21 -2
  14. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/grok-background-tasks.mjs +10 -0
  17. package/server/grok-worker/grok-session-worker.mjs +22 -2
  18. package/server/grok-worker/grok-turn-runner.mjs +4 -0
  19. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  20. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  21. package/server/migrations/0038-turn-seq.mjs +92 -0
  22. package/server/public/app/about-ui.js +1 -1
  23. package/server/public/app/about-view.mjs +19 -0
  24. package/server/public/app/action-confirmations.js +1 -1
  25. package/server/public/app/activity-replay-state.mjs +160 -42
  26. package/server/public/app/api-client.js +11 -2
  27. package/server/public/app/ask-user-view.js +24 -9
  28. package/server/public/app/attachments-view.js +1 -1
  29. package/server/public/app/back-guard.mjs +31 -5
  30. package/server/public/app/bootstrap.js +618 -72
  31. package/server/public/app/chat-actions-menu-state.mjs +141 -4
  32. package/server/public/app/chat-header-meta.mjs +27 -0
  33. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  34. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  35. package/server/public/app/composer-settings-label.mjs +90 -0
  36. package/server/public/app/composer-settings-ui.js +442 -0
  37. package/server/public/app/context-menu.mjs +36 -5
  38. package/server/public/app/context-usage-view.mjs +68 -0
  39. package/server/public/app/conversation-deep-link.mjs +15 -11
  40. package/server/public/app/conversation-group-drag.mjs +12 -2
  41. package/server/public/app/conversation-group-order.mjs +9 -2
  42. package/server/public/app/conversation-group-state.mjs +47 -10
  43. package/server/public/app/conversation-origin-ui.js +17 -1
  44. package/server/public/app/conversation-view.js +309 -49
  45. package/server/public/app/cwd-menu-placement.mjs +18 -6
  46. package/server/public/app/cwd-picker.js +36 -6
  47. package/server/public/app/emoji-view.js +1 -1
  48. package/server/public/app/font-scaling.js +26 -0
  49. package/server/public/app/header-priority-actions.mjs +238 -0
  50. package/server/public/app/held-details.mjs +202 -0
  51. package/server/public/app/hidden-messages-view.mjs +4 -0
  52. package/server/public/app/inbox-ui.js +88 -81
  53. package/server/public/app/inbox-view.mjs +35 -19
  54. package/server/public/app/journal-view.js +328 -58
  55. package/server/public/app/live-activity-cap.mjs +103 -0
  56. package/server/public/app/phone-landscape-ui.js +225 -0
  57. package/server/public/app/phone-landscape.mjs +99 -0
  58. package/server/public/app/phone-typing-state.mjs +104 -0
  59. package/server/public/app/pinned-messages-view.mjs +5 -0
  60. package/server/public/app/push-settings.js +2 -2
  61. package/server/public/app/pwa-install.js +24 -3
  62. package/server/public/app/questions-panel-ui.js +634 -0
  63. package/server/public/app/questions-stub-view.mjs +40 -0
  64. package/server/public/app/questions-view.mjs +89 -0
  65. package/server/public/app/relay-board-view.js +12 -2
  66. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  67. package/server/public/app/relay-event-view.mjs +38 -0
  68. package/server/public/app/relay-restart-ui.js +2 -2
  69. package/server/public/app/relay-schedule-view.mjs +32 -6
  70. package/server/public/app/relay-schedules-ui.js +69 -20
  71. package/server/public/app/relay-thoughts.mjs +15 -1
  72. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  73. package/server/public/app/session-history-ui.js +251 -0
  74. package/server/public/app/session-history.mjs +599 -0
  75. package/server/public/app/settings-modal.js +67 -6
  76. package/server/public/app/settings-tabs.js +12 -0
  77. package/server/public/app/share-state.js +1 -1
  78. package/server/public/app/shared-conversations-settings.js +2 -2
  79. package/server/public/app/socket-handlers.js +47 -13
  80. package/server/public/app/status-view.mjs +18 -2
  81. package/server/public/app/store.js +87 -16
  82. package/server/public/app/stream-view-preference.mjs +60 -0
  83. package/server/public/app/subagent-fold.mjs +6 -3
  84. package/server/public/app/subagent-timeline-view.mjs +12 -5
  85. package/server/public/app/subagent-viewer.mjs +35 -8
  86. package/server/public/app/tab-strip-fade.mjs +101 -0
  87. package/server/public/app/tap-actions.js +66 -0
  88. package/server/public/app/thoughts-preference.mjs +79 -0
  89. package/server/public/app/turn-stream-view.mjs +124 -0
  90. package/server/public/app/usage-limit-ui.js +63 -2
  91. package/server/public/app/viewer-history.mjs +13 -2
  92. package/server/public/index.html +2603 -1767
  93. package/server/relay-tools.md +1 -0
  94. package/server/repositories/message-repository.mjs +33 -0
  95. package/server/repositories/question-repository.mjs +51 -7
  96. package/server/repositories/relay-turn-rows.mjs +56 -0
  97. package/server/repositories/session-repository.mjs +19 -0
  98. package/server/routes/ask-user-routes.mjs +34 -5
  99. package/server/routes/claude-cloud-routes.mjs +19 -0
  100. package/server/routes/messages-routes.mjs +92 -15
  101. package/server/routes/relay-ask-routes.mjs +15 -5
  102. package/server/routes/sessions-routes.mjs +117 -12
  103. package/server/server-runtime.mjs +367 -170
  104. package/server/services/about-service.mjs +22 -53
  105. package/server/services/claude-billing-swap-service.mjs +7 -20
  106. package/server/services/claude-cloud-session-service.mjs +47 -5
  107. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  108. package/server/services/idle-timers.mjs +184 -0
  109. package/server/services/queue-wake-hook.mjs +145 -0
  110. package/server/services/queue-waker.mjs +127 -0
  111. package/server/services/relay-ask-service.mjs +64 -5
  112. package/server/services/relay-event-service.mjs +4 -1
  113. package/server/services/remote-relay-dispatcher.mjs +84 -18
  114. package/server/services/session-activity-tracker.mjs +1 -1
  115. package/server/services/session-history-refresh-service.mjs +4 -0
  116. package/server/services/session-launch-mutex-service.mjs +23 -0
  117. package/server/services/session-worker-registry-service.mjs +36 -0
  118. package/server/services/session-worker-supervisor-service.mjs +36 -0
  119. package/server/services/session-worker-websocket-service.mjs +183 -31
  120. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  121. package/server/services/worker-recycle-service.mjs +772 -0
  122. package/server/services/worker-replacement.mjs +197 -0
  123. package/shared/ask-user-bridge.mjs +9 -6
  124. package/shared/build-id.mjs +128 -0
  125. package/shared/relay-asks.mjs +10 -0
  126. package/shared/relay-events.mjs +1 -0
  127. package/shared/worker-runtime/heartbeat.mjs +31 -0
  128. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  129. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  130. package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
  131. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -63,6 +63,9 @@ import {
63
63
  } from './transcript-viewport-anchor.mjs';
64
64
  import { initTranscriptJumpToBottom, onTranscriptEndLeft } from './transcript-jump-bottom-view.mjs';
65
65
  import { syncScrollOnSendToggle, updateScrollOnSendSetting } from './transcript-jump-bottom.mjs';
66
+ import { isThoughtsExpanded, syncThoughtsExpandedToggle, updateThoughtsExpandedSetting } from './thoughts-preference.mjs';
67
+ import { previewLiveActivityCap, syncLiveActivityCapSlider, updateLiveActivityCap } from './live-activity-cap.mjs';
68
+ import { STREAM_VIEW_CHANGE_EVENT, isStreamViewEnabled, syncStreamViewToggle, updateStreamViewSetting } from './stream-view-preference.mjs';
66
69
  import { previewWindowRetention, syncWindowRetentionSlider, updateWindowRetention } from './conversation-window-retention.mjs';
67
70
  import { conversationPayloadCache, mergeReloadIntoWindow } from './conversation-payload-cache.mjs';
68
71
  import {
@@ -93,7 +96,7 @@ import {
93
96
  reportSharedViewerPresence,
94
97
  setNetworkRequestsEnabled,
95
98
  } from './api-client.js';
96
- import { loadConversations, refreshConversations, openConversation, renderConvList, applyLoadedConversationState, initConversationListLazyLoading, initConversationFilter } from './journal-view.js';
99
+ import { loadConversations, refreshConversations, openConversation, renderConvList, applyLoadedConversationState, heldDetailsStore, initConversationListLazyLoading, initConversationFilter } from './journal-view.js';
97
100
  import {
98
101
  newConversation,
99
102
  confirmNewConversationModel,
@@ -107,6 +110,7 @@ import {
107
110
  openOriginConversation,
108
111
  openConversationGroupPicker,
109
112
  toggleConversationGroupFold,
113
+ toggleAllConversationGroupFolds,
110
114
  isConversationWorking,
111
115
  } from './journal-view.js';
112
116
  import {
@@ -149,12 +153,13 @@ import {
149
153
  getHeldBackLiveRowIds,
150
154
  captureConversationReadingAnchor,
151
155
  canMergeIntoTranscript,
156
+ refreshStreamViewLayout,
152
157
  } from './conversation-view.js';
153
158
  import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
154
159
  import { backgroundTasksSnapshotMark } from './background-tasks-view.mjs';
155
160
  import { newerPageReachesEnd } from './transcript-island.mjs';
156
- import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
157
- import { initHiddenMessagesView, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
161
+ import { initPinnedMessagesView, openPinnedMessagesModal, setPinsConversation } from './pinned-messages-view.mjs';
162
+ import { initHiddenMessagesView, openHiddenMessagesModal, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
158
163
  import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
159
164
  import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
160
165
  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 +242,22 @@ import {
237
242
  isInstalledAppMode,
238
243
  } from './pwa-install.js';
239
244
  import { armBackGuardOnFirstInteraction } from './back-guard.mjs';
245
+ import {
246
+ armSessionHistoryOnFirstInteraction,
247
+ initSessionHistory,
248
+ sessionHistoryConversation,
249
+ sessionHistoryEntryAgain,
250
+ syncSessionDocumentTitle,
251
+ } from './session-history-ui.js';
252
+ // What back closes, beyond the closers imported elsewhere in this file.
253
+ import { closeAnnotateEditor } from './annotate-editor.js';
254
+ import { closeEmojiPicker } from './emoji-view.js';
255
+ import { closeChangeCwdMenu } from './cwd-picker.js';
256
+ import { closeRelayWatchPanel } from './relay-event-subscriptions-ui.js';
257
+ import { closeQuestionsPanel, initQuestionsPanel, openQuestionsPanel, setQuestionsConversation } from './questions-panel-ui.js';
258
+ import { questionsTwinLabel } from './questions-view.mjs';
259
+ import { closeRelaySchedulePanel } from './relay-schedules-ui.js';
260
+ import { isInboxViewActive } from './inbox-ui.js';
240
261
  import { initScrollbarHover } from './scrollbar-hover.mjs';
241
262
  import {
242
263
  autoCompactWindowNotes,
@@ -246,6 +267,8 @@ import {
246
267
  renderAutoCompactNotesHtml,
247
268
  renderContextUsageHtml,
248
269
  renderThinkingControlHtml,
270
+ renderThoughtsPreferenceHtml,
271
+ renderStreamViewPreferenceHtml,
249
272
  thinkingEnabledFromKey,
250
273
  thinkingEnabledToKey,
251
274
  } from './context-usage-view.mjs';
@@ -255,7 +278,8 @@ import {
255
278
  formatAutoCompactWindowLabel,
256
279
  } from './auto-compact-window-options.mjs';
257
280
  import { renderPlanUsageHtml, planUsageSubtitle, normalizeUsageProvider } from './plan-usage-view.mjs';
258
- import { initFontScaling, updateFontScaleFromSelect } from './font-scaling.js';
281
+ import { revealSelectedTabs, watchTabStripFades } from './tab-strip-fade.mjs';
282
+ import { FONT_SCALE_CHANGE_EVENT, getUiScale, initFontScaling, updateFontScaleFromSelect } from './font-scaling.js';
259
283
  import { initClientDiagnostics, recordStatusEvent } from './status-store.mjs';
260
284
  import { isStatusViewActive, toggleStatusView } from './status-view.mjs';
261
285
  import { installExternalLinkPolicy, openExternalNavigation } from './external-link-policy.mjs';
@@ -384,6 +408,8 @@ import {
384
408
  import { applyPendingActionsFromStatus, initHostSuspendUi } from './host-suspend-ui.js';
385
409
  import { applyUsageLimitFromStatus, initUsageLimitUi } from './usage-limit-ui.js';
386
410
  import { initClaudeBillingToggle, renderClaudeBillingToggle } from './claude-billing-toggle-ui.js';
411
+ import { closeComposerSettings, initComposerSettings, scheduleComposerSettingsSync } from './composer-settings-ui.js';
412
+ import { initPhoneLandscape } from './phone-landscape-ui.js';
387
413
  import {
388
414
  startClaudeRelogin,
389
415
  submitClaudeLoginCodeFromInput,
@@ -411,16 +437,43 @@ import {
411
437
  import { isCurrentConversationClaudeCloud, openCurrentCloudSession, syncClaudeCloudConversationUi } from './claude-cloud-conversation-ui.js';
412
438
  import {
413
439
  CHAT_ACTIONS_MENU_ENTRY_IDS,
440
+ CHAT_ACTIONS_MENU_META_CHIP_IDS,
414
441
  CHAT_ACTIONS_MENU_META_IDS,
442
+ chatActionsMenuMetaChipLabels,
443
+ resolveChatActionsMenuPlacement,
415
444
  resolveChatActionsMenuState,
416
445
  } from './chat-actions-menu-state.mjs';
446
+ import {
447
+ HEADER_ACTIONS_CHANGE_EVENT,
448
+ HEADER_BANNER_OWN_ROW_QUERY,
449
+ HEADER_PRIORITY_ACTIONS,
450
+ HEADER_TITLE_MIN_REM,
451
+ fitHeaderPriorityActions,
452
+ headerPriorityMenuIdsFor,
453
+ headerReservedWidth,
454
+ resolveHeaderActionAvailability,
455
+ resolveQuestionBannerMode,
456
+ } from './header-priority-actions.mjs';
457
+ import { PHONE_LANDSCAPE_ATTRIBUTE, PHONE_LANDSCAPE_CHANGE_EVENT } from './phone-landscape.mjs';
417
458
  import { subscribeProviderReadiness, updateProviderReadinessInputs } from './provider-readiness.mjs';
418
459
  import { hasActiveShare, refreshShares, subscribeShares } from './share-state.js';
419
460
  import { openStopSharingConfirmation } from './shared-conversations-settings.js';
420
461
  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';
462
+ import { conversationOriginMenuEntry, syncConversationOriginHeader } from './conversation-origin-ui.js';
463
+ import {
464
+ loadRelayEventSubscriptions,
465
+ onRelayEventSubscriptionsChanged,
466
+ openRelayWatchPanel,
467
+ relayWatchingChipText,
468
+ syncRelayEventHeader,
469
+ } from './relay-event-subscriptions-ui.js';
470
+ import {
471
+ loadRelaySchedules,
472
+ onRelaySchedulesChanged,
473
+ openRelaySchedulePanel,
474
+ relayScheduleChipText,
475
+ syncRelayScheduleHeader,
476
+ } from './relay-schedules-ui.js';
424
477
  import { leaveInboxView, startInbox, toggleInboxView } from './inbox-ui.js';
425
478
  import {
426
479
  previewAgentSessionsMaxWait,
@@ -816,8 +869,9 @@ function initPushNotificationClientHooks() {
816
869
  });
817
870
  }
818
871
 
819
- // ?push_conv=<id> (a notification tap) and ?conv=<id> (another relay's
820
- // provenance badge links here) both open that conversation once.
872
+ // ?push_conv=<id> (a notification tap, consumed) and ?conv=<id> (the open
873
+ // session's address: a reload, a copied link, another relay's provenance
874
+ // badge) open that conversation at load.
821
875
  function consumePushConversationDeepLink() {
822
876
  return consumeConversationDeepLink();
823
877
  }
@@ -931,27 +985,58 @@ function initNetworkLifecycleHandling() {
931
985
  }
932
986
 
933
987
  function syncThemeMenuLabel() {
988
+ const isLight = document.documentElement.getAttribute('data-theme') === 'light';
934
989
  const button = document.getElementById('chat-menu-theme-toggle');
935
- if (!button) return;
990
+ if (button) button.textContent = isLight ? '🌙 Night mode' : '☀️ Day mode';
991
+ // The header twin: the glyph alone, the words in its tooltip.
992
+ const inline = document.getElementById('theme-toggle-btn');
993
+ if (inline) {
994
+ const label = isLight ? 'Night mode' : 'Day mode';
995
+ inline.textContent = isLight ? '🌙' : '☀️';
996
+ inline.title = label;
997
+ inline.setAttribute('aria-label', label);
998
+ }
999
+ }
1000
+
1001
+ function toggleThemeFromHeader() {
936
1002
  const isLight = document.documentElement.getAttribute('data-theme') === 'light';
937
- button.textContent = isLight ? '🌙 Night mode' : '☀️ Day mode';
1003
+ updateTheme(isLight ? 'dark' : 'light');
1004
+ syncThemeMenuLabel();
1005
+ }
1006
+
1007
+ // One priority+ action (header-priority-actions.mjs): its header button and
1008
+ // its ︙ twin run the same handler. A header button that already carries its
1009
+ // own binding (an inline onclick, a view module's listener) passes
1010
+ // `inline: false`, and only the twin is bound here.
1011
+ function bindHeaderPriorityAction(key, run, { inline = true } = {}) {
1012
+ const action = HEADER_PRIORITY_ACTIONS.find((entry) => entry.key === key);
1013
+ if (!action) return;
1014
+ const inlineBtn = inline ? document.getElementById(action.inlineId) : null;
1015
+ if (inlineBtn && inlineBtn.dataset.headerActionBound !== '1') {
1016
+ inlineBtn.dataset.headerActionBound = '1';
1017
+ inlineBtn.addEventListener('click', (event) => {
1018
+ event.preventDefault();
1019
+ event.stopPropagation();
1020
+ run(event);
1021
+ });
1022
+ }
1023
+ const twin = document.getElementById(action.menuId);
1024
+ if (twin && twin.dataset.headerActionBound !== '1') {
1025
+ twin.dataset.headerActionBound = '1';
1026
+ bindMenuAction(twin, (event) => {
1027
+ event.preventDefault();
1028
+ event.stopPropagation();
1029
+ lockChatActionsMenuShield(350);
1030
+ closeChatActionsMenu();
1031
+ run(event);
1032
+ });
1033
+ }
938
1034
  }
939
1035
 
940
1036
  function initThemeMenuToggle() {
941
- const themeBtn = document.getElementById('chat-menu-theme-toggle');
942
- if (!themeBtn) return;
943
- themeBtn.hidden = false;
944
1037
  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
- });
1038
+ // Day/Night is the shared view's one action too, so it is bound in both.
1039
+ bindHeaderPriorityAction('theme', () => toggleThemeFromHeader());
955
1040
  }
956
1041
 
957
1042
  function disableSharedControl(element, {
@@ -1025,18 +1110,16 @@ function applySharedReaderUi() {
1025
1110
  if (effortSelect) effortSelect.hidden = true;
1026
1111
  if (modeSelect) modeSelect.hidden = true;
1027
1112
  // 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.
1113
+ // in the header or in ︙, whichever fits). The loop stays as a guard for
1114
+ // entries the resolver does not know; the sync after it decides Day/Night
1115
+ // and the trigger.
1030
1116
  applyChatActionsMenuState(resolveChatActionsMenuState({ sharedMode: true }));
1031
1117
  if (chatMenu && themeBtn) {
1032
1118
  for (const child of Array.from(chatMenu.children)) {
1033
1119
  if (child !== themeBtn) hideSharedMenuEntry(child);
1034
1120
  }
1035
- themeBtn.hidden = false;
1036
- themeBtn.removeAttribute('aria-hidden');
1037
- themeBtn.disabled = false;
1038
- themeBtn.tabIndex = 0;
1039
1121
  syncThemeMenuLabel();
1122
+ syncChatActionsMenuState();
1040
1123
  } else if (chatMenuBtn) {
1041
1124
  chatMenuBtn.hidden = true;
1042
1125
  }
@@ -1460,6 +1543,8 @@ function syncSessionLockNote({ pinnedModel = '' } = {}) {
1460
1543
  }
1461
1544
  // The billing toggle sits under the note and follows the same chat.
1462
1545
  renderClaudeBillingToggle();
1546
+ // The composer's label shows the lock and the model.
1547
+ scheduleComposerSettingsSync();
1463
1548
  }
1464
1549
 
1465
1550
  function applyModelMetadataHardFail(message = '') {
@@ -2223,6 +2308,8 @@ function applyConversationPreferences({
2223
2308
  suppressConversationPreferenceSync = false;
2224
2309
  }
2225
2310
  syncModeSelectPlanOnlyHint();
2311
+ // Values set here fire no change event: the composer's label reads them.
2312
+ scheduleComposerSettingsSync();
2226
2313
  // Nothing is written to the shared "last used" storage here. Applying a
2227
2314
  // conversation's stored preferences is not a choice: it runs on every open,
2228
2315
  // on the ~1s live poll, on a catalog refresh and on another client's edit,
@@ -2887,7 +2974,7 @@ function renderModelVariantCatalogBody() {
2887
2974
  }
2888
2975
  const visibleProviderOrder = providerOrder.filter((providerKey) => providerBelongsToTab(providerKey, modelVariantCatalogProviderTab));
2889
2976
  const providerTabButtons = `
2890
- <div class="summary-tab-strip" style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
2977
+ <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
2978
  <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'copilot' ? ' active' : ''}" data-model-provider-tab="copilot">Copilot</button>
2892
2979
  <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'openai' ? ' active' : ''}" data-model-provider-tab="openai" ${hasOpenAITab ? '' : 'disabled'}>OpenAI</button>
2893
2980
  <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'anthropic' ? ' active' : ''}" data-model-provider-tab="anthropic" ${hasAnthropicTab ? '' : 'disabled'}>Claude SDK</button>
@@ -3006,7 +3093,7 @@ function renderModelVariantCatalogBody() {
3006
3093
  `;
3007
3094
  }).join('');
3008
3095
  const bodyHtml = `
3009
- <div style="display:flex;flex-direction:column;gap:10px">
3096
+ <div style="display:flex;flex-direction:column;gap:calc(5px * var(--ui-scale))">
3010
3097
  ${providerTabButtons}
3011
3098
  <div style="font-size:0.8rem;color:var(--muted)">
3012
3099
  Saved globally for this relay. Refreshed: <strong>${escHtml(refreshedLabel)}</strong>
@@ -3018,12 +3105,12 @@ function renderModelVariantCatalogBody() {
3018
3105
  Each model shows all supported reasoning efforts above its selectable variants.
3019
3106
  </div>
3020
3107
  ${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>
3108
+ <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
3109
  </div>
3023
3110
  `;
3024
3111
  renderSummaryModalContent({
3025
3112
  title: '🤗 Select Models',
3026
- subtitle: 'Choose model variants shown in the composer selector',
3113
+ subtitle: "Choose model variants shown under Model in the composer's ⚙ settings",
3027
3114
  bodyHtml,
3028
3115
  refresh: async () => {
3029
3116
  const refreshed = await refreshModelVariantCatalog();
@@ -3277,6 +3364,7 @@ function selectUsageTab(root, provider) {
3277
3364
  for (const panel of root.querySelectorAll('[data-usage-panel]')) {
3278
3365
  panel.hidden = panel.dataset.usagePanel !== target;
3279
3366
  }
3367
+ revealSelectedTabs(root);
3280
3368
  usageTabProviderOverride = target;
3281
3369
  if (!usageCardIsFresh(target)) {
3282
3370
  refreshUsageProvider(target).catch(() => {});
@@ -3336,6 +3424,10 @@ function renderUsageReport(report, account, { updating = '' } = {}) {
3336
3424
  kind: 'usage',
3337
3425
  });
3338
3426
  if (updating) markUsageCardUpdating(updating, true);
3427
+ // A new strip each render: its selected tab in view, its fade marked.
3428
+ watchTabStripFades();
3429
+ const body = document.getElementById('summary-modal-body');
3430
+ if (body) revealSelectedTabs(body);
3339
3431
  return true;
3340
3432
  }
3341
3433
 
@@ -3618,6 +3710,18 @@ function initThinkingControl() {
3618
3710
  if (!body || body.dataset.thinkingBound === '1') return;
3619
3711
  body.dataset.thinkingBound = '1';
3620
3712
  body.addEventListener('click', (event) => {
3713
+ // 🧵 Stream view and 💭 Thoughts: settings of this browser, nothing goes
3714
+ // to the relay. (The stream view's buttons share the thoughts' look.)
3715
+ const streamViewBtn = event.target?.closest?.('.ctx-stream-view-btn');
3716
+ if (streamViewBtn && body.contains(streamViewBtn)) {
3717
+ updateStreamViewSetting(streamViewBtn.dataset.streamView === 'on');
3718
+ return;
3719
+ }
3720
+ const thoughtsBtn = event.target?.closest?.('.ctx-thoughts-btn[data-thoughts-expanded]');
3721
+ if (thoughtsBtn && body.contains(thoughtsBtn)) {
3722
+ updateThoughtsExpandedSetting(thoughtsBtn.dataset.thoughtsExpanded !== 'off');
3723
+ return;
3724
+ }
3621
3725
  const btn = event.target?.closest?.('.ctx-thinking-btn');
3622
3726
  if (!btn || !body.contains(btn)) return;
3623
3727
  void applyThinkingSelection(btn.dataset.thinkingAxis, btn.dataset.thinkingValue);
@@ -3777,6 +3881,10 @@ async function loadContextSummaryAndRender(convId) {
3777
3881
  thinkingDisplay: payload.thinkingDisplay ?? null,
3778
3882
  })
3779
3883
  : '';
3884
+ // Every provider streams thoughts somewhere; the row mirrors the Settings
3885
+ // toggle and is this browser's own.
3886
+ const thoughtsHtml = renderThoughtsPreferenceHtml({ expanded: isThoughtsExpanded() })
3887
+ + renderStreamViewPreferenceHtml({ on: isStreamViewEnabled() });
3780
3888
  // What this folder's commits say, with the folder override: Claude (on
3781
3889
  // either billing), and a Claude Cloud chat that was started from a folder
3782
3890
  // (no path, no row).
@@ -3801,10 +3909,10 @@ async function loadContextSummaryAndRender(convId) {
3801
3909
  // The structured breakdown is the answer; the runtime's own text dump stays
3802
3910
  // available underneath for the details it carries that categories don't.
3803
3911
  const bodyHtml = usageHtml
3804
- ? `${usageHtml}${autoCompactHtml}${thinkingHtml}${attributionHtml}${agentSessionsHtml}${detailText
3912
+ ? `${usageHtml}${autoCompactHtml}${thinkingHtml}${thoughtsHtml}${attributionHtml}${agentSessionsHtml}${detailText
3805
3913
  ? `<details class="ctx-usage-raw"><summary>Raw details</summary><pre>${escHtml(detailText)}</pre></details>`
3806
3914
  : ''}`
3807
- : `${autoCompactHtml}${thinkingHtml}${attributionHtml}${agentSessionsHtml}<pre>${escHtml(detailText || 'No context data available.')}</pre>`;
3915
+ : `${autoCompactHtml}${thinkingHtml}${thoughtsHtml}${attributionHtml}${agentSessionsHtml}<pre>${escHtml(detailText || 'No context data available.')}</pre>`;
3808
3916
 
3809
3917
  renderSummaryModalContent({
3810
3918
  title: 'Context usage',
@@ -3820,11 +3928,15 @@ async function showUsage() {
3820
3928
  // A fresh open follows the current session's provider again; only a click
3821
3929
  // inside the modal pins a tab (for the Refresh button's sake).
3822
3930
  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
- }
3931
+ // The header button and its ︙ twin both show the wait.
3932
+ const usageButtons = ['chat-menu-usage', 'usage-btn'].map((id) => document.getElementById(id)).filter(Boolean);
3933
+ const setUsageBusy = (busy) => {
3934
+ for (const button of usageButtons) {
3935
+ button.textContent = busy ? '⏳' : (button.id === 'chat-menu-usage' ? '📊 Check Usage' : '📊');
3936
+ button.disabled = busy;
3937
+ }
3938
+ };
3939
+ setUsageBusy(true);
3828
3940
  // What was shown last time is shown again at once; only the open tab's
3829
3941
  // provider is read live, and its card is swapped in when the answer comes.
3830
3942
  const kept = lastUsageReport || readStoredUsageReport();
@@ -3838,10 +3950,7 @@ async function showUsage() {
3838
3950
  });
3839
3951
  try {
3840
3952
  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
- }
3953
+ setUsageBusy(false);
3845
3954
  await refreshUsageProvider(activeProvider);
3846
3955
  return;
3847
3956
  }
@@ -3856,10 +3965,7 @@ async function showUsage() {
3856
3965
  kind: 'usage',
3857
3966
  });
3858
3967
  } finally {
3859
- if (btn) {
3860
- btn.textContent = btn.id === 'chat-menu-usage' ? '📊 Check Usage' : '📊';
3861
- btn.disabled = false;
3862
- }
3968
+ setUsageBusy(false);
3863
3969
  }
3864
3970
  }
3865
3971
 
@@ -3913,7 +4019,7 @@ function renderSessionInstructionDocs(docs) {
3913
4019
  return '<div class="summary-loading">No session instruction files were found.</div>';
3914
4020
  }
3915
4021
 
3916
- return `<div style="display:flex;flex-direction:column;gap:12px">${
4022
+ return `<div style="display:flex;flex-direction:column;gap:calc(6px * var(--ui-scale))">${
3917
4023
  items.map((doc) => {
3918
4024
  const title = String(doc?.title || doc?.sessionId || 'Session instructions').trim();
3919
4025
  const name = String(doc?.name || '').trim();
@@ -3923,13 +4029,13 @@ function renderSessionInstructionDocs(docs) {
3923
4029
  const updatedAt = String(doc?.updatedAt || '').trim();
3924
4030
  const content = escHtml(String(doc?.content || '').trim());
3925
4031
  return `
3926
- <details open style="border:1px solid var(--border);border-radius:10px;background:var(--bg3);padding:10px 12px">
4032
+ <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
4033
  <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)">
4034
+ <div style="margin-top:calc(3px * var(--ui-scale));font-size:0.78rem;color:var(--muted)">
3929
4035
  ${escHtml(summary || sessionId)}
3930
4036
  ${updatedAt ? ` · ${escHtml(updatedAt)}` : ''}
3931
4037
  </div>
3932
- <pre style="margin-top:10px;white-space:pre-wrap;word-break:break-word">${content}</pre>
4038
+ <pre style="margin-top:calc(5px * var(--ui-scale));white-space:pre-wrap;word-break:break-word">${content}</pre>
3933
4039
  </details>
3934
4040
  `;
3935
4041
  }).join('')
@@ -4048,12 +4154,17 @@ async function pollAuthenticatedCurrentConversationLive() {
4048
4154
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
4049
4155
  const tasksMark = backgroundTasksSnapshotMark(currentId);
4050
4156
  const jumpEpoch = getTranscriptJumpEpoch();
4157
+ // The finished replies this page already holds go out without their
4158
+ // tool lines and thoughts (held-details.mjs); the answer gets them back
4159
+ // from the store, or is dropped if it names one the store lacks.
4160
+ const heldDetails = heldDetailsStore.param(currentId);
4051
4161
  const { response, stale } = await loadKeepingLiveRows(
4052
- () => loadConversation(currentId, { limit: requestLimit }),
4162
+ () => loadConversation(currentId, { limit: requestLimit, heldDetails }),
4053
4163
  getTranscriptLiveRowEpoch,
4054
4164
  );
4055
4165
  if (!response) return;
4056
4166
  if (String(currentConvId || '').trim() !== currentId) return;
4167
+ if (!heldDetailsStore.restore(currentId, response)) return;
4057
4168
  // The user jumped into the history while this request was on its way: the
4058
4169
  // answer is the end of the conversation as it was asked for before that,
4059
4170
  // and would overwrite the window they jumped to. The next round asks again.
@@ -4073,6 +4184,7 @@ async function pollAuthenticatedCurrentConversationLive() {
4073
4184
  followLiveUpdates: preserveBottom,
4074
4185
  mergeFrom: merge?.mergeFrom || null,
4075
4186
  backgroundTasksAsOf: tasksMark,
4187
+ fromPoll: true,
4076
4188
  });
4077
4189
  // A merge does not scroll as the full render does: a reader at the end
4078
4190
  // is taken to the new end here.
@@ -4501,12 +4613,337 @@ function applyChatActionsMenuState(state) {
4501
4613
  }
4502
4614
  }
4503
4615
 
4504
- // The ⋯ menu as it should look right now: the trigger and every entry,
4616
+ // ── Priority+ header actions ──
4617
+ // Pins, hidden, search, context, Check Usage, Day/Night and fullscreen sit in
4618
+ // the header while it has room and fall back into ︙ in reverse priority
4619
+ // order (header-priority-actions.mjs decides; this measures and applies).
4620
+ // The twin ids the last fit put into ︙; null before the header was measured,
4621
+ // which keeps the menu's old reach (Day/Night, Check Usage).
4622
+ let headerPriorityMenuIds = null;
4623
+ // The inputs the last full fit measured with (headerFitSignature); a pass
4624
+ // whose inputs are the same keeps the placement and measures nothing.
4625
+ let headerPriorityFitSignature = '';
4626
+
4627
+ function headerPriorityActionAvailability() {
4628
+ const pins = document.getElementById('pinned-messages-btn');
4629
+ const hiddenBtn = document.getElementById('hidden-messages-btn');
4630
+ const fullscreen = document.getElementById('fullscreen-btn');
4631
+ return resolveHeaderActionAvailability({
4632
+ sharedMode: isSharedReaderMode(),
4633
+ questionCount: Number(document.getElementById('questions-btn')?.dataset.count || 0),
4634
+ pinCount: Number(pins?.dataset.count || 0),
4635
+ hiddenCount: Number(hiddenBtn?.dataset.count || 0),
4636
+ fullscreenAvailable: fullscreen?.dataset.headerAvailable === '1',
4637
+ });
4638
+ }
4639
+
4640
+ function cssPixels(value) {
4641
+ const number = parseFloat(value);
4642
+ return Number.isFinite(number) ? number : 0;
4643
+ }
4644
+
4645
+ function isHeaderBannerOnOwnRow() {
4646
+ return typeof window.matchMedia === 'function' && window.matchMedia(HEADER_BANNER_OWN_ROW_QUERY).matches;
4647
+ }
4648
+
4649
+ // The question banner's two widths, measured with its compact chip shown
4650
+ // and then its full text: the chip ("❓ N", what the fit reserves) and the
4651
+ // least the full text needs ("❓ N open questions" plus padding and border;
4652
+ // the "latest in …" tail may end in an ellipsis). Zero when it does not show.
4653
+ // Leaves the banner in its full form; the caller sets the mode.
4654
+ function measureQuestionBanner(banner) {
4655
+ if (!banner) return { visible: false, compact: 0, fullMin: 0 };
4656
+ banner.removeAttribute('data-compact');
4657
+ if (!banner.offsetWidth) return { visible: false, compact: 0, fullMin: 0 };
4658
+ const count = banner.querySelector('.pending-question-count');
4659
+ const style = getComputedStyle(banner);
4660
+ const frame = cssPixels(style.paddingLeft) + cssPixels(style.paddingRight)
4661
+ + cssPixels(style.borderLeftWidth) + cssPixels(style.borderRightWidth);
4662
+ // Rounded up: a fraction short would cut the last letter.
4663
+ const fullMin = Math.ceil((count ? count.getBoundingClientRect().width : banner.scrollWidth) + frame);
4664
+ banner.setAttribute('data-compact', '');
4665
+ const compact = Math.ceil(banner.getBoundingClientRect().width);
4666
+ banner.removeAttribute('data-compact');
4667
+ return { visible: true, compact, fullMin };
4668
+ }
4669
+
4670
+ // The width of everything on the header's first row that never overflows
4671
+ // (headerReservedWidth): its padding, ☰, the title's minimum, ︙, the
4672
+ // question banner's compact chip while it shares that row, and the flex gaps
4673
+ // between them. Read on every full pass: all of it moves with --font-scale.
4674
+ function headerFixedWidth(header, bannerCompactWidth) {
4675
+ const style = getComputedStyle(header);
4676
+ const rootFont = cssPixels(getComputedStyle(document.documentElement).fontSize) || 16;
4677
+ return headerReservedWidth({
4678
+ padding: cssPixels(style.paddingLeft) + cssPixels(style.paddingRight),
4679
+ titleMin: HEADER_TITLE_MIN_REM * rootFont,
4680
+ toggle: document.getElementById('sidebar-toggle')?.offsetWidth || 0,
4681
+ banner: bannerCompactWidth,
4682
+ menu: document.getElementById('chat-actions-menu-wrap')?.offsetWidth || 0,
4683
+ gap: cssPixels(style.columnGap || style.gap),
4684
+ // On a phone the banner has a row of its own (the ≤680px header CSS).
4685
+ bannerOwnRow: isHeaderBannerOnOwnRow(),
4686
+ });
4687
+ }
4688
+
4689
+ // Everything the fit depends on, in one string: the header's width, the
4690
+ // title, the banner, the counts and availability, the theme (the glyphs'
4691
+ // widths), the scale and the layout. Cheap to read: no element is shown or
4692
+ // hidden for it.
4693
+ function headerFitSignature(header, availability) {
4694
+ const root = document.documentElement;
4695
+ const rootStyle = getComputedStyle(root);
4696
+ const title = document.getElementById('chat-title');
4697
+ const banner = document.getElementById('pending-question-banner');
4698
+ return JSON.stringify([
4699
+ header.clientWidth,
4700
+ rootStyle.fontSize,
4701
+ rootStyle.getPropertyValue('--ui-scale'),
4702
+ root.getAttribute('data-theme'),
4703
+ root.hasAttribute(PHONE_LANDSCAPE_ATTRIBUTE),
4704
+ isHeaderBannerOnOwnRow(),
4705
+ document.body.classList.contains('chat-title-editing'),
4706
+ title?.hidden, title?.textContent,
4707
+ banner?.className, banner?.hidden, banner?.textContent,
4708
+ document.getElementById('sidebar-toggle')?.hidden,
4709
+ document.getElementById('chat-actions-menu-btn')?.hidden,
4710
+ document.getElementById('questions-btn')?.dataset.count,
4711
+ document.getElementById('questions-btn')?.dataset.urgent,
4712
+ document.getElementById('pinned-messages-btn')?.dataset.count,
4713
+ document.getElementById('hidden-messages-btn')?.dataset.count,
4714
+ document.getElementById('fullscreen-btn')?.textContent,
4715
+ document.getElementById('theme-toggle-btn')?.textContent,
4716
+ availability,
4717
+ ]);
4718
+ }
4719
+
4720
+ // The last placement, minus whatever is no longer available. Measures nothing.
4721
+ function keepHeaderPriorityPlacement(availability) {
4722
+ for (const action of HEADER_PRIORITY_ACTIONS) {
4723
+ const inline = document.getElementById(action.inlineId);
4724
+ if (inline && !availability[action.key]) inline.hidden = true;
4725
+ }
4726
+ if (Array.isArray(headerPriorityMenuIds)) {
4727
+ const availableMenuIds = new Set(headerPriorityMenuIdsFor(Object.keys(availability).filter((key) => availability[key])));
4728
+ headerPriorityMenuIds = headerPriorityMenuIds.filter((id) => availableMenuIds.has(id));
4729
+ }
4730
+ return headerPriorityMenuIds;
4731
+ }
4732
+
4733
+ // Shows inline what fits and hides the rest; returns the twin ids for ︙.
4734
+ // A header that cannot be measured (hidden, not laid out) keeps the last
4735
+ // placement, minus whatever is no longer available. Unless `force`, a pass
4736
+ // whose inputs are those of the last one keeps its placement too: the hide
4737
+ // and measure below runs only when something it reads has changed.
4738
+ function syncHeaderPriorityFit({ force = false } = {}) {
4739
+ const availability = headerPriorityActionAvailability();
4740
+ const header = document.getElementById('chat-header');
4741
+ const actions = document.getElementById('chat-header-actions');
4742
+ const measurable = !!(header && actions && header.clientWidth && typeof getComputedStyle === 'function');
4743
+ if (!measurable) return keepHeaderPriorityPlacement(availability);
4744
+ const signature = headerFitSignature(header, availability);
4745
+ if (!force && signature === headerPriorityFitSignature && Array.isArray(headerPriorityMenuIds)) {
4746
+ return keepHeaderPriorityPlacement(availability);
4747
+ }
4748
+ headerPriorityFitSignature = signature;
4749
+ // Un-hide every available button before measuring: a hidden one is 0 wide.
4750
+ // All of this runs in one task, so nothing is painted in between.
4751
+ const widths = {};
4752
+ for (const action of HEADER_PRIORITY_ACTIONS) {
4753
+ const inline = document.getElementById(action.inlineId);
4754
+ if (!inline) continue;
4755
+ inline.hidden = !availability[action.key];
4756
+ if (availability[action.key]) widths[action.key] = inline.offsetWidth;
4757
+ }
4758
+ const gap = cssPixels(getComputedStyle(actions).columnGap || getComputedStyle(actions).gap);
4759
+ // The question banner sits between the title and the actions. The fit
4760
+ // reserves its compact chip only, so an action goes into ︙ before the
4761
+ // banner could push it; then the banner takes what the row has left.
4762
+ const banner = document.getElementById('pending-question-banner');
4763
+ const phoneOwnRow = isHeaderBannerOnOwnRow();
4764
+ const phoneLandscape = document.documentElement.hasAttribute(PHONE_LANDSCAPE_ATTRIBUTE);
4765
+ const bannerSize = measureQuestionBanner(banner);
4766
+ const reserved = headerFixedWidth(header, phoneOwnRow ? 0 : bannerSize.compact);
4767
+ // Where ☰, the title's minimum, the chip and ︙ alone overflow the first
4768
+ // row (a narrow #main at a large zoom), the banner takes a row of its own,
4769
+ // as on a phone, and the fit no longer reserves it.
4770
+ const ownRow = phoneOwnRow || resolveQuestionBannerMode({
4771
+ visible: bannerSize.visible,
4772
+ phoneLandscape,
4773
+ room: header.clientWidth - reserved + bannerSize.compact,
4774
+ compact: bannerSize.compact,
4775
+ }) === 'row';
4776
+ const available = header.clientWidth - (ownRow ? headerFixedWidth(header, 0) : reserved);
4777
+ const fit = fitHeaderPriorityActions({
4778
+ widths,
4779
+ gap,
4780
+ available,
4781
+ availability,
4782
+ });
4783
+ const inlineKeys = new Set(fit.inline);
4784
+ for (const action of HEADER_PRIORITY_ACTIONS) {
4785
+ const inline = document.getElementById(action.inlineId);
4786
+ if (inline) inline.hidden = !inlineKeys.has(action.key);
4787
+ }
4788
+ if (banner) {
4789
+ const headerStyle = getComputedStyle(header);
4790
+ const mode = resolveQuestionBannerMode({
4791
+ visible: bannerSize.visible,
4792
+ ownRow,
4793
+ phoneLandscape,
4794
+ room: available - fit.used + bannerSize.compact,
4795
+ compact: bannerSize.compact,
4796
+ contentWidth: header.clientWidth - cssPixels(headerStyle.paddingLeft) - cssPixels(headerStyle.paddingRight),
4797
+ fullMin: bannerSize.fullMin,
4798
+ });
4799
+ banner.toggleAttribute('data-compact', mode === 'compact');
4800
+ // The phone's header CSS places the banner on its own row by itself; a
4801
+ // wider header needs this attribute for it.
4802
+ banner.toggleAttribute('data-own-row', mode === 'row' && !phoneOwnRow);
4803
+ }
4804
+ headerPriorityMenuIds = headerPriorityMenuIdsFor(fit.menu);
4805
+ // Measured at least once (the e2e helpers wait for it).
4806
+ actions.dataset.headerFit = '1';
4807
+ return headerPriorityMenuIds;
4808
+ }
4809
+
4810
+ // The pins and hidden twins carry the count their header button shows.
4811
+ function syncHeaderPriorityTwinLabels() {
4812
+ const label = (buttonId, twinId, text, icon) => {
4813
+ const twin = document.getElementById(twinId);
4814
+ if (!twin) return;
4815
+ const count = Number(document.getElementById(buttonId)?.dataset.count || 0);
4816
+ twin.textContent = count ? `${icon} ${text} (${count})` : `${icon} ${text}`;
4817
+ };
4818
+ const questions = document.getElementById('questions-btn');
4819
+ const questionsTwin = document.getElementById('chat-menu-questions');
4820
+ if (questionsTwin) {
4821
+ questionsTwin.textContent = questionsTwinLabel({
4822
+ pending: Number(questions?.dataset.count || 0),
4823
+ urgent: Number(questions?.dataset.urgent || 0),
4824
+ });
4825
+ }
4826
+ label('pinned-messages-btn', 'chat-menu-pins', 'Pinned messages', '📍');
4827
+ label('hidden-messages-btn', 'chat-menu-hidden', 'Hidden from shared viewers', '🙈');
4828
+ }
4829
+
4830
+ // At most one fit per frame. A pass asked for by an observer of one of the
4831
+ // fit's inputs (`force`) measures; one asked for by a header sync (every list
4832
+ // paint ends in one) measures only if headerFitSignature says something
4833
+ // changed. Either way it runs before the next paint.
4834
+ let headerPriorityFrame = 0;
4835
+ let headerPriorityForce = false;
4836
+ function scheduleHeaderPrioritySync({ force = false } = {}) {
4837
+ if (force) headerPriorityForce = true;
4838
+ if (headerPriorityFrame) return;
4839
+ headerPriorityFrame = requestAnimationFrame(() => {
4840
+ headerPriorityFrame = 0;
4841
+ const forced = headerPriorityForce;
4842
+ headerPriorityForce = false;
4843
+ syncChatActionsMenuState({ fit: forced ? 'force' : 'now' });
4844
+ // The header moved or changed size: the open menu follows it.
4845
+ if (forced) placeChatActionsMenu();
4846
+ });
4847
+ }
4848
+
4849
+ // Every reason the fit can change: the header's own box (window, drawer,
4850
+ // font scale), the title, the banner, the theme (the glyph's width), a view's
4851
+ // counts or fullscreen verdict (HEADER_ACTIONS_CHANGE_EVENT), the font scale
4852
+ // and the phone turning. Conversation switches reach it through
4853
+ // syncChatTitleControls.
4854
+ function initHeaderPriorityActions({ sharedMode = false } = {}) {
4855
+ if (!sharedMode) {
4856
+ // 📓's own button toggles the list under it (questions-panel-ui.js); the
4857
+ // twin opens it under ︙.
4858
+ bindHeaderPriorityAction('questions', () => {
4859
+ openQuestionsPanel({ anchor: document.getElementById('chat-actions-menu-btn') });
4860
+ }, { inline: false });
4861
+ bindHeaderPriorityAction('pins', () => openPinnedMessagesModal(), { inline: false });
4862
+ bindHeaderPriorityAction('hidden', () => openHiddenMessagesModal(), { inline: false });
4863
+ bindHeaderPriorityAction('search', () => openMessageSearchModal(), { inline: false });
4864
+ bindHeaderPriorityAction('context', () => { void showContext(); }, { inline: false });
4865
+ bindHeaderPriorityAction('usage', () => {
4866
+ showUsage().catch((error) => {
4867
+ alert(error?.message || 'Failed to load usage');
4868
+ });
4869
+ });
4870
+ bindHeaderPriorityAction('fullscreen', () => { void toggleFullscreen(); }, { inline: false });
4871
+ }
4872
+ if (window.__headerPriorityObserversBound) return;
4873
+ window.__headerPriorityObserversBound = true;
4874
+ const forced = () => scheduleHeaderPrioritySync({ force: true });
4875
+ const header = document.getElementById('chat-header');
4876
+ if (header && typeof ResizeObserver === 'function') {
4877
+ new ResizeObserver(forced).observe(header);
4878
+ } else {
4879
+ window.addEventListener('resize', forced);
4880
+ }
4881
+ // The open ︙ menu stays inside the window (a turn, the keyboard).
4882
+ window.addEventListener('resize', () => placeChatActionsMenu());
4883
+ if (typeof MutationObserver === 'function') {
4884
+ new MutationObserver(forced)
4885
+ .observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
4886
+ const title = document.getElementById('chat-title');
4887
+ if (title) {
4888
+ new MutationObserver(forced)
4889
+ .observe(title, { childList: true, characterData: true, subtree: true });
4890
+ }
4891
+ // The pending-question banner shares the first row on a wide screen: it
4892
+ // takes room when it shows (on a phone it has a row of its own).
4893
+ const banner = document.getElementById('pending-question-banner');
4894
+ if (banner) {
4895
+ new MutationObserver(forced)
4896
+ .observe(banner, { attributes: true, attributeFilter: ['class', 'hidden'], childList: true, characterData: true, subtree: true });
4897
+ }
4898
+ }
4899
+ window.addEventListener(HEADER_ACTIONS_CHANGE_EVENT, forced);
4900
+ window.addEventListener(FONT_SCALE_CHANGE_EVENT, forced);
4901
+ window.addEventListener(PHONE_LANDSCAPE_CHANGE_EVENT, forced);
4902
+ document.addEventListener('fullscreenchange', forced);
4903
+ syncChatActionsMenuState({ fit: 'force' });
4904
+ }
4905
+
4906
+ // The meta line's chips the conversation on screen has, for their ︙
4907
+ // stand-ins in phone landscape (where that line is hidden), with the labels.
4908
+ function chatActionsMenuMetaChips(convId) {
4909
+ if (!convId || isSharedReaderMode()) return { chips: {}, labels: null };
4910
+ const watching = relayWatchingChipText(convId);
4911
+ const schedules = relayScheduleChipText(convId);
4912
+ const origin = conversationOriginMenuEntry();
4913
+ return {
4914
+ chips: { watching: !!watching, schedules: !!schedules, origin: !!origin },
4915
+ labels: chatActionsMenuMetaChipLabels({ watching, origin: origin?.label || '' }),
4916
+ };
4917
+ }
4918
+
4919
+ function syncChatActionsMenuMetaChipLabels(labels) {
4920
+ if (!labels) return;
4921
+ for (const [chip, id] of Object.entries(CHAT_ACTIONS_MENU_META_CHIP_IDS)) {
4922
+ const entry = document.getElementById(id);
4923
+ if (entry && labels[chip] && entry.textContent !== labels[chip]) entry.textContent = labels[chip];
4924
+ }
4925
+ }
4926
+
4927
+ // The ︙ menu as it should look right now: the trigger and every entry,
4505
4928
  // decided in chat-actions-menu-state.mjs from the conversation on screen.
4506
- function syncChatActionsMenuState() {
4929
+ // `fit`: 'schedule' (the default) keeps the twins the last fit chose and asks
4930
+ // for a fit before the next paint; 'now' fits first if any of its inputs
4931
+ // changed; 'force' fits first whatever.
4932
+ function syncChatActionsMenuState({ fit = 'schedule' } = {}) {
4507
4933
  const convId = String(currentConvId || '').trim();
4508
4934
  const conversation = convId ? (conversations[convId] || null) : null;
4509
4935
  const isCloud = !!convId && isCurrentConversationClaudeCloud();
4936
+ let headerMenuIds;
4937
+ if (fit === 'now' || fit === 'force') {
4938
+ headerMenuIds = syncHeaderPriorityFit({ force: fit === 'force' });
4939
+ } else {
4940
+ headerMenuIds = keepHeaderPriorityPlacement(headerPriorityActionAvailability());
4941
+ scheduleHeaderPrioritySync();
4942
+ }
4943
+ syncHeaderPriorityTwinLabels();
4944
+ const metaChips = chatActionsMenuMetaChips(convId);
4945
+ syncChatActionsMenuMetaChipLabels(metaChips.labels);
4946
+ const fullscreenBtn = document.getElementById('fullscreen-btn');
4510
4947
  const state = resolveChatActionsMenuState({
4511
4948
  sharedMode: isSharedReaderMode(),
4512
4949
  conversationId: convId,
@@ -4517,20 +4954,60 @@ function syncChatActionsMenuState() {
4517
4954
  editing: !!convId && chatTitleEditingConversationId === convId,
4518
4955
  suspendVisible: isSuspendHostActionVisible(),
4519
4956
  hasActiveShare: hasActiveShare(convId),
4957
+ headerMenuIds,
4958
+ // As the header button: disabled where the browser cannot go fullscreen.
4959
+ fullscreenSupported: !fullscreenBtn?.disabled,
4960
+ phoneLandscape: document.documentElement.hasAttribute(PHONE_LANDSCAPE_ATTRIBUTE),
4961
+ metaChips: metaChips.chips,
4520
4962
  });
4963
+ const trigger = document.getElementById('chat-actions-menu-btn');
4964
+ const triggerWasHidden = !!trigger?.hidden;
4521
4965
  applyChatActionsMenuState(state);
4966
+ // ︙ coming or going changes the room the fit has (the shared view's menu
4967
+ // empties once Day/Night fits): one more pass settles it.
4968
+ if (trigger && !!trigger.hidden !== triggerWasHidden) scheduleHeaderPrioritySync({ force: true });
4522
4969
  return state;
4523
4970
  }
4524
4971
 
4525
4972
  // The menu follows the relay's share list (made, renewed, stopped anywhere).
4526
4973
  subscribeShares(() => syncChatActionsMenuState());
4527
4974
 
4975
+ // Keeps the open ︙ menu inside the viewport and #main (never off-screen,
4976
+ // never under the sidebar) and caps its height at what is left below it,
4977
+ // where it scrolls (resolveChatActionsMenuPlacement decides).
4978
+ function placeChatActionsMenu() {
4979
+ const menu = document.getElementById('chat-actions-menu');
4980
+ if (!menu || menu.hidden) return;
4981
+ // Measured as CSS places it; where the reader scrolled to stays.
4982
+ const scrollTop = menu.scrollTop;
4983
+ menu.style.right = '';
4984
+ menu.style.maxWidth = '';
4985
+ menu.style.maxHeight = '';
4986
+ const rect = menu.getBoundingClientRect();
4987
+ if (!rect.width && !rect.height) return;
4988
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
4989
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
4990
+ const main = document.getElementById('main')?.getBoundingClientRect?.();
4991
+ const bounds = {
4992
+ left: Math.max(0, main && main.width ? main.left : 0),
4993
+ right: Math.min(viewportWidth, main && main.width ? main.right : viewportWidth),
4994
+ bottom: Math.min(viewportHeight, main && main.height ? main.bottom : viewportHeight),
4995
+ };
4996
+ // 8px from the edges and a 120px floor at 100 %, scaled with the zoom.
4997
+ const scale = getUiScale();
4998
+ const placement = resolveChatActionsMenuPlacement({ rect, bounds, margin: 8 * scale, minHeight: 120 * scale });
4999
+ if (placement.right) menu.style.right = `${placement.right}px`;
5000
+ if (placement.maxWidth !== null) menu.style.maxWidth = `${placement.maxWidth}px`;
5001
+ menu.style.maxHeight = `${placement.maxHeight}px`;
5002
+ if (scrollTop) menu.scrollTop = scrollTop;
5003
+ }
5004
+
4528
5005
  function toggleChatActionsMenu() {
4529
5006
  const menu = document.getElementById('chat-actions-menu');
4530
5007
  const trigger = document.getElementById('chat-actions-menu-btn');
4531
5008
  if (!menu || !trigger) return;
4532
5009
  // The entries match the moment of the tap, as Refresh history already does.
4533
- syncChatActionsMenuState();
5010
+ syncChatActionsMenuState({ fit: 'now' });
4534
5011
  if (trigger.hidden || trigger.disabled) return;
4535
5012
  syncRefreshHistoryMenuState();
4536
5013
  syncThemeMenuLabel();
@@ -4538,6 +5015,10 @@ function toggleChatActionsMenu() {
4538
5015
  const willOpen = !!menu.hidden;
4539
5016
  menu.hidden = !willOpen;
4540
5017
  trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
5018
+ if (willOpen) {
5019
+ placeChatActionsMenu();
5020
+ menu.scrollTop = 0;
5021
+ }
4541
5022
  }
4542
5023
 
4543
5024
  function isConversationLocallyProcessing(conversation) {
@@ -4617,6 +5098,7 @@ function syncChatTitleControls() {
4617
5098
  if (!convId) {
4618
5099
  setPinsConversation(null);
4619
5100
  setHiddenMessagesConversation(null);
5101
+ setQuestionsConversation(null);
4620
5102
  }
4621
5103
  const conversation = convId ? (conversations[convId] || null) : null;
4622
5104
  const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
@@ -4644,12 +5126,14 @@ function syncChatTitleControls() {
4644
5126
  const editing = convId && chatTitleEditingConversationId === convId;
4645
5127
  document.body.classList.toggle('chat-title-editing', !!editing);
4646
5128
  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.
5129
+ // The ︙ trigger and its entries: relay-wide ones stay with no conversation
5130
+ // open, so Settings is always one tap away. The header's fit is only asked
5131
+ // for here (once per frame at most, and it measures only when its inputs
5132
+ // changed): every list paint ends in this call.
4649
5133
  syncChatActionsMenuState();
4650
5134
  const archiveBtn = document.getElementById('chat-menu-archive');
4651
5135
  if (archiveBtn) {
4652
- archiveBtn.textContent = conversation?.archived === true ? '📂 Unarchive conversation' : '🗄 Archive conversation';
5136
+ archiveBtn.textContent = conversation?.archived === true ? '📂 Unarchive conversation' : '🗄️ Archive conversation';
4653
5137
  }
4654
5138
  if (editor) {
4655
5139
  editor.hidden = !editing;
@@ -4731,6 +5215,7 @@ function applyConversationTitleUpdate(conversationId, title, updatedAt) {
4731
5215
  if (currentConvId === id) {
4732
5216
  const titleEl = document.getElementById('chat-title');
4733
5217
  if (titleEl) titleEl.textContent = nextTitle;
5218
+ syncSessionDocumentTitle();
4734
5219
  }
4735
5220
  renderConvList();
4736
5221
  }
@@ -4895,6 +5380,9 @@ async function initApp() {
4895
5380
  if (!sharedMode) {
4896
5381
  syncSuspendHostVisibility();
4897
5382
  syncScrollOnSendToggle();
5383
+ syncThoughtsExpandedToggle();
5384
+ syncLiveActivityCapSlider();
5385
+ syncStreamViewToggle();
4898
5386
  syncWindowRetentionSlider();
4899
5387
  initHostSuspendUi();
4900
5388
  initUsageLimitUi();
@@ -4907,6 +5395,11 @@ async function initApp() {
4907
5395
  renderConvList();
4908
5396
  },
4909
5397
  });
5398
+ // The ⚙ panel and the label row above the message box.
5399
+ initComposerSettings({ isSharedReaderMode, showContext: () => showContext() });
5400
+ // A phone turned sideways: its typing mode folds the label row and the
5401
+ // task line into floating buttons while the keyboard is up.
5402
+ initPhoneLandscape({ isSharedReaderMode });
4910
5403
  }
4911
5404
  setupViewportTracking();
4912
5405
  bindChatSelectionGuard();
@@ -4938,24 +5431,37 @@ async function initApp() {
4938
5431
  event.stopPropagation();
4939
5432
  toggleChatActionsMenu();
4940
5433
  });
5434
+ // Header buttons and their ︙ twins (Check Usage among them), and the
5435
+ // passes that decide which of them fit.
5436
+ initHeaderPriorityActions({ sharedMode });
4941
5437
  if (!sharedMode) {
4942
5438
  const chatMenuEditBtn = document.getElementById('chat-menu-edit-title');
4943
- const chatMenuUsageBtn = document.getElementById('chat-menu-usage');
4944
- bindMenuAction(chatMenuUsageBtn, (event) => {
5439
+ bindMenuAction(chatMenuEditBtn, (event) => {
4945
5440
  event.preventDefault();
4946
5441
  event.stopPropagation();
4947
5442
  lockChatActionsMenuShield(350);
4948
5443
  closeChatActionsMenu();
4949
- showUsage().catch((error) => {
4950
- alert(error?.message || 'Failed to load usage');
4951
- });
5444
+ openChatTitleEditor();
4952
5445
  });
4953
- bindMenuAction(chatMenuEditBtn, (event) => {
5446
+ // Phone landscape hides the meta line: these stand in for its chips and
5447
+ // do what a tap on the chip does. The lists open under ︙, the chip
5448
+ // having no box to hang from.
5449
+ const metaChipAction = (id, run) => bindMenuAction(document.getElementById(id), (event) => {
4954
5450
  event.preventDefault();
4955
5451
  event.stopPropagation();
4956
5452
  lockChatActionsMenuShield(350);
4957
5453
  closeChatActionsMenu();
4958
- openChatTitleEditor();
5454
+ run();
5455
+ });
5456
+ metaChipAction(CHAT_ACTIONS_MENU_META_CHIP_IDS.watching, () => {
5457
+ openRelayWatchPanel({ anchor: document.getElementById('chat-actions-menu-btn') });
5458
+ });
5459
+ metaChipAction(CHAT_ACTIONS_MENU_META_CHIP_IDS.schedules, () => {
5460
+ openRelaySchedulePanel({ anchor: document.getElementById('chat-actions-menu-btn') });
5461
+ });
5462
+ metaChipAction(CHAT_ACTIONS_MENU_META_CHIP_IDS.origin, () => {
5463
+ const origin = conversationOriginMenuEntry();
5464
+ if (origin) void openOriginConversation(null, { dataset: { originConversationId: origin.conversationId } });
4959
5465
  });
4960
5466
  const chatMenuCompactBtn = document.getElementById('chat-menu-compact');
4961
5467
  if (chatMenuCompactBtn && !chatMenuCompactBtn.hidden && !chatMenuCompactBtn.disabled) {
@@ -5267,7 +5773,9 @@ async function initApp() {
5267
5773
  initPushNotificationClientHooks();
5268
5774
  // Browsers without Background Sync replay the outbox from the page instead.
5269
5775
  void initOutboxFallbackReplay();
5270
- await loadConversations();
5776
+ // Read before anything opens: the address then follows the open session.
5777
+ const pushConversationId = consumePushConversationDeepLink();
5778
+ await loadConversations({ openLast: !pushConversationId });
5271
5779
  // The sidebar's "watched" badges follow the subscriptions.
5272
5780
  onRelayEventSubscriptionsChanged(() => renderConvList());
5273
5781
  void loadRelayEventSubscriptions();
@@ -5276,9 +5784,9 @@ async function initApp() {
5276
5784
  void loadRelaySchedules();
5277
5785
  // The Inbox's count, and the cards of questions that don't block.
5278
5786
  startInbox();
5279
- const pushConversationId = consumePushConversationDeepLink();
5787
+ initQuestionsPanel();
5280
5788
  if (pushConversationId) {
5281
- await openConversation(pushConversationId).catch(() => {});
5789
+ await openConversation(pushConversationId, { history: 'replace' }).catch(() => {});
5282
5790
  }
5283
5791
  await loadRelayQuestions(currentConvId);
5284
5792
  await loadRelayBoards();
@@ -5333,6 +5841,32 @@ async function bootstrap() {
5333
5841
  }
5334
5842
  const urlToken = getTokenFromUrl();
5335
5843
  if (urlToken) stripTokenFromUrl();
5844
+ // Back and forward walk the sessions opened, and back closes an open
5845
+ // overlay first (session-history.mjs).
5846
+ initSessionHistory({
5847
+ openConversation,
5848
+ openSidebar,
5849
+ closeSidebar,
5850
+ closeComposerSettings,
5851
+ closeChatActionsMenu,
5852
+ closeSummaryModal,
5853
+ closeSettingsModal,
5854
+ closeMessageSearchModal,
5855
+ closeRepoBrowser,
5856
+ closeNewConversationModelModal,
5857
+ // Leaving the Inbox goes back to the conversation it was opened from.
5858
+ // Opened from Status it stored none: `returnTo` is the session history stands on.
5859
+ closeInboxView: ({ returnTo = '' } = {}) => { if (isInboxViewActive()) void toggleInboxView({ returnTo }); },
5860
+ closeChangeCwdMenu: () => closeChangeCwdMenu({ focusTrigger: true }),
5861
+ closeGitChangesModal,
5862
+ closeGitDiffViewer,
5863
+ closeTmuxInspector: closeTmuxInspectorView,
5864
+ closeAnnotateEditor,
5865
+ closeRelayWatchPanel,
5866
+ closeQuestionsPanel,
5867
+ closeRelaySchedulePanel,
5868
+ closeEmojiPicker,
5869
+ });
5336
5870
  // In the installed app the system back button must never close the app;
5337
5871
  // a tab keeps its own back button. After the token is gone from the
5338
5872
  // address, so the entries the guard lays down carry the clean one.
@@ -5341,7 +5875,10 @@ async function bootstrap() {
5341
5875
  enabled: isInstalledAppMode(),
5342
5876
  target: window,
5343
5877
  onFloor: () => showTransientRelayNotice('Press back again to close the app', 2500),
5878
+ entryAgain: sessionHistoryEntryAgain,
5344
5879
  });
5880
+ // Armed after the guard: on a phone the session list lies above its floor.
5881
+ armSessionHistoryOnFirstInteraction(window);
5345
5882
  const persistedToken = consumeLegacyPersistedAuthToken();
5346
5883
  const bootstrapToken = String(urlToken || persistedToken || '').trim();
5347
5884
  const existingSession = await verifyExistingSession(bootstrapToken);
@@ -5415,6 +5952,12 @@ window.resetGrokAllowanceAccounting = resetGrokAllowanceAccounting;
5415
5952
  window.toggleCursorProvider = toggleCursorProvider;
5416
5953
  window.updateShowSuspendHostSetting = updateShowSuspendHostSetting;
5417
5954
  window.updateScrollOnSendSetting = (enabled) => updateScrollOnSendSetting(enabled);
5955
+ window.updateThoughtsExpandedSetting = (expanded) => updateThoughtsExpandedSetting(expanded);
5956
+ window.updateStreamViewSetting = (on) => updateStreamViewSetting(on);
5957
+ // The transcript and the live bubble take the other layout at once.
5958
+ window.addEventListener(STREAM_VIEW_CHANGE_EVENT, () => refreshStreamViewLayout());
5959
+ window.previewLiveActivityCap = (position) => previewLiveActivityCap(position);
5960
+ window.updateLiveActivityCap = (position) => updateLiveActivityCap(position);
5418
5961
  window.previewConversationWindowRetention = (position) => previewWindowRetention(position);
5419
5962
  // A lower cap cuts the kept windows down now; the open conversation's is cut
5420
5963
  // when it is left.
@@ -5480,17 +6023,20 @@ window.deleteConv = deleteConv;
5480
6023
  window.archiveConv = archiveConv;
5481
6024
  window.openConversationGroupPicker = openConversationGroupPicker;
5482
6025
  window.toggleConversationGroupFold = toggleConversationGroupFold;
6026
+ window.toggleAllConversationGroupFolds = toggleAllConversationGroupFolds;
5483
6027
  window.unarchiveConv = unarchiveConv;
5484
6028
  window.toggleArchivedConversations = toggleArchivedConversations;
5485
6029
  window.refreshNewConversationCloudRepoList = refreshNewConversationCloudRepoList;
5486
6030
  window.openConversation = openConversation;
5487
6031
  window.openOriginConversation = openOriginConversation;
5488
6032
  // The Inbox and Status share the transcript's place: opening one leaves the other.
6033
+ // Closed by its button, one opened from the other goes back to the session
6034
+ // history stands on.
5489
6035
  window.toggleStatusView = () => {
5490
6036
  leaveInboxView();
5491
- return toggleStatusView();
6037
+ return toggleStatusView({ returnTo: sessionHistoryConversation() });
5492
6038
  };
5493
- window.toggleInboxView = toggleInboxView;
6039
+ window.toggleInboxView = () => toggleInboxView({ returnTo: sessionHistoryConversation() });
5494
6040
  window.refreshConversations = refreshConversations;
5495
6041
  window.renderConvList = renderConvList;
5496
6042
  window.handleAttachmentInput = handleAttachmentInput;