@oar-sh/oar 0.9.10 → 0.9.12

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 (135) hide show
  1. package/CHANGELOG.md +440 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +112 -25
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +217 -11
  8. package/server/claude-worker/claude-session-worker.mjs +26 -1
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +68 -2
  15. package/server/grok-worker/acp-client.mjs +2 -1
  16. package/server/grok-worker/acp-host-services.mjs +46 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0013-previews.mjs +130 -0
  22. package/server/migrations/0014-conversation-groups.mjs +89 -0
  23. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  24. package/server/migrations/0016-share-access-log.mjs +110 -0
  25. package/server/migrations/0017-conversation-history-indexes.mjs +89 -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 +40 -101
  30. package/server/public/app/api-client.js +264 -16
  31. package/server/public/app/ask-user-view.js +17 -15
  32. package/server/public/app/background-tasks-view.mjs +285 -16
  33. package/server/public/app/bootstrap.js +253 -37
  34. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  35. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  36. package/server/public/app/context-menu.mjs +90 -6
  37. package/server/public/app/conversation-group-drag.mjs +191 -0
  38. package/server/public/app/conversation-group-order.mjs +265 -0
  39. package/server/public/app/conversation-group-state.mjs +100 -0
  40. package/server/public/app/conversation-list-drag.mjs +449 -0
  41. package/server/public/app/conversation-payload-cache.mjs +357 -0
  42. package/server/public/app/conversation-turn-state.mjs +81 -0
  43. package/server/public/app/conversation-view.js +567 -94
  44. package/server/public/app/conversation-window-retention.mjs +96 -0
  45. package/server/public/app/cwd-picker.js +1 -1
  46. package/server/public/app/hidden-messages-view.mjs +312 -0
  47. package/server/public/app/host-suspend-ui.js +7 -1
  48. package/server/public/app/journal-view.js +1033 -82
  49. package/server/public/app/preview-cards.mjs +117 -31
  50. package/server/public/app/preview-command.mjs +10 -1
  51. package/server/public/app/relay-board-view.js +19 -6
  52. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  53. package/server/public/app/relay-restart-error.mjs +16 -0
  54. package/server/public/app/settings-modal.js +150 -10
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/share-format.mjs +49 -0
  57. package/server/public/app/share-preview-marks.mjs +167 -0
  58. package/server/public/app/share-state.js +109 -0
  59. package/server/public/app/shared-conversations-settings.js +480 -0
  60. package/server/public/app/socket-handlers.js +138 -20
  61. package/server/public/app/store.js +121 -30
  62. package/server/public/app/subagent-row.mjs +3 -3
  63. package/server/public/app/subagent-viewer.mjs +37 -7
  64. package/server/public/app/task-console-render.mjs +237 -0
  65. package/server/public/app/task-console-watch.mjs +42 -0
  66. package/server/public/app/task-console.mjs +630 -0
  67. package/server/public/app/task-output-text.mjs +234 -0
  68. package/server/public/app/thinking-follow.js +0 -4
  69. package/server/public/app/transcript-island.mjs +73 -0
  70. package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
  71. package/server/public/app/transcript-jump-bottom.mjs +170 -22
  72. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  73. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  74. package/server/public/app/transcript-viewport-anchor.mjs +159 -0
  75. package/server/public/index.html +625 -21
  76. package/server/relay-tools.md +3 -1
  77. package/server/relay.mjs +2 -1
  78. package/server/repositories/conversation-group-repository.mjs +444 -0
  79. package/server/repositories/message-repository.mjs +9 -0
  80. package/server/repositories/question-repository.mjs +4 -0
  81. package/server/repositories/session-repository.mjs +12 -37
  82. package/server/repositories/share-repository.mjs +322 -0
  83. package/server/repositories/share-visibility-statements.mjs +10 -0
  84. package/server/routes/about-routes.mjs +15 -0
  85. package/server/routes/cache-routes.mjs +4 -0
  86. package/server/routes/conversation-group-routes.mjs +171 -0
  87. package/server/routes/messages-routes.mjs +433 -68
  88. package/server/routes/preview-routes.mjs +9 -1
  89. package/server/routes/sessions-routes.mjs +901 -253
  90. package/server/server-runtime.mjs +392 -99
  91. package/server/services/about-service.mjs +461 -0
  92. package/server/services/background-task-store.mjs +365 -0
  93. package/server/services/background-work-view.mjs +228 -0
  94. package/server/services/cache-rebuild-service.mjs +30 -0
  95. package/server/services/cli-process-runner.mjs +4 -21
  96. package/server/services/conversation-history-page.mjs +199 -0
  97. package/server/services/delete-archive-service.mjs +4 -1
  98. package/server/services/detached-worker-watch-service.mjs +150 -0
  99. package/server/services/hidden-message-list.mjs +21 -0
  100. package/server/services/host-suspend-command.mjs +2 -1
  101. package/server/services/message-pin-list.mjs +1 -1
  102. package/server/services/preview-health-probe.mjs +8 -1
  103. package/server/services/preview-proxy-server.mjs +8 -4
  104. package/server/services/preview-registry-service.mjs +676 -26
  105. package/server/services/preview-static-handler.mjs +40 -7
  106. package/server/services/preview-store.mjs +274 -0
  107. package/server/services/rate-limit-service.mjs +12 -1
  108. package/server/services/relay-auth.mjs +183 -0
  109. package/server/services/relay-cli-launcher-service.mjs +3 -1
  110. package/server/services/relay-event-service.mjs +17 -0
  111. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  112. package/server/services/remote-relay-dispatcher.mjs +187 -7
  113. package/server/services/request-boundary.mjs +183 -0
  114. package/server/services/session-activity-tracker.mjs +231 -19
  115. package/server/services/session-worker-launch-service.mjs +190 -6
  116. package/server/services/session-worker-process-service.mjs +10 -6
  117. package/server/services/share-access-log-service.mjs +123 -0
  118. package/server/services/share-path-redaction.mjs +424 -0
  119. package/server/services/status-event-service.mjs +7 -3
  120. package/server/services/task-output-rooms.mjs +259 -0
  121. package/server/services/task-output-service.mjs +562 -0
  122. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  123. package/server/services/windows-autostart-service.mjs +6 -1
  124. package/server/services/windows-boot-autostart-service.mjs +207 -23
  125. package/shared/claude-background-command-limit.mjs +83 -0
  126. package/shared/preview-tool-core.mjs +15 -5
  127. package/shared/remote-relay-contract.mjs +35 -3
  128. package/shared/share-path-privacy.mjs +30 -0
  129. package/shared/task-output-text.mjs +737 -0
  130. package/shared/windows-system-tools.mjs +106 -0
  131. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  132. package/shared/worker-runtime/heartbeat.mjs +24 -0
  133. package/shared/worker-runtime/kept-posts.mjs +83 -0
  134. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  135. 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,
