@oar-sh/oar 0.9.9 → 0.9.11

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 (154) hide show
  1. package/CHANGELOG.md +561 -1
  2. package/README.md +162 -909
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +14 -14
  5. package/server/README.md +369 -13
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +121 -9
  8. package/server/claude-worker/claude-session-worker.mjs +9 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
  13. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  14. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  15. package/server/db-schema.mjs +95 -2
  16. package/server/grok-worker/acp-client.mjs +2 -1
  17. package/server/grok-worker/acp-host-services.mjs +4 -3
  18. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  19. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  20. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  21. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  22. package/server/migrations/0013-previews.mjs +130 -0
  23. package/server/migrations/0014-conversation-groups.mjs +89 -0
  24. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  25. package/server/migrations/0016-share-access-log.mjs +110 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +187 -0
  30. package/server/public/app/api-client.js +312 -16
  31. package/server/public/app/archive-confirm.mjs +28 -0
  32. package/server/public/app/ask-user-view.js +17 -12
  33. package/server/public/app/attachments-view.js +516 -6
  34. package/server/public/app/background-tasks-view.mjs +18 -26
  35. package/server/public/app/bootstrap.js +336 -59
  36. package/server/public/app/chat-actions-menu-state.mjs +126 -0
  37. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  38. package/server/public/app/composer-control-state.mjs +16 -0
  39. package/server/public/app/composer-keyboard.mjs +54 -0
  40. package/server/public/app/context-menu.mjs +90 -6
  41. package/server/public/app/conversation-group-drag.mjs +191 -0
  42. package/server/public/app/conversation-group-order.mjs +265 -0
  43. package/server/public/app/conversation-group-state.mjs +100 -0
  44. package/server/public/app/conversation-list-drag.mjs +449 -0
  45. package/server/public/app/conversation-turn-state.mjs +81 -0
  46. package/server/public/app/conversation-view.js +436 -117
  47. package/server/public/app/cwd-picker.js +1 -1
  48. package/server/public/app/explorer-edit-state.mjs +68 -0
  49. package/server/public/app/hidden-messages-view.mjs +312 -0
  50. package/server/public/app/host-suspend-ui.js +13 -4
  51. package/server/public/app/journal-view.js +930 -45
  52. package/server/public/app/no-conversation-state.mjs +58 -0
  53. package/server/public/app/preview-cards.mjs +117 -31
  54. package/server/public/app/preview-command.mjs +10 -1
  55. package/server/public/app/provider-readiness.mjs +120 -0
  56. package/server/public/app/relay-board-view.js +19 -3
  57. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  58. package/server/public/app/relay-event-view.mjs +234 -0
  59. package/server/public/app/relay-restart-error.mjs +16 -0
  60. package/server/public/app/settings-modal.js +93 -9
  61. package/server/public/app/settings-tabs.js +1 -1
  62. package/server/public/app/share-format.mjs +49 -0
  63. package/server/public/app/share-preview-marks.mjs +167 -0
  64. package/server/public/app/share-state.js +109 -0
  65. package/server/public/app/shared-conversations-settings.js +480 -0
  66. package/server/public/app/socket-handlers.js +137 -40
  67. package/server/public/app/store.js +115 -32
  68. package/server/public/app/subagent-row.mjs +138 -0
  69. package/server/public/app/subagent-timeline-view.mjs +186 -0
  70. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  71. package/server/public/app/subagent-viewer.mjs +558 -0
  72. package/server/public/app/task-row-format.mjs +29 -0
  73. package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
  74. package/server/public/app/transcript-jump-bottom.mjs +210 -0
  75. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  76. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  77. package/server/public/app/update-ui.js +1 -1
  78. package/server/public/app/viewer-history.mjs +5 -3
  79. package/server/public/index.html +774 -12
  80. package/server/relay-tools.md +2 -1
  81. package/server/relay.mjs +2 -1
  82. package/server/repositories/conversation-group-repository.mjs +444 -0
  83. package/server/repositories/message-repository.mjs +30 -3
  84. package/server/repositories/question-repository.mjs +9 -3
  85. package/server/repositories/remote-relay-repository.mjs +6 -2
  86. package/server/repositories/session-repository.mjs +12 -37
  87. package/server/repositories/share-repository.mjs +322 -0
  88. package/server/repositories/share-visibility-statements.mjs +10 -0
  89. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  90. package/server/routes/about-routes.mjs +15 -0
  91. package/server/routes/ask-user-routes.mjs +3 -0
  92. package/server/routes/cache-routes.mjs +4 -0
  93. package/server/routes/conversation-group-routes.mjs +171 -0
  94. package/server/routes/messages-routes.mjs +698 -114
  95. package/server/routes/preview-routes.mjs +9 -1
  96. package/server/routes/relay-event-routes.mjs +157 -0
  97. package/server/routes/remote-relay-routes.mjs +15 -0
  98. package/server/routes/sessions-routes.mjs +843 -193
  99. package/server/server-runtime.mjs +559 -160
  100. package/server/services/about-service.mjs +461 -0
  101. package/server/services/background-task-store.mjs +128 -0
  102. package/server/services/background-work-view.mjs +228 -0
  103. package/server/services/cache-rebuild-service.mjs +30 -0
  104. package/server/services/delete-archive-service.mjs +4 -1
  105. package/server/services/detached-worker-watch-service.mjs +150 -0
  106. package/server/services/explorer-entry-name.mjs +26 -0
  107. package/server/services/explorer-mutations.mjs +122 -0
  108. package/server/services/hidden-message-list.mjs +21 -0
  109. package/server/services/host-suspend-command.mjs +2 -1
  110. package/server/services/message-pin-list.mjs +1 -1
  111. package/server/services/preview-health-probe.mjs +8 -1
  112. package/server/services/preview-proxy-server.mjs +49 -27
  113. package/server/services/preview-registry-service.mjs +676 -26
  114. package/server/services/preview-static-handler.mjs +40 -7
  115. package/server/services/preview-store.mjs +274 -0
  116. package/server/services/push-dispatch-service.mjs +18 -0
  117. package/server/services/rate-limit-service.mjs +12 -1
  118. package/server/services/relay-auth.mjs +183 -0
  119. package/server/services/relay-cli-launcher-service.mjs +3 -1
  120. package/server/services/relay-event-link-service.mjs +289 -0
  121. package/server/services/relay-event-service.mjs +1952 -0
  122. package/server/services/relay-shutdown-gate.mjs +31 -0
  123. package/server/services/remote-relay-caller-context.mjs +52 -7
  124. package/server/services/remote-relay-dispatcher.mjs +551 -7
  125. package/server/services/remote-relay-registry-service.mjs +11 -1
  126. package/server/services/request-boundary.mjs +183 -0
  127. package/server/services/session-activity-tracker.mjs +695 -0
  128. package/server/services/session-history-refresh-service.mjs +7 -0
  129. package/server/services/session-worker-launch-service.mjs +190 -6
  130. package/server/services/session-worker-process-service.mjs +10 -6
  131. package/server/services/share-access-log-service.mjs +123 -0
  132. package/server/services/share-path-redaction.mjs +424 -0
  133. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  134. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  135. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  136. package/server/services/status-event-service.mjs +7 -3
  137. package/server/services/subagent-record-retention.mjs +164 -0
  138. package/server/services/subagent-timeline-service.mjs +328 -0
  139. package/server/services/subagent-viewer-rooms.mjs +119 -0
  140. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  141. package/server/services/upgrade-socket-guard.mjs +16 -0
  142. package/server/services/windows-autostart-service.mjs +6 -1
  143. package/server/services/windows-boot-autostart-service.mjs +207 -23
  144. package/shared/preview-tool-core.mjs +15 -5
  145. package/shared/relay-events.mjs +371 -0
  146. package/shared/remote-relay-contract.mjs +160 -6
  147. package/shared/share-path-privacy.mjs +30 -0
  148. package/shared/subagent-record-retention.mjs +31 -0
  149. package/shared/subagent-timeline.mjs +79 -0
  150. package/shared/windows-system-tools.mjs +106 -0
  151. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  152. package/shared/worker-runtime/heartbeat.mjs +24 -0
  153. package/shared/worker-runtime/kept-posts.mjs +83 -0
  154. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -1,4 +1,5 @@
