@oar-sh/oar 0.9.10 → 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 (104) hide show
  1. package/CHANGELOG.md +314 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +95 -22
  5. package/server/claude-worker/claude-session-process.mjs +89 -11
  6. package/server/claude-worker/claude-session-worker.mjs +9 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
  8. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  9. package/server/db-schema.mjs +59 -2
  10. package/server/grok-worker/acp-client.mjs +2 -1
  11. package/server/grok-worker/acp-host-services.mjs +4 -3
  12. package/server/migrations/0013-previews.mjs +130 -0
  13. package/server/migrations/0014-conversation-groups.mjs +89 -0
  14. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  15. package/server/migrations/0016-share-access-log.mjs +110 -0
  16. package/server/public/app/about-ui.js +73 -0
  17. package/server/public/app/about-view.mjs +234 -0
  18. package/server/public/app/action-confirmations.js +31 -14
  19. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  20. package/server/public/app/api-client.js +251 -16
  21. package/server/public/app/ask-user-view.js +17 -15
  22. package/server/public/app/bootstrap.js +158 -32
  23. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  24. package/server/public/app/context-menu.mjs +90 -6
  25. package/server/public/app/conversation-group-drag.mjs +191 -0
  26. package/server/public/app/conversation-group-order.mjs +265 -0
  27. package/server/public/app/conversation-group-state.mjs +100 -0
  28. package/server/public/app/conversation-list-drag.mjs +449 -0
  29. package/server/public/app/conversation-turn-state.mjs +81 -0
  30. package/server/public/app/conversation-view.js +136 -93
  31. package/server/public/app/cwd-picker.js +1 -1
  32. package/server/public/app/hidden-messages-view.mjs +312 -0
  33. package/server/public/app/host-suspend-ui.js +7 -1
  34. package/server/public/app/journal-view.js +787 -22
  35. package/server/public/app/preview-cards.mjs +117 -31
  36. package/server/public/app/preview-command.mjs +10 -1
  37. package/server/public/app/relay-board-view.js +19 -6
  38. package/server/public/app/relay-restart-error.mjs +16 -0
  39. package/server/public/app/settings-modal.js +40 -8
  40. package/server/public/app/settings-tabs.js +1 -1
  41. package/server/public/app/share-format.mjs +49 -0
  42. package/server/public/app/share-preview-marks.mjs +167 -0
  43. package/server/public/app/share-state.js +109 -0
  44. package/server/public/app/shared-conversations-settings.js +480 -0
  45. package/server/public/app/socket-handlers.js +80 -14
  46. package/server/public/app/store.js +96 -30
  47. package/server/public/app/subagent-row.mjs +3 -3
  48. package/server/public/app/subagent-viewer.mjs +30 -6
  49. package/server/public/app/thinking-follow.js +0 -4
  50. package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
  51. package/server/public/app/transcript-jump-bottom.mjs +132 -21
  52. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  53. package/server/public/index.html +386 -15
  54. package/server/relay-tools.md +2 -1
  55. package/server/relay.mjs +2 -1
  56. package/server/repositories/conversation-group-repository.mjs +444 -0
  57. package/server/repositories/message-repository.mjs +1 -0
  58. package/server/repositories/session-repository.mjs +12 -37
  59. package/server/repositories/share-repository.mjs +322 -0
  60. package/server/repositories/share-visibility-statements.mjs +10 -0
  61. package/server/routes/about-routes.mjs +15 -0
  62. package/server/routes/cache-routes.mjs +4 -0
  63. package/server/routes/conversation-group-routes.mjs +171 -0
  64. package/server/routes/messages-routes.mjs +184 -65
  65. package/server/routes/preview-routes.mjs +9 -1
  66. package/server/routes/sessions-routes.mjs +771 -193
  67. package/server/server-runtime.mjs +316 -100
  68. package/server/services/about-service.mjs +461 -0
  69. package/server/services/background-task-store.mjs +128 -0
  70. package/server/services/background-work-view.mjs +228 -0
  71. package/server/services/cache-rebuild-service.mjs +30 -0
  72. package/server/services/delete-archive-service.mjs +4 -1
  73. package/server/services/detached-worker-watch-service.mjs +150 -0
  74. package/server/services/hidden-message-list.mjs +21 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/message-pin-list.mjs +1 -1
  77. package/server/services/preview-health-probe.mjs +8 -1
  78. package/server/services/preview-proxy-server.mjs +8 -4
  79. package/server/services/preview-registry-service.mjs +676 -26
  80. package/server/services/preview-static-handler.mjs +40 -7
  81. package/server/services/preview-store.mjs +274 -0
  82. package/server/services/rate-limit-service.mjs +12 -1
  83. package/server/services/relay-auth.mjs +183 -0
  84. package/server/services/relay-cli-launcher-service.mjs +3 -1
  85. package/server/services/relay-event-service.mjs +17 -0
  86. package/server/services/remote-relay-dispatcher.mjs +57 -5
  87. package/server/services/request-boundary.mjs +183 -0
  88. package/server/services/session-activity-tracker.mjs +227 -19
  89. package/server/services/session-worker-launch-service.mjs +190 -6
  90. package/server/services/session-worker-process-service.mjs +10 -6
  91. package/server/services/share-access-log-service.mjs +123 -0
  92. package/server/services/share-path-redaction.mjs +424 -0
  93. package/server/services/status-event-service.mjs +7 -3
  94. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  95. package/server/services/windows-autostart-service.mjs +6 -1
  96. package/server/services/windows-boot-autostart-service.mjs +207 -23
  97. package/shared/preview-tool-core.mjs +15 -5
  98. package/shared/remote-relay-contract.mjs +3 -1
  99. package/shared/share-path-privacy.mjs +30 -0
  100. package/shared/windows-system-tools.mjs +106 -0
  101. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  102. package/shared/worker-runtime/heartbeat.mjs +24 -0
  103. package/shared/worker-runtime/kept-posts.mjs +83 -0
  104. 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,14 +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';