@@ -42,18 +43,28 @@ import {
42
43
  loadConversationScrollTop,
43
44
  loadConversationLoadedMessageCount,
44
45
  saveConversationScrollTop,
46
+ loadConversationReadingAnchor,
47
+ saveConversationReadingAnchor,
45
48
  getSessionWorkerState,
46
49
  resolveConversationUiState,
47
50
  summaryModalState,
48
51
  IS_SHARED_VIEW,
52
+ IS_SHARE_PREVIEW,
49
53
  SHARED_CONVERSATION_TOKEN,
50
54
  getConversationWatcherCount,
51
55
  generateId,
52
56
  imageEditTarget,
53
57
  hasPendingUserMessageForConversation,
54
58
  } from './store.js';
55
- import { createTranscriptResizeKeeper } from './transcript-viewport-anchor.mjs';
56
- import { initTranscriptJumpToBottom } from './transcript-jump-bottom-view.mjs';
59
+ import {
60
+ captureReadingBlock,
61
+ createTranscriptResizeKeeper,
62
+ restoreReadingBlock,
63
+ } from './transcript-viewport-anchor.mjs';
64
+ import { initTranscriptJumpToBottom, onTranscriptEndLeft } from './transcript-jump-bottom-view.mjs';
65
+ import { syncScrollOnSendToggle, updateScrollOnSendSetting } from './transcript-jump-bottom.mjs';
66
+ import { previewWindowRetention, syncWindowRetentionSlider, updateWindowRetention } from './conversation-window-retention.mjs';
67
+ import { conversationPayloadCache, mergeReloadIntoWindow } from './conversation-payload-cache.mjs';
57
68
  import {
58
69
  verifyExistingSession,
59
70
  verifyToken,
@@ -92,6 +103,9 @@ import {
92
103
  isArchivedConversationsView,
93
104
  refreshNewConversationCloudRepoList,
94
105
  openOriginConversation,
106
+ openConversationGroupPicker,
107
+ toggleConversationGroupFold,
108
+ isConversationWorking,
95
109
  } from './journal-view.js';
96
110
  import {
97
111
  loadRelayQuestions,
@@ -126,13 +140,18 @@ import {
126
140
  conversationHasActiveTurn,
127
141
  focusConversationMessageById,
128
142
  syncRenderedPinState,
143
+ syncRenderedShareVisibility,
129
144
  isConversationWindowAtTail,
130
145
  getTranscriptJumpEpoch,
131
146
  getTranscriptLiveRowEpoch,
132
- syncAgentExchangeFoldsNow,
147
+ getHeldBackLiveRowIds,
148
+ captureConversationReadingAnchor,
149
+ canMergeIntoTranscript,
133
150
  } from './conversation-view.js';
134
151
  import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
152
+ import { newerPageReachesEnd } from './transcript-island.mjs';
135
153
  import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
154
+ import { initHiddenMessagesView, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
136
155
  import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
137
156
  import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
138
157
  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 +198,8 @@ import {
179
198
  closeMessageSearchModal,
180
199
  } from './message-search-view.js';
181
200
  import { initSubagentViewer } from './subagent-viewer.mjs';
201
+ import { initTaskConsole } from './task-console.mjs';
202
+ import { watchPreviewMarks } from './share-preview-marks.mjs';
182
203
  import {
183
204
  initGitChangesView,
184
205
  openGitChangesModal,
@@ -316,6 +337,8 @@ import {
316
337
  updateTurnCeilingSetting,
317
338
  previewBackgroundTaskTimeoutSetting,
318
339
  updateBackgroundTaskTimeoutSetting,
340
+ previewTaskStallMinutesSetting,
341
+ updateTaskStallMinutesSetting,
319
342
  updateSubagentRecordRetentionSetting,
320
343
  } from './settings-modal.js';
321
344
  import {
@@ -371,6 +394,9 @@ import {
371
394
  resolveChatActionsMenuState,
372
395
  } from './chat-actions-menu-state.mjs';
373
396
  import { subscribeProviderReadiness, updateProviderReadinessInputs } from './provider-readiness.mjs';
397
+ import { hasActiveShare, refreshShares, subscribeShares } from './share-state.js';
398
+ import { openStopSharingConfirmation } from './shared-conversations-settings.js';
399
+ import { showAbout, syncAboutMenuLabel } from './about-ui.js';
374
400
  import { syncConversationOriginHeader } from './conversation-origin-ui.js';
375
401
  import { loadRelayEventSubscriptions, onRelayEventSubscriptionsChanged, syncRelayEventHeader } from './relay-event-subscriptions-ui.js';
376
402
  import {
@@ -458,6 +484,8 @@ let liveConversationPollInFlight = false;
458
484
  let sharedViewerIdFallback = '';
459
485
  let sharedConversationRenderKey = '';
460
486
  let sharedConversationLastError = '';
487
+ // The share was stopped or renewed while the page was open: the reader stops.
488
+ let sharedConversationEnded = false;
461
489
  let networkLifecycleBound = false;
462
490
  let foregroundRecoveryTimer = null;
463
491
  let foregroundRecoveryInFlight = false;
@@ -876,14 +904,14 @@ function initNetworkLifecycleHandling() {
876
904
  }
877
905
 
878
906
  function syncThemeMenuLabel() {
879
- const button = document.getElementById('chat-menu-shared-theme-toggle');
907
+ const button = document.getElementById('chat-menu-theme-toggle');
880
908
  if (!button) return;
881
909
  const isLight = document.documentElement.getAttribute('data-theme') === 'light';
882
910
  button.textContent = isLight ? '🌙 Night mode' : '☀️ Day mode';
883
911
  }
884
912
 
885
913
  function initThemeMenuToggle() {
886
- const themeBtn = document.getElementById('chat-menu-shared-theme-toggle');
914
+ const themeBtn = document.getElementById('chat-menu-theme-toggle');
887
915
  if (!themeBtn) return;
888
916
  themeBtn.hidden = false;
889
917
  syncThemeMenuLabel();
@@ -940,7 +968,7 @@ function applySharedReaderUi() {
940
968
  const modeSelect = document.getElementById('mode-select');
941
969
  const chatMenuBtn = document.getElementById('chat-actions-menu-btn');
942
970
  const chatMenu = document.getElementById('chat-actions-menu');
943
- const sharedThemeBtn = document.getElementById('chat-menu-shared-theme-toggle');
971
+ const themeBtn = document.getElementById('chat-menu-theme-toggle');
944
972
  const installBtn = document.getElementById('install-btn');
945
973
  const activeElement = document.activeElement;
946
974
  if (chatMenu && activeElement instanceof Element && chatMenu.contains(activeElement)) {
@@ -957,7 +985,7 @@ function applySharedReaderUi() {
957
985
  if (input) {
958
986
  input.readOnly = true;
959
987
  input.disabled = true;
960
- input.placeholder = 'Shared conversation (read-only)';
988
+ input.placeholder = IS_SHARE_PREVIEW ? 'Preview as shared (read-only)' : 'Shared conversation (read-only)';
961
989
  }
962
990
  disableSharedControl(sendBtn);
963
991
  disableSharedControl(attachBtn);
@@ -973,19 +1001,19 @@ function applySharedReaderUi() {
973
1001
  // trigger usable). The loop below stays as a guard for entries the resolver
974
1002
  // does not know.
975
1003
  applyChatActionsMenuState(resolveChatActionsMenuState({ sharedMode: true }));
976
- if (chatMenu && sharedThemeBtn) {
1004
+ if (chatMenu && themeBtn) {
977
1005
  for (const child of Array.from(chatMenu.children)) {
978
- if (child !== sharedThemeBtn) hideSharedMenuEntry(child);
1006
+ if (child !== themeBtn) hideSharedMenuEntry(child);
979
1007
  }
980
- sharedThemeBtn.hidden = false;
981
- sharedThemeBtn.removeAttribute('aria-hidden');
982
- sharedThemeBtn.disabled = false;
983
- sharedThemeBtn.tabIndex = 0;
1008
+ themeBtn.hidden = false;
1009
+ themeBtn.removeAttribute('aria-hidden');
1010
+ themeBtn.disabled = false;
1011
+ themeBtn.tabIndex = 0;
984
1012
  syncThemeMenuLabel();
985
1013
  } else if (chatMenuBtn) {
986
1014
  chatMenuBtn.hidden = true;
987
1015
  }
988
- if (chatMenuBtn && !sharedThemeBtn) chatMenuBtn.hidden = true;
1016
+ if (chatMenuBtn && !themeBtn) chatMenuBtn.hidden = true;
989
1017
  }
990
1018
 
991
1019
  function syncChatTitleWatcherIndicator() {
@@ -1032,25 +1060,42 @@ function resolveStableScrollTopForLiveRefresh(messagesEl, capturedScrollTop) {
1032
1060
  function resolveLiveRefreshAtBottom(messagesEl, capturedScrollTop, capturedAtBottom) {
1033
1061
  const current = Number(messagesEl?.scrollTop);
1034
1062
  if (!Number.isFinite(current) || Math.abs(current - Number(capturedScrollTop)) <= 2) return capturedAtBottom;
1035
- return isMessagesAtBottom();
1063
+ return isMessagesNearBottom();
1064
+ }
1065
+
1066
+ // A stopped or renewed share answers 404. The page stops polling at once and
1067
+ // keeps nothing of the conversation on screen; one neutral line stays.
1068
+ function endSharedConversationReader() {
1069
+ if (sharedConversationEnded) return;
1070
+ sharedConversationEnded = true;
1071
+ stopSharedModeTimers();
1072
+ document.getElementById('messages')?.replaceChildren();
1073
+ const titleEl = document.getElementById('chat-title');
1074
+ if (titleEl) titleEl.textContent = 'Shared conversation';
1075
+ sharedConversationLastError = '';
1076
+ setModelBanner('This shared conversation is not available.');
1036
1077
  }
1037
1078
 
1038
- async function refreshSharedConversation() {
1039
- if (sharedConversationPollInFlight) return;
1079
+ async function refreshSharedConversation({ open = false } = {}) {
1080
+ if (sharedConversationPollInFlight || sharedConversationEnded) return;
1040
1081
  const sharedToken = resolveSharedTokenFromLocation();
1041
- if (!sharedToken) return;
1082
+ if (!sharedToken && !IS_SHARE_PREVIEW) return;
1042
1083
  sharedConversationPollInFlight = true;
1043
1084
  const requestSeq = ++sharedConversationRequestSeq;
1044
1085
  const messagesEl = document.getElementById('messages');
1045
- const wasAtBottom = isMessagesAtBottom();
1086
+ const wasAtBottom = isMessagesNearBottom();
1046
1087
  const savedScrollTop = messagesEl?.scrollTop || 0;
1047
1088
  try {
1048
1089
  // The reader has no socket today, so nothing moves the count; the guard
1049
1090
  // keeps a rebuild from dropping a live row should one ever arrive.
1050
1091
  const { response, stale } = await loadKeepingLiveRows(
1051
- () => loadSharedConversation(sharedToken, { limit: 120 }),
1092
+ () => loadSharedConversation(sharedToken, { limit: 120, open }),
1052
1093
  getTranscriptLiveRowEpoch,
1053
1094
  );
1095
+ if (response?.status === 404) {
1096
+ endSharedConversationReader();
1097
+ return;
1098
+ }
1054
1099
  if (requestSeq < sharedConversationAppliedSeq) return;
1055
1100
  if (stale) return;
1056
1101
  if (!response?.ok) {
@@ -1101,15 +1146,29 @@ async function refreshSharedConversation() {
1101
1146
 
1102
1147
  async function pulseSharedViewerPresence() {
1103
1148
  const sharedToken = resolveSharedTokenFromLocation();
1104
- if (!sharedToken) return;
1149
+ if (!sharedToken || sharedConversationEnded) return;
1105
1150
  await reportSharedViewerPresence(sharedToken, sharedViewerId()).catch(() => {});
1106
1151
  }
1107
1152
 
1153
+ // "Preview as shared": the owner's own page, so it says what it is and what
1154
+ // the highlights mean; the marks come with the preview payload.
1155
+ function initSharePreviewUi() {
1156
+ if (!IS_SHARE_PREVIEW) return;
1157
+ document.body.classList.add('share-preview-mode');
1158
+ const banner = document.getElementById('share-preview-banner');
1159
+ if (banner) {
1160
+ 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.';
1161
+ }
1162
+ watchPreviewMarks(document.body);
1163
+ }
1164
+
1108
1165
  async function initSharedConversationReader() {
1109
1166
  applySharedReaderUi();
1110
- await refreshSharedConversation();
1167
+ initSharePreviewUi();
1168
+ await refreshSharedConversation({ open: true });
1111
1169
  await pulseSharedViewerPresence();
1112
1170
  stopSharedModeTimers();
1171
+ if (sharedConversationEnded) return;
1113
1172
  sharedConversationPollTimer = setInterval(() => {
1114
1173
  if (!shouldRunForegroundNetworkWork()) return;
1115
1174
  void refreshSharedConversation();
@@ -1126,6 +1185,12 @@ function showAuthError(msg) {
1126
1185
 
1127
1186
  function resolveAuthErrorMessage(result = null) {
1128
1187
  if (result?.status === 401) return 'Invalid token';
1188
+ if (result?.status === 429) {
1189
+ const seconds = Number(result?.payload?.retryAfterSeconds);
1190
+ return Number.isFinite(seconds) && seconds > 0
1191
+ ? `Too many failed sign-in attempts. Try again in ${Math.ceil(seconds)} seconds.`
1192
+ : 'Too many failed sign-in attempts. Try again in a minute.';
1193
+ }
1129
1194
  if (result?.status === 403) return 'Access denied';
1130
1195
  if (result?.status > 0) return `Authentication failed (${result.status})`;
1131
1196
  return String(result?.error || 'Could not reach the relay').trim() || 'Could not reach the relay';
@@ -3650,6 +3715,23 @@ function transcriptIsInHistory(conversationId) {
3650
3715
  return !isConversationWindowAtTail() && !hasPendingUserMessageForConversation(conversationId);
3651
3716
  }
3652
3717
 
3718
+ // A reload of the end of the conversation on screen, when the page holds
3719
+ // more rows than the answer can (the relay caps a page at 100): the answer
3720
+ // is merged into the window (mergeReloadIntoWindow) rather than rendered,
3721
+ // which would cut the page and its kept window down to the answer. Null
3722
+ // means the plain render, as before: nothing to merge, or a transcript that
3723
+ // cannot take a merge (a bubble waiting for its send, the status view, a
3724
+ // held selection, a row missing from the page). Both callers merge only an
3725
+ // answer no live row overtook (loadKeepingLiveRows not stale), so the window
3726
+ // predates it: a newer row the answer lacks was deleted (a cancelled
3727
+ // follow-up) and leaves the page.
3728
+ function reloadMergeFor(conversationId, response) {
3729
+ if (IS_SHARED_VIEW) return null;
3730
+ const merge = mergeReloadIntoWindow(conversationPayloadCache.peekWindow(conversationId), response, { windowPredatesPage: true });
3731
+ if (!merge || !canMergeIntoTranscript(merge.mergeFrom, conversationId)) return null;
3732
+ return merge;
3733
+ }
3734
+
3653
3735
  async function pollAuthenticatedCurrentConversationLive() {
3654
3736
  if (liveConversationPollInFlight) return;
3655
3737
  if (isSharedReaderMode()) return;
@@ -3672,7 +3754,7 @@ async function pollAuthenticatedCurrentConversationLive() {
3672
3754
  liveConversationPollInFlight = true;
3673
3755
  try {
3674
3756
  const messagesEl = document.getElementById('messages');
3675
- const wasAtBottom = isMessagesAtBottom();
3757
+ const wasAtBottom = isMessagesNearBottom();
3676
3758
  const savedScrollTop = messagesEl?.scrollTop || 0;
3677
3759
  const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
3678
3760
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
@@ -3692,11 +3774,20 @@ async function pollAuthenticatedCurrentConversationLive() {
3692
3774
  if (stale) return;
3693
3775
  const preserveBottom = resolveLiveRefreshAtBottom(messagesEl, savedScrollTop, wasAtBottom);
3694
3776
  const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, savedScrollTop);
3695
- applyLoadedConversationState(currentId, response, {
3777
+ // The rows are rebuilt: a reader who is not at the end keeps the text
3778
+ // they read where it is, whatever the rows above it measure now.
3779
+ const reading = preserveBottom ? null : captureReadingBlock(messagesEl);
3780
+ const merge = reloadMergeFor(currentId, response);
3781
+ applyLoadedConversationState(currentId, merge?.payload || response, {
3696
3782
  restoreScroll: !preserveBottom,
3697
3783
  savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
3698
3784
  followLiveUpdates: preserveBottom,
3785
+ mergeFrom: merge?.mergeFrom || null,
3699
3786
  });
3787
+ // A merge does not scroll as the full render does: a reader at the end
3788
+ // is taken to the new end here.
3789
+ if (merge && preserveBottom && messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
3790
+ if (reading) restoreReadingBlock(messagesEl, reading);
3700
3791
  } finally {
3701
3792
  liveConversationPollInFlight = false;
3702
3793
  }
@@ -3713,6 +3804,7 @@ function startLiveConversationPolling() {
3713
3804
  const transcriptResizeKeeper = createTranscriptResizeKeeper({
3714
3805
  getElement: () => document.getElementById('messages'),
3715
3806
  });
3807
+ onTranscriptEndLeft(() => transcriptResizeKeeper.recordEndLeft());
3716
3808
 
3717
3809
  function setupViewportTracking() {
3718
3810
  syncViewportMetrics();
@@ -3830,6 +3922,21 @@ function initComposerAttachmentInput() {
3830
3922
  window.addEventListener('dragend', clearHighlight);
3831
3923
  }
3832
3924
 
3925
+ // The place read is noted once per frame: it measures rows, a scroll event
3926
+ // can come many times a frame. It is noted for the conversation whose rows
3927
+ // are on the page, and only while that is the one open: during a switch the
3928
+ // page still shows the conversation left.
3929
+ let readingAnchorSaveFrame = 0;
3930
+ function scheduleReadingAnchorSave() {
3931
+ if (readingAnchorSaveFrame || IS_SHARED_VIEW) return;
3932
+ readingAnchorSaveFrame = requestAnimationFrame(() => {
3933
+ readingAnchorSaveFrame = 0;
3934
+ const captured = captureConversationReadingAnchor();
3935
+ if (!captured || captured.conversationId !== String(currentConvId || '').trim()) return;
3936
+ saveConversationReadingAnchor(captured.conversationId, captured.anchor);
3937
+ });
3938
+ }
3939
+
3833
3940
  function initMessageScrollPersistence() {
3834
3941
  const el = document.getElementById('messages');
3835
3942
  if (!el || el.dataset.scrollPersistenceBound === '1') return;
@@ -3839,19 +3946,43 @@ function initMessageScrollPersistence() {
3839
3946
  const convId = String(currentConvId || '').trim();
3840
3947
  if (!convId) return;
3841
3948
  saveConversationScrollTop(convId, el.scrollTop);
3949
+ scheduleReadingAnchorSave();
3842
3950
  }, { passive: true });
3843
3951
  }
3844
3952
 
3845
- // The jump-to-bottom button on a window in the middle of the history: the
3846
- // end of the conversation replaces it, scrolled to the bottom.
3953
+ // The end's answer can predate a row held back below the window while it
3954
+ // was on its way (stored after the request read the conversation), and its
3955
+ // render lets the held rows go. The newest row held is the one to look for:
3956
+ // older ones can lie before a page of the end and are not missing.
3957
+ function endAnswerLacksHeldBackRow(response) {
3958
+ const newestHeld = getHeldBackLiveRowIds().slice(-1);
3959
+ if (!newestHeld.length) return false;
3960
+ return !newerPageReachesEnd({
3961
+ pageHasMore: false,
3962
+ pageMessageIds: (Array.isArray(response?.messages) ? response.messages : []).map((message) => message?.id),
3963
+ heldBackIds: newestHeld,
3964
+ });
3965
+ }
3966
+
3967
+ // The jump-to-bottom button on a window in the middle of the history (and an
3968
+ // own send from one): the end of the conversation replaces it, scrolled to
3969
+ // the bottom.
3847
3970
  async function loadCurrentConversationEnd() {
3848
3971
  const currentId = String(currentConvId || '').trim();
3849
3972
  if (!currentId) return;
3850
3973
  const jumpEpoch = getTranscriptJumpEpoch();
3851
- const response = await loadConversation(currentId, { limit: 20 });
3974
+ const load = () => loadConversation(currentId, { limit: 20 });
3975
+ let response = await load();
3852
3976
  if (!response || String(currentConvId || '').trim() !== currentId) return;
3853
3977
  // A jump into the history while it was on its way wins.
3854
3978
  if (getTranscriptJumpEpoch() !== jumpEpoch) return;
3979
+ // Asked once more, not until it has them: a held row deleted since would
3980
+ // never come.
3981
+ if (endAnswerLacksHeldBackRow(response)) {
3982
+ const again = await load();
3983
+ if (String(currentConvId || '').trim() !== currentId || getTranscriptJumpEpoch() !== jumpEpoch) return;
3984
+ if (again) response = again;
3985
+ }
3855
3986
  applyLoadedConversationState(currentId, response, { restoreScroll: false });
3856
3987
  }
3857
3988
 
@@ -3913,7 +4044,7 @@ async function refreshCurrentView() {
3913
4044
  const capturedVersion = ++refreshViewVersion;
3914
4045
  const jumpEpoch = getTranscriptJumpEpoch();
3915
4046
  const messagesEl = document.getElementById('messages');
3916
- const wasAtBottom = isMessagesAtBottom();
4047
+ const wasAtBottom = isMessagesNearBottom();
3917
4048
  const scrollTop = messagesEl?.scrollTop || 0;
3918
4049
 
3919
4050
  await refreshConversations();
@@ -3943,13 +4074,23 @@ async function refreshCurrentView() {
3943
4074
  const keepTranscript = getTranscriptJumpEpoch() !== jumpEpoch || transcriptIsInHistory(currentId) || stale;
3944
4075
  const preserveBottom = resolveLiveRefreshAtBottom(messagesEl, scrollTop, wasAtBottom);
3945
4076
  const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, scrollTop);
4077
+ // As in the live poll: the text the reader is at stays where it is.
4078
+ const reading = preserveBottom || keepTranscript ? null : captureReadingBlock(messagesEl);
4079
+ let restoredAt = null;
3946
4080
  if (r) {
3947
- applyLoadedConversationState(currentId, r, {
4081
+ const merge = keepTranscript ? null : reloadMergeFor(currentId, r);
4082
+ applyLoadedConversationState(currentId, merge?.payload || r, {
3948
4083
  restoreScroll: !preserveBottom,
3949
4084
  savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
3950
4085
  followLiveUpdates: preserveBottom && !keepTranscript,
3951
4086
  keepTranscript,
4087
+ mergeFrom: merge?.mergeFrom || null,
3952
4088
  });
4089
+ // As in the live poll: a merge leaves the scrolling to the caller.
4090
+ if (merge && preserveBottom && messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
4091
+ // At once, and again below once the cards are back on the page.
4092
+ restoreReadingBlock(messagesEl, reading);
4093
+ restoredAt = { scrollTop: messagesEl?.scrollTop };
3953
4094
  } else {
3954
4095
  setRepoBrowserSessionInfo('', '');
3955
4096
  restoreInFlightThinking(null);
@@ -3958,7 +4099,12 @@ async function refreshCurrentView() {
3958
4099
  await loadRelayBoards();
3959
4100
  scheduleContextUsageRefresh(currentId, 0);
3960
4101
  if (messagesEl && !keepTranscript && String(currentConvId || '').trim() === currentId) {
3961
- if (!preserveBottom && Number.isFinite(stableSavedScrollTop) && stableSavedScrollTop >= 0) {
4102
+ // Two requests later: a reader who has scrolled, pressed the jump
4103
+ // button or sent something meanwhile is where they want to be.
4104
+ const readerMoved = !restoredAt
4105
+ || Math.abs(Number(messagesEl.scrollTop) - Number(restoredAt.scrollTop)) > 2;
4106
+ if (!preserveBottom && !readerMoved && !restoreReadingBlock(messagesEl, reading)
4107
+ && Number.isFinite(stableSavedScrollTop) && stableSavedScrollTop >= 0) {
3962
4108
  messagesEl.scrollTop = stableSavedScrollTop;
3963
4109
  }
3964
4110
  saveConversationScrollTop(currentId, messagesEl.scrollTop);
@@ -4078,11 +4224,15 @@ function syncChatActionsMenuState() {
4078
4224
  cloudSessionUrl: isCloud ? buildCloudLineModel(conversation?.cloud)?.sessionUrl || null : null,
4079
4225
  editing: !!convId && chatTitleEditingConversationId === convId,
4080
4226
  suspendVisible: isSuspendHostActionVisible(),
4227
+ hasActiveShare: hasActiveShare(convId),
4081
4228
  });
4082
4229
  applyChatActionsMenuState(state);
4083
4230
  return state;
4084
4231
  }
4085
4232
 
4233
+ // The menu follows the relay's share list (made, renewed, stopped anywhere).
4234
+ subscribeShares(() => syncChatActionsMenuState());
4235
+
4086
4236
  function toggleChatActionsMenu() {
4087
4237
  const menu = document.getElementById('chat-actions-menu');
4088
4238
  const trigger = document.getElementById('chat-actions-menu-btn');
@@ -4092,6 +4242,7 @@ function toggleChatActionsMenu() {
4092
4242
  if (trigger.hidden || trigger.disabled) return;
4093
4243
  syncRefreshHistoryMenuState();
4094
4244
  syncThemeMenuLabel();
4245
+ syncAboutMenuLabel();
4095
4246
  const willOpen = !!menu.hidden;
4096
4247
  menu.hidden = !willOpen;
4097
4248
  trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
@@ -4170,13 +4321,17 @@ function lockChatActionsMenuShield(ms = 300) {
4170
4321
  function syncChatTitleControls() {
4171
4322
  const { title, editor, input } = getChatTitleElements();
4172
4323
  const convId = String(currentConvId || '').trim();
4173
- // No conversation open (deleted, new chat): its 📍 button goes with it.
4174
- if (!convId) setPinsConversation(null);
4324
+ // No conversation open (deleted, new chat): its 📍 and 🙈 buttons go with it.
4325
+ if (!convId) {
4326
+ setPinsConversation(null);
4327
+ setHiddenMessagesConversation(null);
4328
+ }
4175
4329
  const conversation = convId ? (conversations[convId] || null) : null;
4176
4330
  const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
4177
4331
  if (title) {
4178
4332
  if (!convId || !conversation) {
4179
4333
  delete title.dataset.uiState;
4334
+ delete title.dataset.working;
4180
4335
  } else {
4181
4336
  const pendingByConversation = getPendingQuestionCountsByConversation();
4182
4337
  const pendingCount = Number(pendingByConversation[convId] || 0);
@@ -4187,6 +4342,8 @@ function syncChatTitleControls() {
4187
4342
  hasPendingQuestion: pendingCount > 0,
4188
4343
  });
4189
4344
  title.dataset.uiState = uiState;
4345
+ // Bright green while it works, the darker green while idle, as its row.
4346
+ title.toggleAttribute('data-working', isConversationWorking(conversation));
4190
4347
  }
4191
4348
  }
4192
4349
  if (chatTitleEditingConversationId && chatTitleEditingConversationId !== convId) {
@@ -4429,19 +4586,20 @@ async function initApp() {
4429
4586
  appSharedMode = sharedMode;
4430
4587
  initNetworkLifecycleHandling();
4431
4588
  setForegroundNetworkWorkEnabled(document.visibilityState === 'visible');
4432
- const sharedThemeBtn = document.getElementById('chat-menu-shared-theme-toggle');
4433
4589
  initTheme();
4434
4590
  initScrollbarHover();
4435
4591
  initThemeMenuToggle();
4436
- if (sharedThemeBtn && sharedMode) sharedThemeBtn.hidden = false;
4437
4592
  initFontScaling();
4438
4593
  if (!sharedMode) {
4439
4594
  clearLegacyKnownCwdHistoryStorage();
4440
4595
  }
4441
4596
  syncPwaVersionMenuEntry();
4442
4597
  syncQueueStatusMenuEntry();
4598
+ if (!sharedMode) syncAboutMenuLabel();
4443
4599
  if (!sharedMode) {
4444
4600
  syncSuspendHostVisibility();
4601
+ syncScrollOnSendToggle();
4602
+ syncWindowRetentionSlider();
4445
4603
  initHostSuspendUi();
4446
4604
  initUsageLimitUi();
4447
4605
  }
@@ -4506,6 +4664,16 @@ async function initApp() {
4506
4664
  });
4507
4665
  });
4508
4666
  }
4667
+ // "Session group…": the picker for the open conversation, reachable on
4668
+ // the phone with the drawer closed.
4669
+ bindMenuAction(document.getElementById('chat-menu-group'), (event) => {
4670
+ event.preventDefault();
4671
+ event.stopPropagation();
4672
+ lockChatActionsMenuShield(350);
4673
+ closeChatActionsMenu();
4674
+ const convId = String(currentConvId || '').trim();
4675
+ if (convId) openConversationGroupPicker(convId);
4676
+ });
4509
4677
  const chatMenuArchiveBtn = document.getElementById('chat-menu-archive');
4510
4678
  bindMenuAction(chatMenuArchiveBtn, (event) => {
4511
4679
  event.preventDefault();
@@ -4536,10 +4704,35 @@ async function initApp() {
4536
4704
  }
4537
4705
  const copied = await copyTextToClipboard(shareUrl);
4538
4706
  showTransientRelayNotice(copied ? 'Share link copied to clipboard.' : shareUrl);
4707
+ void refreshShares().catch(() => {});
4539
4708
  }).catch((error) => {
4540
4709
  showTransientRelayNotice(error?.message || 'Could not create share link.');
4541
4710
  });
4542
4711
  });
4712
+ // "Stop sharing": only shown while the open conversation has a live link.
4713
+ bindMenuAction(document.getElementById('chat-menu-stop-sharing'), (event) => {
4714
+ event.preventDefault();
4715
+ event.stopPropagation();
4716
+ lockChatActionsMenuShield(350);
4717
+ closeChatActionsMenu();
4718
+ const convId = String(currentConvId || '').trim();
4719
+ if (!convId) return;
4720
+ openStopSharingConfirmation(convId, conversations[convId]?.title || '');
4721
+ });
4722
+ const chatMenuPreviewSharedBtn = document.getElementById('chat-menu-preview-shared');
4723
+ bindMenuAction(chatMenuPreviewSharedBtn, (event) => {
4724
+ event.preventDefault();
4725
+ event.stopPropagation();
4726
+ lockChatActionsMenuShield(350);
4727
+ closeChatActionsMenu();
4728
+ const convId = String(currentConvId || '').trim();
4729
+ if (!convId) {
4730
+ showTransientRelayNotice('Select a conversation first.');
4731
+ return;
4732
+ }
4733
+ // Never creates a share link: the preview reads through the owner's route.
4734
+ window.open(`${BASE}/share-preview/${encodeURIComponent(convId)}`, '_blank', 'noopener');
4735
+ });
4543
4736
  const chatMenuRefreshHistoryBtn = document.getElementById('chat-menu-refresh-history');
4544
4737
  if (chatMenuRefreshHistoryBtn && !chatMenuRefreshHistoryBtn.hidden && !chatMenuRefreshHistoryBtn.disabled) {
4545
4738
  bindMenuAction(chatMenuRefreshHistoryBtn, (event) => {
@@ -4592,6 +4785,14 @@ async function initApp() {
4592
4785
  closeChatActionsMenu();
4593
4786
  openSettingsModal();
4594
4787
  });
4788
+ const chatMenuAboutBtn = document.getElementById('chat-menu-about');
4789
+ bindMenuAction(chatMenuAboutBtn, (event) => {
4790
+ event.preventDefault();
4791
+ event.stopPropagation();
4792
+ lockChatActionsMenuShield(350);
4793
+ closeChatActionsMenu();
4794
+ void showAbout();
4795
+ });
4595
4796
  const chatMenuRestartRelayBtn = document.getElementById('chat-menu-restart-relay');
4596
4797
  bindMenuAction(chatMenuRestartRelayBtn, (event) => {
4597
4798
  event.preventDefault();
@@ -4669,6 +4870,7 @@ async function initApp() {
4669
4870
  initBubbleActionHandlers();
4670
4871
  initMessageScrollPersistence();
4671
4872
  initTranscriptJumpToBottom();
4873
+ initSubagentViewer();
4672
4874
  syncChatTitleControls();
4673
4875
  await initSharedConversationReader();
4674
4876
  return;
@@ -4727,11 +4929,13 @@ async function initApp() {
4727
4929
  initComposerAttachmentInput();
4728
4930
  initMessageSearchView({ openConversation });
4729
4931
  initSubagentViewer();
4932
+ initTaskConsole();
4730
4933
  initPinnedMessagesView({
4731
4934
  openConversation,
4732
4935
  focusMessage: focusConversationMessageById,
4733
4936
  syncRenderedPinState,
4734
4937
  });
4938
+ initHiddenMessagesView({ syncRenderedShareVisibility });
4735
4939
  initGitChangesView();
4736
4940
  syncChatTitleControls();
4737
4941
  // A provider turned on or off (a Settings save, a catalog change) redraws
@@ -4795,7 +4999,9 @@ async function doAuth() {
4795
4999
  async function bootstrap() {
4796
5000
  if (ensureTrailingSlashPath()) return;
4797
5001
  const sharedMode = isSharedReaderMode();
4798
- if (sharedMode && navigator.serviceWorker?.getRegistrations) {
5002
+ // The preview runs in the owner's own browser: their app's service worker
5003
+ // (and its push subscription) stays.
5004
+ if (sharedMode && !IS_SHARE_PREVIEW && navigator.serviceWorker?.getRegistrations) {
4799
5005
  navigator.serviceWorker.getRegistrations()
4800
5006
  .then((registrations) => Promise.all(registrations.map((registration) => registration.unregister().catch(() => false))))
4801
5007
  .catch(() => {});
@@ -4882,11 +5088,20 @@ window.saveGrokAllowanceSettings = saveGrokAllowanceSettings;
4882
5088
  window.resetGrokAllowanceAccounting = resetGrokAllowanceAccounting;
4883
5089
  window.toggleCursorProvider = toggleCursorProvider;
4884
5090
  window.updateShowSuspendHostSetting = updateShowSuspendHostSetting;
5091
+ window.updateScrollOnSendSetting = (enabled) => updateScrollOnSendSetting(enabled);
5092
+ window.previewConversationWindowRetention = (position) => previewWindowRetention(position);
5093
+ // A lower cap cuts the kept windows down now; the open conversation's is cut
5094
+ // when it is left.
5095
+ window.updateConversationWindowRetention = (position) => updateWindowRetention(position, {
5096
+ onChange: (cap) => conversationPayloadCache.trimAll(cap, loadConversationReadingAnchor, { except: currentConvId }),
5097
+ });
4885
5098
  window.updateWindowsAutostartMode = updateWindowsAutostartMode;
4886
5099
  window.previewTurnCeilingSetting = previewTurnCeilingSetting;
4887
5100
  window.updateTurnCeilingSetting = updateTurnCeilingSetting;
4888
5101
  window.previewBackgroundTaskTimeoutSetting = previewBackgroundTaskTimeoutSetting;
4889
5102
  window.updateBackgroundTaskTimeoutSetting = updateBackgroundTaskTimeoutSetting;
5103
+ window.previewTaskStallMinutesSetting = previewTaskStallMinutesSetting;
5104
+ window.updateTaskStallMinutesSetting = updateTaskStallMinutesSetting;
4890
5105
  window.updateSubagentRecordRetentionSetting = updateSubagentRecordRetentionSetting;
4891
5106
  window.togglePushOnThisDevice = togglePushOnThisDevice;
4892
5107
  window.updatePushPreferencesFromControls = updatePushPreferencesFromControls;
@@ -4936,6 +5151,8 @@ window.confirmNewConversationModel = confirmNewConversationModel;
4936
5151
  window.closeNewConversationModelModal = closeNewConversationModelModal;
4937
5152
  window.deleteConv = deleteConv;
4938
5153
  window.archiveConv = archiveConv;
5154
+ window.openConversationGroupPicker = openConversationGroupPicker;
5155
+ window.toggleConversationGroupFold = toggleConversationGroupFold;
4939
5156
  window.unarchiveConv = unarchiveConv;
4940
5157
  window.toggleArchivedConversations = toggleArchivedConversations;
4941
5158
  window.refreshNewConversationCloudRepoList = refreshNewConversationCloudRepoList;
@@ -4977,7 +5194,6 @@ window.setRelayQuestionMultiMode = setRelayQuestionMultiMode;
4977
5194
  window.handleRelayQuestionKey = handleRelayQuestionKey;
4978
5195
  window.openPendingQuestionFromBanner = openPendingQuestionFromBanner;
4979
5196
  window.submitRelayBoardAction = submitRelayBoardAction;
4980
- window.syncAgentExchangeFolds = syncAgentExchangeFoldsNow;
4981
5197
  window.compactCurrentConversation = compactCurrentConversation;
4982
5198
  window.sendMessage = sendMessage;
4983
5199
  window.syncComposerButtonState = syncComposerButtonState;