1
1
  import {
2
+ BASE,
2
3
  CLIENT_ID,
3
4
  currentConvId,
4
5
  conversations,
@@ -24,7 +25,7 @@ import {
24
25
  showTransientRelayNotice,
25
26
  applyContextUsageBar,
26
27
  scrollBottom,
27
- isMessagesAtBottom,
28
+ isMessagesNearBottom,
28
29
  setHistoryRefreshInFlight,
29
30
  isHistoryRefreshInFlight,
30
31
  setSummaryModalLoading,
@@ -46,13 +47,20 @@ import {
46
47
  resolveConversationUiState,
47
48
  summaryModalState,
48
49
  IS_SHARED_VIEW,
50
+ IS_SHARE_PREVIEW,
49
51
  SHARED_CONVERSATION_TOKEN,
50
52
  getConversationWatcherCount,
51
53
  generateId,
52
54
  imageEditTarget,
53
55
  hasPendingUserMessageForConversation,
54
56
  } from './store.js';
55
- import { createTranscriptResizeKeeper } from './transcript-viewport-anchor.mjs';
57
+ import {
58
+ captureReadingBlock,
59
+ createTranscriptResizeKeeper,
60
+ restoreReadingBlock,
61
+ } from './transcript-viewport-anchor.mjs';
62
+ import { initTranscriptJumpToBottom, onTranscriptEndLeft } from './transcript-jump-bottom-view.mjs';
63
+ import { syncScrollOnSendToggle, updateScrollOnSendSetting } from './transcript-jump-bottom.mjs';
56
64
  import {
57
65
  verifyExistingSession,
58
66
  verifyToken,
@@ -91,6 +99,8 @@ import {
91
99
  isArchivedConversationsView,
92
100
  refreshNewConversationCloudRepoList,
93
101
  openOriginConversation,
102
+ openConversationGroupPicker,
103
+ toggleConversationGroupFold,
94
104
  } from './journal-view.js';
95
105
  import {
96
106
  loadRelayQuestions,
@@ -103,6 +113,7 @@ import {
103
113
  renderMessages,
104
114
  appendMessage,
105
115
  compactCurrentConversation,
116
+ renderNoConversationState,
106
117
  sendMessage,
107
118
  handleKey,
108
119
  updateComposerSlashMenu,
@@ -124,13 +135,17 @@ import {
124
135
  conversationHasActiveTurn,
125
136
  focusConversationMessageById,
126
137
  syncRenderedPinState,
138
+ syncRenderedShareVisibility,
127
139
  isConversationWindowAtTail,
128
140
  getTranscriptJumpEpoch,
141
+ getTranscriptLiveRowEpoch,
129
142
  } from './conversation-view.js';
143
+ import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
130
144
  import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
145
+ import { initHiddenMessagesView, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
131
146
  import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
132
147
  import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
133
- 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 } from './attachments-view.js';
148
+ 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';
134
149
  import { initEmojiPicker, toggleEmojiPicker } from './emoji-view.js';
135
150
  import { initSlashAutocomplete } from './slash-autocomplete.mjs';
136
151
  import { initMentionAutocomplete } from './mention-autocomplete.mjs';
@@ -173,6 +188,8 @@ import {
173
188
  openMessageSearchModal,
174
189
  closeMessageSearchModal,
175
190
  } from './message-search-view.js';
191
+ import { initSubagentViewer } from './subagent-viewer.mjs';
192
+ import { watchPreviewMarks } from './share-preview-marks.mjs';
176
193
  import {
177
194
  initGitChangesView,
178
195
  openGitChangesModal,
@@ -274,6 +291,7 @@ import {
274
291
  updateTheme,
275
292
  openSettingsModal,
276
293
  closeSettingsModal,
294
+ isSuspendHostActionVisible,
277
295
  syncSuspendHostVisibility,
278
296
  updateShowSuspendHostSetting,
279
297
  syncDefaultSessionWorkspaceRootInput,
@@ -309,6 +327,7 @@ import {
309
327
  updateTurnCeilingSetting,
310
328
  previewBackgroundTaskTimeoutSetting,
311
329
  updateBackgroundTaskTimeoutSetting,
330
+ updateSubagentRecordRetentionSetting,
312
331
  } from './settings-modal.js';
313
332
  import {
314
333
  togglePushOnThisDevice,
@@ -356,14 +375,24 @@ import {
356
375
  subscribeClaudeCloudSettings,
357
376
  toggleClaudeCloudProvider,
358
377
  } from './claude-cloud-settings-ui.js';
359
- import { openCurrentCloudSession, syncClaudeCloudConversationUi } from './claude-cloud-conversation-ui.js';
378
+ import { isCurrentConversationClaudeCloud, openCurrentCloudSession, syncClaudeCloudConversationUi } from './claude-cloud-conversation-ui.js';
379
+ import {
380
+ CHAT_ACTIONS_MENU_ENTRY_IDS,
381
+ CHAT_ACTIONS_MENU_META_IDS,
382
+ resolveChatActionsMenuState,
383
+ } from './chat-actions-menu-state.mjs';
384
+ import { subscribeProviderReadiness, updateProviderReadinessInputs } from './provider-readiness.mjs';
385
+ import { hasActiveShare, refreshShares, subscribeShares } from './share-state.js';
386
+ import { openStopSharingConfirmation } from './shared-conversations-settings.js';
387
+ import { showAbout, syncAboutMenuLabel } from './about-ui.js';
360
388
  import { syncConversationOriginHeader } from './conversation-origin-ui.js';
389
+ import { loadRelayEventSubscriptions, onRelayEventSubscriptionsChanged, syncRelayEventHeader } from './relay-event-subscriptions-ui.js';
361
390
  import {
362
391
  previewAgentSessionsMaxWait,
363
392
  saveAgentSessionsMaxWait,
364
393
  toggleAgentSessions,
365
394
  } from './agent-sessions-settings-ui.js';
366
- import { CLAUDE_CLOUD_PROVIDER, claudeCloudComposerModelIds } from './claude-cloud-ui.mjs';
395
+ import { CLAUDE_CLOUD_PROVIDER, buildCloudLineModel, claudeCloudComposerModelIds } from './claude-cloud-ui.mjs';
367
396
  import {
368
397
  confirmCliInstall,
369
398
  runCliInstall,
@@ -377,6 +406,7 @@ import {
377
406
  dismissAvailableUpdate,
378
407
  dismissUpdateOutcome,
379
408
  } from './update-ui.js';
409
+ import { shouldAutofocusComposerOnLoad } from './composer-keyboard.mjs';
380
410
 
381
411
  const MODEL_STORAGE_KEY = 'copilot_selected_model';
382
412
  // The New Chat modal used to keep its own model key, so a selection made there
@@ -442,6 +472,8 @@ let liveConversationPollInFlight = false;
442
472
  let sharedViewerIdFallback = '';
443
473
  let sharedConversationRenderKey = '';
444
474
  let sharedConversationLastError = '';
475
+ // The share was stopped or renewed while the page was open: the reader stops.
476
+ let sharedConversationEnded = false;
445
477
  let networkLifecycleBound = false;
446
478
  let foregroundRecoveryTimer = null;
447
479
  let foregroundRecoveryInFlight = false;
@@ -860,14 +892,14 @@ function initNetworkLifecycleHandling() {
860
892
  }
861
893
 
862
894
  function syncThemeMenuLabel() {
863
- const button = document.getElementById('chat-menu-shared-theme-toggle');
895
+ const button = document.getElementById('chat-menu-theme-toggle');
864
896
  if (!button) return;
865
897
  const isLight = document.documentElement.getAttribute('data-theme') === 'light';
866
898
  button.textContent = isLight ? '🌙 Night mode' : '☀️ Day mode';
867
899
  }
868
900
 
869
901
  function initThemeMenuToggle() {
870
- const themeBtn = document.getElementById('chat-menu-shared-theme-toggle');
902
+ const themeBtn = document.getElementById('chat-menu-theme-toggle');
871
903
  if (!themeBtn) return;
872
904
  themeBtn.hidden = false;
873
905
  syncThemeMenuLabel();
@@ -924,7 +956,7 @@ function applySharedReaderUi() {
924
956
  const modeSelect = document.getElementById('mode-select');
925
957
  const chatMenuBtn = document.getElementById('chat-actions-menu-btn');
926
958
  const chatMenu = document.getElementById('chat-actions-menu');
927
- const sharedThemeBtn = document.getElementById('chat-menu-shared-theme-toggle');
959
+ const themeBtn = document.getElementById('chat-menu-theme-toggle');
928
960
  const installBtn = document.getElementById('install-btn');
929
961
  const activeElement = document.activeElement;
930
962
  if (chatMenu && activeElement instanceof Element && chatMenu.contains(activeElement)) {
@@ -941,7 +973,7 @@ function applySharedReaderUi() {
941
973
  if (input) {
942
974
  input.readOnly = true;
943
975
  input.disabled = true;
944
- input.placeholder = 'Shared conversation (read-only)';
976
+ input.placeholder = IS_SHARE_PREVIEW ? 'Preview as shared (read-only)' : 'Shared conversation (read-only)';
945
977
  }
946
978
  disableSharedControl(sendBtn);
947
979
  disableSharedControl(attachBtn);
@@ -953,19 +985,23 @@ function applySharedReaderUi() {
953
985
  if (modelSelect) modelSelect.hidden = true;
954
986
  if (effortSelect) effortSelect.hidden = true;
955
987
  if (modeSelect) modeSelect.hidden = true;
956
- if (chatMenu && sharedThemeBtn) {
988
+ // One source for the menu: the resolver's shared branch (Day/Night only,
989
+ // trigger usable). The loop below stays as a guard for entries the resolver
990
+ // does not know.
991
+ applyChatActionsMenuState(resolveChatActionsMenuState({ sharedMode: true }));
992
+ if (chatMenu && themeBtn) {
957
993
  for (const child of Array.from(chatMenu.children)) {
958
- if (child !== sharedThemeBtn) hideSharedMenuEntry(child);
994
+ if (child !== themeBtn) hideSharedMenuEntry(child);
959
995
  }
960
- sharedThemeBtn.hidden = false;
961
- sharedThemeBtn.removeAttribute('aria-hidden');
962
- sharedThemeBtn.disabled = false;
963
- sharedThemeBtn.tabIndex = 0;
996
+ themeBtn.hidden = false;
997
+ themeBtn.removeAttribute('aria-hidden');
998
+ themeBtn.disabled = false;
999
+ themeBtn.tabIndex = 0;
964
1000
  syncThemeMenuLabel();
965
1001
  } else if (chatMenuBtn) {
966
1002
  chatMenuBtn.hidden = true;
967
1003
  }
968
- if (chatMenuBtn && !sharedThemeBtn) chatMenuBtn.hidden = true;
1004
+ if (chatMenuBtn && !themeBtn) chatMenuBtn.hidden = true;
969
1005
  }
970
1006
 
971
1007
  function syncChatTitleWatcherIndicator() {
@@ -1006,18 +1042,50 @@ function resolveStableScrollTopForLiveRefresh(messagesEl, capturedScrollTop) {
1006
1042
  return captured;
1007
1043
  }
1008
1044
 
1009
- async function refreshSharedConversation() {
1010
- if (sharedConversationPollInFlight) return;
1045
+ // Whether a reload keeps the reader at the end: as it was when the reload was
1046
+ // asked for, unless they have moved while it was on its way (scrolled up, or
1047
+ // jumped back to the end); then as it is now.
1048
+ function resolveLiveRefreshAtBottom(messagesEl, capturedScrollTop, capturedAtBottom) {
1049
+ const current = Number(messagesEl?.scrollTop);
1050
+ if (!Number.isFinite(current) || Math.abs(current - Number(capturedScrollTop)) <= 2) return capturedAtBottom;
1051
+ return isMessagesNearBottom();
1052
+ }
1053
+
1054
+ // A stopped or renewed share answers 404. The page stops polling at once and
1055
+ // keeps nothing of the conversation on screen; one neutral line stays.
1056
+ function endSharedConversationReader() {
1057
+ if (sharedConversationEnded) return;
1058
+ sharedConversationEnded = true;
1059
+ stopSharedModeTimers();
1060
+ document.getElementById('messages')?.replaceChildren();
1061
+ const titleEl = document.getElementById('chat-title');
1062
+ if (titleEl) titleEl.textContent = 'Shared conversation';
1063
+ sharedConversationLastError = '';
1064
+ setModelBanner('This shared conversation is not available.');
1065
+ }
1066
+
1067
+ async function refreshSharedConversation({ open = false } = {}) {
1068
+ if (sharedConversationPollInFlight || sharedConversationEnded) return;
1011
1069
  const sharedToken = resolveSharedTokenFromLocation();
1012
- if (!sharedToken) return;
1070
+ if (!sharedToken && !IS_SHARE_PREVIEW) return;
1013
1071
  sharedConversationPollInFlight = true;
1014
1072
  const requestSeq = ++sharedConversationRequestSeq;
1015
1073
  const messagesEl = document.getElementById('messages');
1016
- const preserveBottom = isMessagesAtBottom();
1074
+ const wasAtBottom = isMessagesNearBottom();
1017
1075
  const savedScrollTop = messagesEl?.scrollTop || 0;
1018
1076
  try {
1019
- const response = await loadSharedConversation(sharedToken, { limit: 120 });
1077
+ // The reader has no socket today, so nothing moves the count; the guard
1078
+ // keeps a rebuild from dropping a live row should one ever arrive.
1079
+ const { response, stale } = await loadKeepingLiveRows(
1080
+ () => loadSharedConversation(sharedToken, { limit: 120, open }),
1081
+ getTranscriptLiveRowEpoch,
1082
+ );
1083
+ if (response?.status === 404) {
1084
+ endSharedConversationReader();
1085
+ return;
1086
+ }
1020
1087
  if (requestSeq < sharedConversationAppliedSeq) return;
1088
+ if (stale) return;
1021
1089
  if (!response?.ok) {
1022
1090
  const message = String(response?.error || 'Could not load shared conversation.').trim();
1023
1091
  if (message && message !== sharedConversationLastError) {
@@ -1050,6 +1118,7 @@ async function refreshSharedConversation() {
1050
1118
  return;
1051
1119
  }
1052
1120
  sharedConversationRenderKey = renderKey;
1121
+ const preserveBottom = resolveLiveRefreshAtBottom(messagesEl, savedScrollTop, wasAtBottom);
1053
1122
  const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, savedScrollTop);
1054
1123
  applyLoadedConversationState(convId, response, {
1055
1124
  restoreScroll: !preserveBottom,
@@ -1065,15 +1134,29 @@ async function refreshSharedConversation() {
1065
1134
 
1066
1135
  async function pulseSharedViewerPresence() {
1067
1136
  const sharedToken = resolveSharedTokenFromLocation();
1068
- if (!sharedToken) return;
1137
+ if (!sharedToken || sharedConversationEnded) return;
1069
1138
  await reportSharedViewerPresence(sharedToken, sharedViewerId()).catch(() => {});
1070
1139
  }
1071
1140
 
1141
+ // "Preview as shared": the owner's own page, so it says what it is and what
1142
+ // the highlights mean; the marks come with the preview payload.
1143
+ function initSharePreviewUi() {
1144
+ if (!IS_SHARE_PREVIEW) return;
1145
+ document.body.classList.add('share-preview-mode');
1146
+ const banner = document.getElementById('share-preview-banner');
1147
+ if (banner) {
1148
+ banner.textContent = 'Preview as shared: this is what a share link shows. Green marks what viewers get instead of a path; amber marks what still looks like one. Free text is filtered on a best-effort basis.';
1149
+ }
1150
+ watchPreviewMarks(document.body);
1151
+ }
1152
+
1072
1153
  async function initSharedConversationReader() {
1073
1154
  applySharedReaderUi();
1074
- await refreshSharedConversation();
1155
+ initSharePreviewUi();
1156
+ await refreshSharedConversation({ open: true });
1075
1157
  await pulseSharedViewerPresence();
1076
1158
  stopSharedModeTimers();
1159
+ if (sharedConversationEnded) return;
1077
1160
  sharedConversationPollTimer = setInterval(() => {
1078
1161
  if (!shouldRunForegroundNetworkWork()) return;
1079
1162
  void refreshSharedConversation();
@@ -1090,6 +1173,12 @@ function showAuthError(msg) {
1090
1173
 
1091
1174
  function resolveAuthErrorMessage(result = null) {
1092
1175
  if (result?.status === 401) return 'Invalid token';
1176
+ if (result?.status === 429) {
1177
+ const seconds = Number(result?.payload?.retryAfterSeconds);
1178
+ return Number.isFinite(seconds) && seconds > 0
1179
+ ? `Too many failed sign-in attempts. Try again in ${Math.ceil(seconds)} seconds.`
1180
+ : 'Too many failed sign-in attempts. Try again in a minute.';
1181
+ }
1093
1182
  if (result?.status === 403) return 'Access denied';
1094
1183
  if (result?.status > 0) return `Authentication failed (${result.status})`;
1095
1184
  return String(result?.error || 'Could not reach the relay').trim() || 'Could not reach the relay';
@@ -1382,6 +1471,8 @@ function syncComposerPlaceholder() {
1382
1471
  if (!input) return;
1383
1472
  if (isSharedReaderMode()) return;
1384
1473
  if (imageEditTarget) return;
1474
+ // The composer is locked until a provider is ready; its text says why.
1475
+ if (input.dataset.setupLocked === '1') return;
1385
1476
  const rawValue = String(document.getElementById('model-select')?.value || '');
1386
1477
  const { baseModelId } = splitModelVariantId(rawValue);
1387
1478
  input.placeholder = composerPlaceholderFor({
@@ -1616,6 +1707,8 @@ function updateModelCatalogState(payload) {
1616
1707
  // Shared readers never load the catalog, so this would only surface
1617
1708
  // "metadata unavailable" warnings for a picker they cannot see.
1618
1709
  if (isSharedReaderMode()) return;
1710
+ // The catalog's `source` is the Copilot readiness signal (provider-readiness.mjs).
1711
+ updateProviderReadinessInputs({ catalog: payload && typeof payload === 'object' ? payload : null });
1619
1712
  const select = document.getElementById('model-select');
1620
1713
  if (!select) return;
1621
1714
  const models = Array.isArray(payload?.models)
@@ -2180,6 +2273,8 @@ function initModeSelector() {
2180
2273
  function refreshModelCatalog(force = false) {
2181
2274
  return loadModelCatalog().then((r) => {
2182
2275
  if (!r) {
2276
+ // A failed load leaves provider readiness unknown: no setup wall on a blip.
2277
+ updateProviderReadinessInputs({ catalog: null });
2183
2278
  if (isModelMetadataHealthy(modelCatalogState) || isModelMetadataHealthy(lastHealthyModelCatalogState)) {
2184
2279
  setModelBanner('⚠️ Could not refresh model metadata; using last known good catalog.');
2185
2280
  syncModelMetadataBlocker();
@@ -3630,24 +3725,35 @@ async function pollAuthenticatedCurrentConversationLive() {
3630
3725
  liveConversationPollInFlight = true;
3631
3726
  try {
3632
3727
  const messagesEl = document.getElementById('messages');
3633
- const preserveBottom = isMessagesAtBottom();
3728
+ const wasAtBottom = isMessagesNearBottom();
3634
3729
  const savedScrollTop = messagesEl?.scrollTop || 0;
3635
3730
  const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
3636
3731
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
3637
3732
  const jumpEpoch = getTranscriptJumpEpoch();
3638
- const response = await loadConversation(currentId, { limit: requestLimit });
3733
+ const { response, stale } = await loadKeepingLiveRows(
3734
+ () => loadConversation(currentId, { limit: requestLimit }),
3735
+ getTranscriptLiveRowEpoch,
3736
+ );
3639
3737
  if (!response) return;
3640
3738
  if (String(currentConvId || '').trim() !== currentId) return;
3641
3739
  // The user jumped into the history while this request was on its way: the
3642
3740
  // answer is the end of the conversation as it was asked for before that,
3643
3741
  // and would overwrite the window they jumped to. The next round asks again.
3644
3742
  if (getTranscriptJumpEpoch() !== jumpEpoch) return;
3743
+ // Rows kept arriving through every request: the page has them, the answer
3744
+ // does not. The next round asks again.
3745
+ if (stale) return;
3746
+ const preserveBottom = resolveLiveRefreshAtBottom(messagesEl, savedScrollTop, wasAtBottom);
3645
3747
  const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, savedScrollTop);
3748
+ // The rows are rebuilt: a reader who is not at the end keeps the text
3749
+ // they read where it is, whatever the rows above it measure now.
3750
+ const reading = preserveBottom ? null : captureReadingBlock(messagesEl);
3646
3751
  applyLoadedConversationState(currentId, response, {
3647
3752
  restoreScroll: !preserveBottom,
3648
3753
  savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
3649
3754
  followLiveUpdates: preserveBottom,
3650
3755
  });
3756
+ if (reading) restoreReadingBlock(messagesEl, reading);
3651
3757
  } finally {
3652
3758
  liveConversationPollInFlight = false;
3653
3759
  }
@@ -3664,6 +3770,7 @@ function startLiveConversationPolling() {
3664
3770
  const transcriptResizeKeeper = createTranscriptResizeKeeper({
3665
3771
  getElement: () => document.getElementById('messages'),
3666
3772
  });
3773
+ onTranscriptEndLeft(() => transcriptResizeKeeper.recordEndLeft());
3667
3774
 
3668
3775
  function setupViewportTracking() {
3669
3776
  syncViewportMetrics();
@@ -3793,6 +3900,19 @@ function initMessageScrollPersistence() {
3793
3900
  }, { passive: true });
3794
3901
  }
3795
3902
 
3903
+ // The jump-to-bottom button on a window in the middle of the history: the
3904
+ // end of the conversation replaces it, scrolled to the bottom.
3905
+ async function loadCurrentConversationEnd() {
3906
+ const currentId = String(currentConvId || '').trim();
3907
+ if (!currentId) return;
3908
+ const jumpEpoch = getTranscriptJumpEpoch();
3909
+ const response = await loadConversation(currentId, { limit: 20 });
3910
+ if (!response || String(currentConvId || '').trim() !== currentId) return;
3911
+ // A jump into the history while it was on its way wins.
3912
+ if (getTranscriptJumpEpoch() !== jumpEpoch) return;
3913
+ applyLoadedConversationState(currentId, response, { restoreScroll: false });
3914
+ }
3915
+
3796
3916
  async function runConversationHistoryRefresh({ source = 'menu' } = {}) {
3797
3917
  const currentId = String(currentConvId || '').trim();
3798
3918
  if (!currentId) {
@@ -3851,9 +3971,8 @@ async function refreshCurrentView() {
3851
3971
  const capturedVersion = ++refreshViewVersion;
3852
3972
  const jumpEpoch = getTranscriptJumpEpoch();
3853
3973
  const messagesEl = document.getElementById('messages');
3854
- const preserveBottom = isMessagesAtBottom();
3974
+ const wasAtBottom = isMessagesNearBottom();
3855
3975
  const scrollTop = messagesEl?.scrollTop || 0;
3856
- const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, scrollTop);
3857
3976
 
3858
3977
  await refreshConversations();
3859
3978
 
@@ -3869,13 +3988,22 @@ async function refreshCurrentView() {
3869
3988
 
3870
3989
  const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
3871
3990
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
3872
- const r = await loadConversation(currentId, { limit: requestLimit });
3991
+ const { response: r, stale } = await loadKeepingLiveRows(
3992
+ () => loadConversation(currentId, { limit: requestLimit }),
3993
+ getTranscriptLiveRowEpoch,
3994
+ );
3873
3995
  if (capturedVersion < refreshViewVersion) return;
3874
3996
  if (String(currentConvId || '').trim() !== currentId) return;
3875
3997
  // The user jumped into the history, before this refresh or while it was on
3876
3998
  // its way: title, tasks, pins and the rest are refreshed, the transcript
3877
- // and its scroll position are left as they are.
3878
- const keepTranscript = getTranscriptJumpEpoch() !== jumpEpoch || transcriptIsInHistory(currentId);
3999
+ // and its scroll position are left as they are. So too when rows kept
4000
+ // arriving through every request: the page has rows the answer lacks.
4001
+ const keepTranscript = getTranscriptJumpEpoch() !== jumpEpoch || transcriptIsInHistory(currentId) || stale;
4002
+ const preserveBottom = resolveLiveRefreshAtBottom(messagesEl, scrollTop, wasAtBottom);
4003
+ const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, scrollTop);
4004
+ // As in the live poll: the text the reader is at stays where it is.
4005
+ const reading = preserveBottom || keepTranscript ? null : captureReadingBlock(messagesEl);
4006
+ let restoredAt = null;
3879
4007
  if (r) {
3880
4008
  applyLoadedConversationState(currentId, r, {
3881
4009
  restoreScroll: !preserveBottom,
@@ -3883,6 +4011,9 @@ async function refreshCurrentView() {
3883
4011
  followLiveUpdates: preserveBottom && !keepTranscript,
3884
4012
  keepTranscript,
3885
4013
  });
4014
+ // At once, and again below once the cards are back on the page.
4015
+ restoreReadingBlock(messagesEl, reading);
4016
+ restoredAt = { scrollTop: messagesEl?.scrollTop };
3886
4017
  } else {
3887
4018
  setRepoBrowserSessionInfo('', '');
3888
4019
  restoreInFlightThinking(null);
@@ -3891,7 +4022,12 @@ async function refreshCurrentView() {
3891
4022
  await loadRelayBoards();
3892
4023
  scheduleContextUsageRefresh(currentId, 0);
3893
4024
  if (messagesEl && !keepTranscript && String(currentConvId || '').trim() === currentId) {
3894
- if (!preserveBottom && Number.isFinite(stableSavedScrollTop) && stableSavedScrollTop >= 0) {
4025
+ // Two requests later: a reader who has scrolled, pressed the jump
4026
+ // button or sent something meanwhile is where they want to be.
4027
+ const readerMoved = !restoredAt
4028
+ || Math.abs(Number(messagesEl.scrollTop) - Number(restoredAt.scrollTop)) > 2;
4029
+ if (!preserveBottom && !readerMoved && !restoreReadingBlock(messagesEl, reading)
4030
+ && Number.isFinite(stableSavedScrollTop) && stableSavedScrollTop >= 0) {
3895
4031
  messagesEl.scrollTop = stableSavedScrollTop;
3896
4032
  }
3897
4033
  saveConversationScrollTop(currentId, messagesEl.scrollTop);
@@ -3951,7 +4087,7 @@ function getChatTitleElements() {
3951
4087
  return {
3952
4088
  wrap: document.getElementById('chat-title-wrap'),
3953
4089
  title: document.getElementById('chat-title'),
3954
- editBtn: document.getElementById('chat-actions-menu-btn'),
4090
+ menuBtn: document.getElementById('chat-actions-menu-btn'),
3955
4091
  editor: document.getElementById('chat-title-editor'),
3956
4092
  input: document.getElementById('chat-title-input'),
3957
4093
  saveBtn: document.getElementById('chat-title-save-btn'),
@@ -3968,12 +4104,68 @@ function closeChatActionsMenu() {
3968
4104
  if (backdrop && !window.__chatActionsMenuShieldTimer) backdrop.classList.remove('visible');
3969
4105
  }
3970
4106
 
4107
+ // Writes one resolver decision to the DOM, the way the shared view hides its
4108
+ // entries: `hidden` alone for the text chips, plus `disabled`, `aria-hidden`
4109
+ // and the tab order for the buttons.
4110
+ function applyChatActionsMenuState(state) {
4111
+ const trigger = document.getElementById('chat-actions-menu-btn');
4112
+ if (trigger && state?.trigger) {
4113
+ trigger.hidden = !!state.trigger.hidden;
4114
+ trigger.disabled = !!state.trigger.disabled;
4115
+ }
4116
+ const entries = state?.entries || {};
4117
+ for (const id of CHAT_ACTIONS_MENU_ENTRY_IDS) {
4118
+ const element = document.getElementById(id);
4119
+ const decision = entries[id];
4120
+ if (!element || !decision) continue;
4121
+ element.hidden = !!decision.hidden;
4122
+ if (CHAT_ACTIONS_MENU_META_IDS.includes(id)) continue;
4123
+ if (decision.hidden) {
4124
+ element.setAttribute('aria-hidden', 'true');
4125
+ if ('disabled' in element) element.disabled = true;
4126
+ if ('tabIndex' in element) element.tabIndex = -1;
4127
+ continue;
4128
+ }
4129
+ element.removeAttribute('aria-hidden');
4130
+ if ('disabled' in element) element.disabled = !!decision.disabled;
4131
+ if ('tabIndex' in element) element.tabIndex = 0;
4132
+ }
4133
+ }
4134
+
4135
+ // The ⋯ menu as it should look right now: the trigger and every entry,
4136
+ // decided in chat-actions-menu-state.mjs from the conversation on screen.
4137
+ function syncChatActionsMenuState() {
4138
+ const convId = String(currentConvId || '').trim();
4139
+ const conversation = convId ? (conversations[convId] || null) : null;
4140
+ const isCloud = !!convId && isCurrentConversationClaudeCloud();
4141
+ const state = resolveChatActionsMenuState({
4142
+ sharedMode: isSharedReaderMode(),
4143
+ conversationId: convId,
4144
+ conversation,
4145
+ sdkSessionId: String(conversation?.sdkSessionId || '').trim(),
4146
+ isCloud,
4147
+ cloudSessionUrl: isCloud ? buildCloudLineModel(conversation?.cloud)?.sessionUrl || null : null,
4148
+ editing: !!convId && chatTitleEditingConversationId === convId,
4149
+ suspendVisible: isSuspendHostActionVisible(),
4150
+ hasActiveShare: hasActiveShare(convId),
4151
+ });
4152
+ applyChatActionsMenuState(state);
4153
+ return state;
4154
+ }
4155
+
4156
+ // The menu follows the relay's share list (made, renewed, stopped anywhere).
4157
+ subscribeShares(() => syncChatActionsMenuState());
4158
+
3971
4159
  function toggleChatActionsMenu() {
3972
4160
  const menu = document.getElementById('chat-actions-menu');
3973
4161
  const trigger = document.getElementById('chat-actions-menu-btn');
3974
- if (!menu || !trigger || trigger.hidden || trigger.disabled) return;
4162
+ if (!menu || !trigger) return;
4163
+ // The entries match the moment of the tap, as Refresh history already does.
4164
+ syncChatActionsMenuState();
4165
+ if (trigger.hidden || trigger.disabled) return;
3975
4166
  syncRefreshHistoryMenuState();
3976
4167
  syncThemeMenuLabel();
4168
+ syncAboutMenuLabel();
3977
4169
  const willOpen = !!menu.hidden;
3978
4170
  menu.hidden = !willOpen;
3979
4171
  trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
@@ -4050,12 +4242,13 @@ function lockChatActionsMenuShield(ms = 300) {
4050
4242
 
4051
4243
 
4052
4244
  function syncChatTitleControls() {
4053
- const { title, editBtn, editor, input } = getChatTitleElements();
4245
+ const { title, editor, input } = getChatTitleElements();
4054
4246
  const convId = String(currentConvId || '').trim();
4055
- // No conversation open (deleted, new chat): its 📍 button goes with it.
4056
- if (!convId) setPinsConversation(null);
4057
- const killBtn = document.getElementById('chat-menu-kill-session');
4058
- const sharedMode = isSharedReaderMode();
4247
+ // No conversation open (deleted, new chat): its 📍 and 🙈 buttons go with it.
4248
+ if (!convId) {
4249
+ setPinsConversation(null);
4250
+ setHiddenMessagesConversation(null);
4251
+ }
4059
4252
  const conversation = convId ? (conversations[convId] || null) : null;
4060
4253
  const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
4061
4254
  if (title) {
@@ -4079,17 +4272,11 @@ function syncChatTitleControls() {
4079
4272
  const editing = convId && chatTitleEditingConversationId === convId;
4080
4273
  document.body.classList.toggle('chat-title-editing', !!editing);
4081
4274
  if (title) title.hidden = editing;
4082
- if (editBtn) {
4083
- editBtn.hidden = !convId;
4084
- editBtn.disabled = !convId || editing;
4085
- }
4086
- if (killBtn) {
4087
- killBtn.disabled = sharedMode || !convId || !sdkSessionId;
4088
- killBtn.hidden = sharedMode || !convId;
4089
- }
4275
+ // The ⋯ trigger and its entries: relay-wide ones stay with no conversation
4276
+ // open, so Settings is always one tap away.
4277
+ syncChatActionsMenuState();
4090
4278
  const archiveBtn = document.getElementById('chat-menu-archive');
4091
4279
  if (archiveBtn) {
4092
- archiveBtn.hidden = sharedMode || !convId;
4093
4280
  archiveBtn.textContent = conversation?.archived === true ? '📂 Unarchive conversation' : '🗄 Archive conversation';
4094
4281
  }
4095
4282
  if (editor) {
@@ -4112,19 +4299,21 @@ function syncChatTitleControls() {
4112
4299
  syncClaudeCloudConversationUi();
4113
4300
  // "via agent": the conversation an agent on this relay started this one from.
4114
4301
  syncConversationOriginHeader();
4302
+ // Relay events: "watching N sessions" on a watcher, "watched" on a watched one.
4303
+ syncRelayEventHeader();
4115
4304
  syncChatTitleWatcherIndicator();
4116
4305
  }
4117
4306
 
4118
4307
  function openChatTitleEditor() {
4119
4308
  const convId = String(currentConvId || '').trim();
4120
4309
  if (!convId) return;
4121
- const { title, editBtn, editor, input } = getChatTitleElements();
4310
+ const { title, menuBtn, editor, input } = getChatTitleElements();
4122
4311
  closeChatActionsMenu();
4123
4312
  const currentTitle = String(conversations[convId]?.title || title?.textContent || convId).trim() || convId;
4124
4313
  chatTitleEditingConversationId = convId;
4125
4314
  document.body.classList.add('chat-title-editing');
4126
4315
  if (title) title.hidden = true;
4127
- if (editBtn) editBtn.disabled = true;
4316
+ if (menuBtn) menuBtn.disabled = true;
4128
4317
  if (editor) editor.hidden = false;
4129
4318
  if (input) {
4130
4319
  input.maxLength = CHAT_TITLE_MAX_LENGTH;
@@ -4143,11 +4332,11 @@ function openChatTitleEditor() {
4143
4332
  function closeChatTitleEditor() {
4144
4333
  chatTitleEditingConversationId = null;
4145
4334
  document.body.classList.remove('chat-title-editing');
4146
- const { title, editBtn, editor, input } = getChatTitleElements();
4335
+ const { title, editor, input } = getChatTitleElements();
4147
4336
  closeChatActionsMenu();
4148
4337
  if (editor) editor.hidden = true;
4149
4338
  if (title) title.hidden = !String(currentConvId || '').trim();
4150
- if (editBtn) editBtn.disabled = !String(currentConvId || '').trim();
4339
+ // The trigger is decided by the syncChatTitleControls() call below.
4151
4340
  if (input) {
4152
4341
  const convId = String(currentConvId || '').trim();
4153
4342
  input.value = convId ? String(conversations[convId]?.title || title?.textContent || convId) : '';
@@ -4317,19 +4506,19 @@ async function initApp() {
4317
4506
  appSharedMode = sharedMode;
4318
4507
  initNetworkLifecycleHandling();
4319
4508
  setForegroundNetworkWorkEnabled(document.visibilityState === 'visible');
4320
- const sharedThemeBtn = document.getElementById('chat-menu-shared-theme-toggle');
4321
4509
  initTheme();
4322
4510
  initScrollbarHover();
4323
4511
  initThemeMenuToggle();
4324
- if (sharedThemeBtn && sharedMode) sharedThemeBtn.hidden = false;
4325
4512
  initFontScaling();
4326
4513
  if (!sharedMode) {
4327
4514
  clearLegacyKnownCwdHistoryStorage();
4328
4515
  }
4329
4516
  syncPwaVersionMenuEntry();
4330
4517
  syncQueueStatusMenuEntry();
4518
+ if (!sharedMode) syncAboutMenuLabel();
4331
4519
  if (!sharedMode) {
4332
4520
  syncSuspendHostVisibility();
4521
+ syncScrollOnSendToggle();
4333
4522
  initHostSuspendUi();
4334
4523
  initUsageLimitUi();
4335
4524
  }
@@ -4394,6 +4583,16 @@ async function initApp() {
4394
4583
  });
4395
4584
  });
4396
4585
  }
4586
+ // "Session group…": the picker for the open conversation, reachable on
4587
+ // the phone with the drawer closed.
4588
+ bindMenuAction(document.getElementById('chat-menu-group'), (event) => {
4589
+ event.preventDefault();
4590
+ event.stopPropagation();
4591
+ lockChatActionsMenuShield(350);
4592
+ closeChatActionsMenu();
4593
+ const convId = String(currentConvId || '').trim();
4594
+ if (convId) openConversationGroupPicker(convId);
4595
+ });
4397
4596
  const chatMenuArchiveBtn = document.getElementById('chat-menu-archive');
4398
4597
  bindMenuAction(chatMenuArchiveBtn, (event) => {
4399
4598
  event.preventDefault();
@@ -4424,10 +4623,35 @@ async function initApp() {
4424
4623
  }
4425
4624
  const copied = await copyTextToClipboard(shareUrl);
4426
4625
  showTransientRelayNotice(copied ? 'Share link copied to clipboard.' : shareUrl);
4626
+ void refreshShares().catch(() => {});
4427
4627
  }).catch((error) => {
4428
4628
  showTransientRelayNotice(error?.message || 'Could not create share link.');
4429
4629
  });
4430
4630
  });
4631
+ // "Stop sharing": only shown while the open conversation has a live link.
4632
+ bindMenuAction(document.getElementById('chat-menu-stop-sharing'), (event) => {
4633
+ event.preventDefault();
4634
+ event.stopPropagation();
4635
+ lockChatActionsMenuShield(350);
4636
+ closeChatActionsMenu();
4637
+ const convId = String(currentConvId || '').trim();
4638
+ if (!convId) return;
4639
+ openStopSharingConfirmation(convId, conversations[convId]?.title || '');
4640
+ });
4641
+ const chatMenuPreviewSharedBtn = document.getElementById('chat-menu-preview-shared');
4642
+ bindMenuAction(chatMenuPreviewSharedBtn, (event) => {
4643
+ event.preventDefault();
4644
+ event.stopPropagation();
4645
+ lockChatActionsMenuShield(350);
4646
+ closeChatActionsMenu();
4647
+ const convId = String(currentConvId || '').trim();
4648
+ if (!convId) {
4649
+ showTransientRelayNotice('Select a conversation first.');
4650
+ return;
4651
+ }
4652
+ // Never creates a share link: the preview reads through the owner's route.
4653
+ window.open(`${BASE}/share-preview/${encodeURIComponent(convId)}`, '_blank', 'noopener');
4654
+ });
4431
4655
  const chatMenuRefreshHistoryBtn = document.getElementById('chat-menu-refresh-history');
4432
4656
  if (chatMenuRefreshHistoryBtn && !chatMenuRefreshHistoryBtn.hidden && !chatMenuRefreshHistoryBtn.disabled) {
4433
4657
  bindMenuAction(chatMenuRefreshHistoryBtn, (event) => {
@@ -4480,6 +4704,14 @@ async function initApp() {
4480
4704
  closeChatActionsMenu();
4481
4705
  openSettingsModal();
4482
4706
  });
4707
+ const chatMenuAboutBtn = document.getElementById('chat-menu-about');
4708
+ bindMenuAction(chatMenuAboutBtn, (event) => {
4709
+ event.preventDefault();
4710
+ event.stopPropagation();
4711
+ lockChatActionsMenuShield(350);
4712
+ closeChatActionsMenu();
4713
+ void showAbout();
4714
+ });
4483
4715
  const chatMenuRestartRelayBtn = document.getElementById('chat-menu-restart-relay');
4484
4716
  bindMenuAction(chatMenuRestartRelayBtn, (event) => {
4485
4717
  event.preventDefault();
@@ -4556,6 +4788,8 @@ async function initApp() {
4556
4788
  initConversationHistoryLazyLoading();
4557
4789
  initBubbleActionHandlers();
4558
4790
  initMessageScrollPersistence();
4791
+ initTranscriptJumpToBottom();
4792
+ initSubagentViewer();
4559
4793
  syncChatTitleControls();
4560
4794
  await initSharedConversationReader();
4561
4795
  return;
@@ -4578,14 +4812,25 @@ async function initApp() {
4578
4812
  const status = await refreshWorkspaceRootHints();
4579
4813
  syncQueueStatusMenuEntry(status);
4580
4814
  setSessionWorkerStatesFromStatusPayload(status?.sessionWorker || null);
4581
- await refreshOpenAISettingsState();
4582
- await refreshCursorSettingsState();
4583
- await refreshGrokSettingsState();
4815
+ const openAISettings = await refreshOpenAISettingsState();
4816
+ const cursorSettings = await refreshCursorSettingsState();
4817
+ const grokSettings = await refreshGrokSettingsState();
4818
+ // Loaded here for provider readiness: whether any provider can run a chat
4819
+ // decides what the empty screen, New Chat and the composer offer.
4820
+ const claudeSettings = await refreshClaudeSettingsState();
4821
+ updateProviderReadinessInputs({
4822
+ openai: openAISettings || null,
4823
+ cursor: cursorSettings || null,
4824
+ grok: grokSettings || null,
4825
+ claude: claudeSettings || null,
4826
+ });
4584
4827
  // Not awaited: only a Claude Cloud conversation needs it (its model list),
4585
4828
  // and the subscription below rebuilds that list when the answer arrives. A
4586
4829
  // relay without the provider answers nothing and nothing changes.
4587
4830
  subscribeClaudeCloudSettings(refreshClaudeCloudComposerModels);
4588
- void refreshClaudeCloudSettingsState();
4831
+ void refreshClaudeCloudSettingsState().then((state) => {
4832
+ updateProviderReadinessInputs({ claudeCloud: state || null });
4833
+ });
4589
4834
  await refreshModelCatalog(true);
4590
4835
  initFullscreenButton();
4591
4836
  initInstallButton();
@@ -4599,15 +4844,25 @@ async function initApp() {
4599
4844
  initConversationHistoryLazyLoading();
4600
4845
  initBubbleActionHandlers();
4601
4846
  initMessageScrollPersistence();
4847
+ initTranscriptJumpToBottom({ loadEnd: loadCurrentConversationEnd });
4602
4848
  initComposerAttachmentInput();
4603
4849
  initMessageSearchView({ openConversation });
4850
+ initSubagentViewer();
4604
4851
  initPinnedMessagesView({
4605
4852
  openConversation,
4606
4853
  focusMessage: focusConversationMessageById,
4607
4854
  syncRenderedPinState,
4608
4855
  });
4856
+ initHiddenMessagesView({ syncRenderedShareVisibility });
4609
4857
  initGitChangesView();
4610
4858
  syncChatTitleControls();
4859
+ // A provider turned on or off (a Settings save, a catalog change) redraws
4860
+ // the empty screen and the composer without a reload.
4861
+ subscribeProviderReadiness(() => {
4862
+ renderNoConversationState();
4863
+ syncComposerButtonState();
4864
+ syncComposerPlaceholder();
4865
+ });
4611
4866
  connectSocket();
4612
4867
  startRelayConnectionWatchdog();
4613
4868
  startDeviceVisibilityHeartbeat();
@@ -4619,6 +4874,9 @@ async function initApp() {
4619
4874
  // Browsers without Background Sync replay the outbox from the page instead.
4620
4875
  void initOutboxFallbackReplay();
4621
4876
  await loadConversations();
4877
+ // The sidebar's "watched" badges follow the subscriptions.
4878
+ onRelayEventSubscriptionsChanged(() => renderConvList());
4879
+ void loadRelayEventSubscriptions();
4622
4880
  const pushConversationId = consumePushConversationDeepLink();
4623
4881
  if (pushConversationId) {
4624
4882
  await openConversation(pushConversationId).catch(() => {});
@@ -4626,7 +4884,19 @@ async function initApp() {
4626
4884
  await loadRelayQuestions(currentConvId);
4627
4885
  await loadRelayBoards();
4628
4886
  updateCompactButton();
4629
- document.getElementById('msg-input').focus();
4887
+ // Nothing opened (fresh client, fresh relay, a last conversation that is
4888
+ // gone): the static markup gives way to the real empty screen.
4889
+ renderNoConversationState();
4890
+ syncComposerButtonState();
4891
+ syncComposerPlaceholder();
4892
+ // A phone shows no keyboard for this focus, but the next tap that keeps it
4893
+ // (the transcript's ⌄) would raise one.
4894
+ if (shouldAutofocusComposerOnLoad({
4895
+ coarsePointer: !!window.matchMedia?.('(pointer: coarse)')?.matches,
4896
+ phoneLayout: isMobileComposerViewport(),
4897
+ })) {
4898
+ document.getElementById('msg-input').focus();
4899
+ }
4630
4900
  }
4631
4901
 
4632
4902
  async function doAuth() {
@@ -4647,7 +4917,9 @@ async function doAuth() {
4647
4917
  async function bootstrap() {
4648
4918
  if (ensureTrailingSlashPath()) return;
4649
4919
  const sharedMode = isSharedReaderMode();
4650
- if (sharedMode && navigator.serviceWorker?.getRegistrations) {
4920
+ // The preview runs in the owner's own browser: their app's service worker
4921
+ // (and its push subscription) stays.
4922
+ if (sharedMode && !IS_SHARE_PREVIEW && navigator.serviceWorker?.getRegistrations) {
4651
4923
  navigator.serviceWorker.getRegistrations()
4652
4924
  .then((registrations) => Promise.all(registrations.map((registration) => registration.unregister().catch(() => false))))
4653
4925
  .catch(() => {});
@@ -4734,11 +5006,13 @@ window.saveGrokAllowanceSettings = saveGrokAllowanceSettings;
4734
5006
  window.resetGrokAllowanceAccounting = resetGrokAllowanceAccounting;
4735
5007
  window.toggleCursorProvider = toggleCursorProvider;
4736
5008
  window.updateShowSuspendHostSetting = updateShowSuspendHostSetting;
5009
+ window.updateScrollOnSendSetting = (enabled) => updateScrollOnSendSetting(enabled);
4737
5010
  window.updateWindowsAutostartMode = updateWindowsAutostartMode;
4738
5011
  window.previewTurnCeilingSetting = previewTurnCeilingSetting;
4739
5012
  window.updateTurnCeilingSetting = updateTurnCeilingSetting;
4740
5013
  window.previewBackgroundTaskTimeoutSetting = previewBackgroundTaskTimeoutSetting;
4741
5014
  window.updateBackgroundTaskTimeoutSetting = updateBackgroundTaskTimeoutSetting;
5015
+ window.updateSubagentRecordRetentionSetting = updateSubagentRecordRetentionSetting;
4742
5016
  window.togglePushOnThisDevice = togglePushOnThisDevice;
4743
5017
  window.updatePushPreferencesFromControls = updatePushPreferencesFromControls;
4744
5018
  window.openSettingsModal = openSettingsModal;
@@ -4787,6 +5061,8 @@ window.confirmNewConversationModel = confirmNewConversationModel;
4787
5061
  window.closeNewConversationModelModal = closeNewConversationModelModal;
4788
5062
  window.deleteConv = deleteConv;
4789
5063
  window.archiveConv = archiveConv;
5064
+ window.openConversationGroupPicker = openConversationGroupPicker;
5065
+ window.toggleConversationGroupFold = toggleConversationGroupFold;
4790
5066
  window.unarchiveConv = unarchiveConv;
4791
5067
  window.toggleArchivedConversations = toggleArchivedConversations;
4792
5068
  window.refreshNewConversationCloudRepoList = refreshNewConversationCloudRepoList;
@@ -4815,6 +5091,7 @@ window.toggleRepoBrowserHidden = toggleRepoBrowserHidden;
4815
5091
  window.toggleRepoBrowserHeavy = toggleRepoBrowserHeavy;
4816
5092
  window.focusRepoTree = focusRepoTree;
4817
5093
  window.setRepoCurrentPath = setRepoCurrentPath;
5094
+ window.startRepoNewFolder = startRepoNewFolder;
4818
5095
  window.confirmRepoBrowserCwdPick = confirmRepoBrowserCwdPick;
4819
5096
  window.toggleEmojiPicker = toggleEmojiPicker;
4820
5097
  window.submitRelayQuestionChoice = submitRelayQuestionChoice;