56
- import { initTranscriptJumpToBottom } from './transcript-jump-bottom-view.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';
57
64
  import {
58
65
  verifyExistingSession,
59
66
  verifyToken,
@@ -92,6 +99,8 @@ import {
92
99
  isArchivedConversationsView,
93
100
  refreshNewConversationCloudRepoList,
94
101
  openOriginConversation,
102
+ openConversationGroupPicker,
103
+ toggleConversationGroupFold,
95
104
  } from './journal-view.js';
96
105
  import {
97
106
  loadRelayQuestions,
@@ -126,13 +135,14 @@ import {
126
135
  conversationHasActiveTurn,
127
136
  focusConversationMessageById,
128
137
  syncRenderedPinState,
138
+ syncRenderedShareVisibility,
129
139
  isConversationWindowAtTail,
130
140
  getTranscriptJumpEpoch,
131
141
  getTranscriptLiveRowEpoch,
132
- syncAgentExchangeFoldsNow,
133
142
  } from './conversation-view.js';
134
143
  import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
135
144
  import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
145
+ import { initHiddenMessagesView, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
136
146
  import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
137
147
  import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
138
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';
@@ -179,6 +189,7 @@ import {
179
189
  closeMessageSearchModal,
180
190
  } from './message-search-view.js';
181
191
  import { initSubagentViewer } from './subagent-viewer.mjs';
192
+ import { watchPreviewMarks } from './share-preview-marks.mjs';
182
193
  import {
183
194
  initGitChangesView,
184
195
  openGitChangesModal,
@@ -371,6 +382,9 @@ import {
371
382
  resolveChatActionsMenuState,
372
383
  } from './chat-actions-menu-state.mjs';
373
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';
374
388
  import { syncConversationOriginHeader } from './conversation-origin-ui.js';
375
389
  import { loadRelayEventSubscriptions, onRelayEventSubscriptionsChanged, syncRelayEventHeader } from './relay-event-subscriptions-ui.js';
376
390
  import {
@@ -458,6 +472,8 @@ let liveConversationPollInFlight = false;
458
472
  let sharedViewerIdFallback = '';
459
473
  let sharedConversationRenderKey = '';
460
474
  let sharedConversationLastError = '';
475
+ // The share was stopped or renewed while the page was open: the reader stops.
476
+ let sharedConversationEnded = false;
461
477
  let networkLifecycleBound = false;
462
478
  let foregroundRecoveryTimer = null;
463
479
  let foregroundRecoveryInFlight = false;
@@ -876,14 +892,14 @@ function initNetworkLifecycleHandling() {
876
892
  }
877
893
 
878
894
  function syncThemeMenuLabel() {
879
- const button = document.getElementById('chat-menu-shared-theme-toggle');
895
+ const button = document.getElementById('chat-menu-theme-toggle');
880
896
  if (!button) return;
881
897
  const isLight = document.documentElement.getAttribute('data-theme') === 'light';
882
898
  button.textContent = isLight ? '🌙 Night mode' : '☀️ Day mode';
883
899
  }
884
900
 
885
901
  function initThemeMenuToggle() {
886
- const themeBtn = document.getElementById('chat-menu-shared-theme-toggle');
902
+ const themeBtn = document.getElementById('chat-menu-theme-toggle');
887
903
  if (!themeBtn) return;
888
904
  themeBtn.hidden = false;
889
905
  syncThemeMenuLabel();
@@ -940,7 +956,7 @@ function applySharedReaderUi() {
940
956
  const modeSelect = document.getElementById('mode-select');
941
957
  const chatMenuBtn = document.getElementById('chat-actions-menu-btn');
942
958
  const chatMenu = document.getElementById('chat-actions-menu');
943
- const sharedThemeBtn = document.getElementById('chat-menu-shared-theme-toggle');
959
+ const themeBtn = document.getElementById('chat-menu-theme-toggle');
944
960
  const installBtn = document.getElementById('install-btn');
945
961
  const activeElement = document.activeElement;
946
962
  if (chatMenu && activeElement instanceof Element && chatMenu.contains(activeElement)) {
@@ -957,7 +973,7 @@ function applySharedReaderUi() {
957
973
  if (input) {
958
974
  input.readOnly = true;
959
975
  input.disabled = true;
960
- input.placeholder = 'Shared conversation (read-only)';
976
+ input.placeholder = IS_SHARE_PREVIEW ? 'Preview as shared (read-only)' : 'Shared conversation (read-only)';
961
977
  }
962
978
  disableSharedControl(sendBtn);
963
979
  disableSharedControl(attachBtn);
@@ -973,19 +989,19 @@ function applySharedReaderUi() {
973
989
  // trigger usable). The loop below stays as a guard for entries the resolver
974
990
  // does not know.
975
991
  applyChatActionsMenuState(resolveChatActionsMenuState({ sharedMode: true }));
976
- if (chatMenu && sharedThemeBtn) {
992
+ if (chatMenu && themeBtn) {
977
993
  for (const child of Array.from(chatMenu.children)) {
978
- if (child !== sharedThemeBtn) hideSharedMenuEntry(child);
994
+ if (child !== themeBtn) hideSharedMenuEntry(child);
979
995
  }
980
- sharedThemeBtn.hidden = false;
981
- sharedThemeBtn.removeAttribute('aria-hidden');
982
- sharedThemeBtn.disabled = false;
983
- sharedThemeBtn.tabIndex = 0;
996
+ themeBtn.hidden = false;
997
+ themeBtn.removeAttribute('aria-hidden');
998
+ themeBtn.disabled = false;
999
+ themeBtn.tabIndex = 0;
984
1000
  syncThemeMenuLabel();
985
1001
  } else if (chatMenuBtn) {
986
1002
  chatMenuBtn.hidden = true;
987
1003
  }
988
- if (chatMenuBtn && !sharedThemeBtn) chatMenuBtn.hidden = true;
1004
+ if (chatMenuBtn && !themeBtn) chatMenuBtn.hidden = true;
989
1005
  }
990
1006
 
991
1007
  function syncChatTitleWatcherIndicator() {
@@ -1032,25 +1048,42 @@ function resolveStableScrollTopForLiveRefresh(messagesEl, capturedScrollTop) {
1032
1048
  function resolveLiveRefreshAtBottom(messagesEl, capturedScrollTop, capturedAtBottom) {
1033
1049
  const current = Number(messagesEl?.scrollTop);
1034
1050
  if (!Number.isFinite(current) || Math.abs(current - Number(capturedScrollTop)) <= 2) return capturedAtBottom;
1035
- return isMessagesAtBottom();
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.');
1036
1065
  }
1037
1066
 
1038
- async function refreshSharedConversation() {
1039
- if (sharedConversationPollInFlight) return;
1067
+ async function refreshSharedConversation({ open = false } = {}) {
1068
+ if (sharedConversationPollInFlight || sharedConversationEnded) return;
1040
1069
  const sharedToken = resolveSharedTokenFromLocation();
1041
- if (!sharedToken) return;
1070
+ if (!sharedToken && !IS_SHARE_PREVIEW) return;
1042
1071
  sharedConversationPollInFlight = true;
1043
1072
  const requestSeq = ++sharedConversationRequestSeq;
1044
1073
  const messagesEl = document.getElementById('messages');
1045
- const wasAtBottom = isMessagesAtBottom();
1074
+ const wasAtBottom = isMessagesNearBottom();
1046
1075
  const savedScrollTop = messagesEl?.scrollTop || 0;
1047
1076
  try {
1048
1077
  // The reader has no socket today, so nothing moves the count; the guard
1049
1078
  // keeps a rebuild from dropping a live row should one ever arrive.
1050
1079
  const { response, stale } = await loadKeepingLiveRows(
1051
- () => loadSharedConversation(sharedToken, { limit: 120 }),
1080
+ () => loadSharedConversation(sharedToken, { limit: 120, open }),
1052
1081
  getTranscriptLiveRowEpoch,
1053
1082
  );
1083
+ if (response?.status === 404) {
1084
+ endSharedConversationReader();
1085
+ return;
1086
+ }
1054
1087
  if (requestSeq < sharedConversationAppliedSeq) return;
1055
1088
  if (stale) return;
1056
1089
  if (!response?.ok) {
@@ -1101,15 +1134,29 @@ async function refreshSharedConversation() {
1101
1134
 
1102
1135
  async function pulseSharedViewerPresence() {
1103
1136
  const sharedToken = resolveSharedTokenFromLocation();
1104
- if (!sharedToken) return;
1137
+ if (!sharedToken || sharedConversationEnded) return;
1105
1138
  await reportSharedViewerPresence(sharedToken, sharedViewerId()).catch(() => {});
1106
1139
  }
1107
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
+
1108
1153
  async function initSharedConversationReader() {
1109
1154
  applySharedReaderUi();
1110
- await refreshSharedConversation();
1155
+ initSharePreviewUi();
1156
+ await refreshSharedConversation({ open: true });
1111
1157
  await pulseSharedViewerPresence();
1112
1158
  stopSharedModeTimers();
1159
+ if (sharedConversationEnded) return;
1113
1160
  sharedConversationPollTimer = setInterval(() => {
1114
1161
  if (!shouldRunForegroundNetworkWork()) return;
1115
1162
  void refreshSharedConversation();
@@ -1126,6 +1173,12 @@ function showAuthError(msg) {
1126
1173
 
1127
1174
  function resolveAuthErrorMessage(result = null) {
1128
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
+ }
1129
1182
  if (result?.status === 403) return 'Access denied';
1130
1183
  if (result?.status > 0) return `Authentication failed (${result.status})`;
1131
1184
  return String(result?.error || 'Could not reach the relay').trim() || 'Could not reach the relay';
@@ -3672,7 +3725,7 @@ async function pollAuthenticatedCurrentConversationLive() {
3672
3725
  liveConversationPollInFlight = true;
3673
3726
  try {
3674
3727
  const messagesEl = document.getElementById('messages');
3675
- const wasAtBottom = isMessagesAtBottom();
3728
+ const wasAtBottom = isMessagesNearBottom();
3676
3729
  const savedScrollTop = messagesEl?.scrollTop || 0;
3677
3730
  const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
3678
3731
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
@@ -3692,11 +3745,15 @@ async function pollAuthenticatedCurrentConversationLive() {
3692
3745
  if (stale) return;
3693
3746
  const preserveBottom = resolveLiveRefreshAtBottom(messagesEl, savedScrollTop, wasAtBottom);
3694
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);
3695
3751
  applyLoadedConversationState(currentId, response, {
3696
3752
  restoreScroll: !preserveBottom,
3697
3753
  savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
3698
3754
  followLiveUpdates: preserveBottom,
3699
3755
  });
3756
+ if (reading) restoreReadingBlock(messagesEl, reading);
3700
3757
  } finally {
3701
3758
  liveConversationPollInFlight = false;
3702
3759
  }
@@ -3713,6 +3770,7 @@ function startLiveConversationPolling() {
3713
3770
  const transcriptResizeKeeper = createTranscriptResizeKeeper({
3714
3771
  getElement: () => document.getElementById('messages'),
3715
3772
  });
3773
+ onTranscriptEndLeft(() => transcriptResizeKeeper.recordEndLeft());
3716
3774
 
3717
3775
  function setupViewportTracking() {
3718
3776
  syncViewportMetrics();
@@ -3913,7 +3971,7 @@ async function refreshCurrentView() {
3913
3971
  const capturedVersion = ++refreshViewVersion;
3914
3972
  const jumpEpoch = getTranscriptJumpEpoch();
3915
3973
  const messagesEl = document.getElementById('messages');
3916
- const wasAtBottom = isMessagesAtBottom();
3974
+ const wasAtBottom = isMessagesNearBottom();
3917
3975
  const scrollTop = messagesEl?.scrollTop || 0;
3918
3976
 
3919
3977
  await refreshConversations();
@@ -3943,6 +4001,9 @@ async function refreshCurrentView() {
3943
4001
  const keepTranscript = getTranscriptJumpEpoch() !== jumpEpoch || transcriptIsInHistory(currentId) || stale;
3944
4002
  const preserveBottom = resolveLiveRefreshAtBottom(messagesEl, scrollTop, wasAtBottom);
3945
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;
3946
4007
  if (r) {
3947
4008
  applyLoadedConversationState(currentId, r, {
3948
4009
  restoreScroll: !preserveBottom,
@@ -3950,6 +4011,9 @@ async function refreshCurrentView() {
3950
4011
  followLiveUpdates: preserveBottom && !keepTranscript,
3951
4012
  keepTranscript,
3952
4013
  });
4014
+ // At once, and again below once the cards are back on the page.
4015
+ restoreReadingBlock(messagesEl, reading);
4016
+ restoredAt = { scrollTop: messagesEl?.scrollTop };
3953
4017
  } else {
3954
4018
  setRepoBrowserSessionInfo('', '');
3955
4019
  restoreInFlightThinking(null);
@@ -3958,7 +4022,12 @@ async function refreshCurrentView() {
3958
4022
  await loadRelayBoards();
3959
4023
  scheduleContextUsageRefresh(currentId, 0);
3960
4024
  if (messagesEl && !keepTranscript && String(currentConvId || '').trim() === currentId) {
3961
- 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) {
3962
4031
  messagesEl.scrollTop = stableSavedScrollTop;
3963
4032
  }
3964
4033
  saveConversationScrollTop(currentId, messagesEl.scrollTop);
@@ -4078,11 +4147,15 @@ function syncChatActionsMenuState() {
4078
4147
  cloudSessionUrl: isCloud ? buildCloudLineModel(conversation?.cloud)?.sessionUrl || null : null,
4079
4148
  editing: !!convId && chatTitleEditingConversationId === convId,
4080
4149
  suspendVisible: isSuspendHostActionVisible(),
4150
+ hasActiveShare: hasActiveShare(convId),
4081
4151
  });
4082
4152
  applyChatActionsMenuState(state);
4083
4153
  return state;
4084
4154
  }
4085
4155
 
4156
+ // The menu follows the relay's share list (made, renewed, stopped anywhere).
4157
+ subscribeShares(() => syncChatActionsMenuState());
4158
+
4086
4159
  function toggleChatActionsMenu() {
4087
4160
  const menu = document.getElementById('chat-actions-menu');
4088
4161
  const trigger = document.getElementById('chat-actions-menu-btn');
@@ -4092,6 +4165,7 @@ function toggleChatActionsMenu() {
4092
4165
  if (trigger.hidden || trigger.disabled) return;
4093
4166
  syncRefreshHistoryMenuState();
4094
4167
  syncThemeMenuLabel();
4168
+ syncAboutMenuLabel();
4095
4169
  const willOpen = !!menu.hidden;
4096
4170
  menu.hidden = !willOpen;
4097
4171
  trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
@@ -4170,8 +4244,11 @@ function lockChatActionsMenuShield(ms = 300) {
4170
4244
  function syncChatTitleControls() {
4171
4245
  const { title, editor, input } = getChatTitleElements();
4172
4246
  const convId = String(currentConvId || '').trim();
4173
- // No conversation open (deleted, new chat): its 📍 button goes with it.
4174
- if (!convId) setPinsConversation(null);
4247
+ // No conversation open (deleted, new chat): its 📍 and 🙈 buttons go with it.
4248
+ if (!convId) {
4249
+ setPinsConversation(null);
4250
+ setHiddenMessagesConversation(null);
4251
+ }
4175
4252
  const conversation = convId ? (conversations[convId] || null) : null;
4176
4253
  const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
4177
4254
  if (title) {
@@ -4429,19 +4506,19 @@ async function initApp() {
4429
4506
  appSharedMode = sharedMode;
4430
4507
  initNetworkLifecycleHandling();
4431
4508
  setForegroundNetworkWorkEnabled(document.visibilityState === 'visible');
4432
- const sharedThemeBtn = document.getElementById('chat-menu-shared-theme-toggle');
4433
4509
  initTheme();
4434
4510
  initScrollbarHover();
4435
4511
  initThemeMenuToggle();
4436
- if (sharedThemeBtn && sharedMode) sharedThemeBtn.hidden = false;
4437
4512
  initFontScaling();
4438
4513
  if (!sharedMode) {
4439
4514
  clearLegacyKnownCwdHistoryStorage();
4440
4515
  }
4441
4516
  syncPwaVersionMenuEntry();
4442
4517
  syncQueueStatusMenuEntry();
4518
+ if (!sharedMode) syncAboutMenuLabel();
4443
4519
  if (!sharedMode) {
4444
4520
  syncSuspendHostVisibility();
4521
+ syncScrollOnSendToggle();
4445
4522
  initHostSuspendUi();
4446
4523
  initUsageLimitUi();
4447
4524
  }
@@ -4506,6 +4583,16 @@ async function initApp() {
4506
4583
  });
4507
4584
  });
4508
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
+ });
4509
4596
  const chatMenuArchiveBtn = document.getElementById('chat-menu-archive');
4510
4597
  bindMenuAction(chatMenuArchiveBtn, (event) => {
4511
4598
  event.preventDefault();
@@ -4536,10 +4623,35 @@ async function initApp() {
4536
4623
  }
4537
4624
  const copied = await copyTextToClipboard(shareUrl);
4538
4625
  showTransientRelayNotice(copied ? 'Share link copied to clipboard.' : shareUrl);
4626
+ void refreshShares().catch(() => {});
4539
4627
  }).catch((error) => {
4540
4628
  showTransientRelayNotice(error?.message || 'Could not create share link.');
4541
4629
  });
4542
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
+ });
4543
4655
  const chatMenuRefreshHistoryBtn = document.getElementById('chat-menu-refresh-history');
4544
4656
  if (chatMenuRefreshHistoryBtn && !chatMenuRefreshHistoryBtn.hidden && !chatMenuRefreshHistoryBtn.disabled) {
4545
4657
  bindMenuAction(chatMenuRefreshHistoryBtn, (event) => {
@@ -4592,6 +4704,14 @@ async function initApp() {
4592
4704
  closeChatActionsMenu();
4593
4705
  openSettingsModal();
4594
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
+ });
4595
4715
  const chatMenuRestartRelayBtn = document.getElementById('chat-menu-restart-relay');
4596
4716
  bindMenuAction(chatMenuRestartRelayBtn, (event) => {
4597
4717
  event.preventDefault();
@@ -4669,6 +4789,7 @@ async function initApp() {
4669
4789
  initBubbleActionHandlers();
4670
4790
  initMessageScrollPersistence();
4671
4791
  initTranscriptJumpToBottom();
4792
+ initSubagentViewer();
4672
4793
  syncChatTitleControls();
4673
4794
  await initSharedConversationReader();
4674
4795
  return;
@@ -4732,6 +4853,7 @@ async function initApp() {
4732
4853
  focusMessage: focusConversationMessageById,
4733
4854
  syncRenderedPinState,
4734
4855
  });
4856
+ initHiddenMessagesView({ syncRenderedShareVisibility });
4735
4857
  initGitChangesView();
4736
4858
  syncChatTitleControls();
4737
4859
  // A provider turned on or off (a Settings save, a catalog change) redraws
@@ -4795,7 +4917,9 @@ async function doAuth() {
4795
4917
  async function bootstrap() {
4796
4918
  if (ensureTrailingSlashPath()) return;
4797
4919
  const sharedMode = isSharedReaderMode();
4798
- 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) {
4799
4923
  navigator.serviceWorker.getRegistrations()
4800
4924
  .then((registrations) => Promise.all(registrations.map((registration) => registration.unregister().catch(() => false))))
4801
4925
  .catch(() => {});
@@ -4882,6 +5006,7 @@ window.saveGrokAllowanceSettings = saveGrokAllowanceSettings;
4882
5006
  window.resetGrokAllowanceAccounting = resetGrokAllowanceAccounting;
4883
5007
  window.toggleCursorProvider = toggleCursorProvider;
4884
5008
  window.updateShowSuspendHostSetting = updateShowSuspendHostSetting;
5009
+ window.updateScrollOnSendSetting = (enabled) => updateScrollOnSendSetting(enabled);
4885
5010
  window.updateWindowsAutostartMode = updateWindowsAutostartMode;
4886
5011
  window.previewTurnCeilingSetting = previewTurnCeilingSetting;
4887
5012
  window.updateTurnCeilingSetting = updateTurnCeilingSetting;
@@ -4936,6 +5061,8 @@ window.confirmNewConversationModel = confirmNewConversationModel;
4936
5061
  window.closeNewConversationModelModal = closeNewConversationModelModal;
4937
5062
  window.deleteConv = deleteConv;
4938
5063
  window.archiveConv = archiveConv;
5064
+ window.openConversationGroupPicker = openConversationGroupPicker;
5065
+ window.toggleConversationGroupFold = toggleConversationGroupFold;
4939
5066
  window.unarchiveConv = unarchiveConv;
4940
5067
  window.toggleArchivedConversations = toggleArchivedConversations;
4941
5068
  window.refreshNewConversationCloudRepoList = refreshNewConversationCloudRepoList;
@@ -4977,7 +5104,6 @@ window.setRelayQuestionMultiMode = setRelayQuestionMultiMode;
4977
5104
  window.handleRelayQuestionKey = handleRelayQuestionKey;
4978
5105
  window.openPendingQuestionFromBanner = openPendingQuestionFromBanner;
4979
5106
  window.submitRelayBoardAction = submitRelayBoardAction;
4980
- window.syncAgentExchangeFolds = syncAgentExchangeFoldsNow;
4981
5107
  window.compactCurrentConversation = compactCurrentConversation;
4982
5108
  window.sendMessage = sendMessage;
4983
5109
  window.syncComposerButtonState = syncComposerButtonState;
@@ -1,13 +1,14 @@
1
1
  // The header's ⋯ menu: one decision for its trigger and for every entry, with
2
2
  // or without a conversation open. No DOM here; bootstrap.js applies the result.
3
3
  //
4
- // The menu holds two kinds of entries. Relay-wide ones (Settings, Restart web
5
- // relay, Select Models, Check Usage, the queue, Day/Night, the PWA version)
6
- // are shown whenever the app is signed in, so Settings can be reached on a
7
- // fresh install, where no conversation exists yet. Per-conversation ones (Edit
8
- // title, Share, Archive, Inspect tmux, Change CWD, Git changes, Kill session,
9
- // Open on claude.ai) are hidden, not greyed, while nothing is open: an empty
10
- // screen offers what can be done there and nothing else.
4
+ // The menu holds two kinds of entries. Relay-wide ones (Settings, About OAR,
5
+ // Restart web relay, Select Models, Check Usage, the queue, Day/Night, the PWA
6
+ // version) are shown whenever the app is signed in, so Settings can be reached
7
+ // on a fresh install, where no conversation exists yet. Per-conversation ones
8
+ // (Edit title, Share, Stop sharing, Preview as shared, Archive, Inspect tmux,
9
+ // Change CWD, Git changes, Kill session, Open on claude.ai) are hidden, not
10
+ // greyed, while nothing is open: an empty screen offers what can be done there
11
+ // and nothing else.
11
12
  //
12
13
  // The shared read-only view is its own branch: Day/Night only, whatever the
13
14
  // conversation state, and the trigger stays usable even when the shared
@@ -26,11 +27,14 @@ export const CHAT_ACTIONS_MENU_ENTRY_IDS = Object.freeze([
26
27
  'chat-menu-pwa-version',
27
28
  'chat-menu-queue-status',
28
29
  'chat-menu-edit-title',
29
- 'chat-menu-shared-theme-toggle',
30
+ 'chat-menu-theme-toggle',
30
31
  'chat-menu-usage',
31
32
  'chat-menu-compact',
32
33
  'chat-menu-share-conversation',
34
+ 'chat-menu-stop-sharing',
35
+ 'chat-menu-preview-shared',
33
36
  'chat-menu-archive',
37
+ 'chat-menu-group',
34
38
  'chat-menu-refresh-history',
35
39
  'chat-menu-inspect-tmux',
36
40
  'chat-menu-select-models',
@@ -38,6 +42,7 @@ export const CHAT_ACTIONS_MENU_ENTRY_IDS = Object.freeze([
38
42
  'chat-menu-git-changes',
39
43
  'chat-menu-open-cloud-session',
40
44
  'chat-menu-settings',
45
+ 'chat-menu-about',
41
46
  'chat-menu-restart-relay',
42
47
  'chat-menu-suspend-host',
43
48
  'chat-menu-kill-session',
@@ -59,6 +64,7 @@ const hidden = () => ({ hidden: true, disabled: true });
59
64
  * @param {string|null} [input.cloudSessionUrl] its claude.ai session link, if reported
60
65
  * @param {boolean} [input.editing] its title is being edited
61
66
  * @param {boolean} [input.suspendVisible] the "show Suspend host" setting
67
+ * @param {boolean} [input.hasActiveShare] it has a public share link
62
68
  * @returns {{ trigger: { hidden: boolean, disabled: boolean }, entries: Record<string, { hidden: boolean, disabled: boolean }> }}
63
69
  */
64
70
  export function resolveChatActionsMenuState({
@@ -70,11 +76,12 @@ export function resolveChatActionsMenuState({
70
76
  cloudSessionUrl = null,
71
77
  editing = false,
72
78
  suspendVisible = false,
79
+ hasActiveShare = false,
73
80
  } = {}) {
74
81
  const entries = {};
75
82
  if (sharedMode) {
76
83
  for (const id of CHAT_ACTIONS_MENU_ENTRY_IDS) entries[id] = hidden();
77
- entries['chat-menu-shared-theme-toggle'] = shown();
84
+ entries['chat-menu-theme-toggle'] = shown();
78
85
  return { trigger: { hidden: false, disabled: false }, entries };
79
86
  }
80
87
 
@@ -87,10 +94,13 @@ export function resolveChatActionsMenuState({
87
94
 
88
95
  for (const id of CHAT_ACTIONS_MENU_ENTRY_IDS) entries[id] = shown();
89
96
  for (const id of ALWAYS_HIDDEN_IDS) entries[id] = hidden();
90
- entries['chat-menu-shared-theme-toggle'] = hidden();
91
97
  entries['chat-menu-edit-title'] = perConversation();
92
98
  entries['chat-menu-share-conversation'] = perConversation();
99
+ // Only a conversation with a live link has something to stop.
100
+ entries['chat-menu-stop-sharing'] = hasConversation && hasActiveShare ? shown() : hidden();
101
+ entries['chat-menu-preview-shared'] = perConversation();
93
102
  entries['chat-menu-archive'] = perConversation();
103
+ entries['chat-menu-group'] = perConversation();
94
104
  entries['chat-menu-inspect-tmux'] = perConversation();
95
105
  entries['chat-menu-git-changes'] = perConversation();
96
106
  // A cloud session has no working directory on the relay host to change.