@oar-sh/oar 0.9.8 → 0.9.10

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 (107) hide show
  1. package/CHANGELOG.md +478 -0
  2. package/README.md +129 -39
  3. package/bin/bat/oar.bat +46 -46
  4. package/bin/oar.js +814 -228
  5. package/package.json +5 -6
  6. package/server/README.md +306 -4
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  8. package/server/claude-worker/claude-session-process.mjs +35 -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 +3 -1
  13. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  14. package/server/db-schema.mjs +44 -0
  15. package/server/migrations/0008-message-pins.mjs +85 -0
  16. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  17. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  18. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  19. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  20. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  21. package/server/public/app/api-client.js +91 -0
  22. package/server/public/app/archive-confirm.mjs +28 -0
  23. package/server/public/app/ask-user-view.js +3 -0
  24. package/server/public/app/attachments-view.js +1024 -131
  25. package/server/public/app/back-guard.mjs +58 -0
  26. package/server/public/app/background-tasks-view.mjs +18 -26
  27. package/server/public/app/bootstrap.js +230 -35
  28. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  29. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  30. package/server/public/app/composer-control-state.mjs +16 -0
  31. package/server/public/app/composer-keyboard.mjs +54 -0
  32. package/server/public/app/conversation-view.js +471 -110
  33. package/server/public/app/explorer-edit-state.mjs +68 -0
  34. package/server/public/app/external-link-policy.mjs +4 -1
  35. package/server/public/app/file-version.mjs +58 -0
  36. package/server/public/app/host-suspend-ui.js +6 -3
  37. package/server/public/app/journal-view.js +161 -27
  38. package/server/public/app/no-conversation-state.mjs +58 -0
  39. package/server/public/app/pinned-messages-view.mjs +253 -0
  40. package/server/public/app/provider-readiness.mjs +120 -0
  41. package/server/public/app/pwa-install.js +1 -1
  42. package/server/public/app/relay-board-view.js +3 -0
  43. package/server/public/app/relay-dot-state.mjs +8 -1
  44. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  45. package/server/public/app/relay-event-view.mjs +234 -0
  46. package/server/public/app/router.js +8 -2
  47. package/server/public/app/settings-modal.js +53 -1
  48. package/server/public/app/socket-handlers.js +65 -28
  49. package/server/public/app/store.js +25 -2
  50. package/server/public/app/subagent-row.mjs +138 -0
  51. package/server/public/app/subagent-timeline-view.mjs +186 -0
  52. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  53. package/server/public/app/subagent-viewer.mjs +534 -0
  54. package/server/public/app/task-row-format.mjs +29 -0
  55. package/server/public/app/thinking-follow.js +4 -0
  56. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  57. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  58. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  59. package/server/public/app/update-ui.js +1 -1
  60. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  61. package/server/public/app/viewer-controls-state.mjs +59 -0
  62. package/server/public/app/viewer-gallery.mjs +46 -0
  63. package/server/public/app/viewer-gestures.mjs +105 -0
  64. package/server/public/app/viewer-history.mjs +45 -0
  65. package/server/public/app/viewer-tap.mjs +112 -0
  66. package/server/public/index.html +700 -59
  67. package/server/repositories/message-pin-statements.mjs +39 -0
  68. package/server/repositories/message-repository.mjs +34 -3
  69. package/server/repositories/question-repository.mjs +9 -3
  70. package/server/repositories/remote-relay-repository.mjs +6 -2
  71. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  72. package/server/routes/ask-user-routes.mjs +3 -0
  73. package/server/routes/messages-routes.mjs +548 -111
  74. package/server/routes/relay-event-routes.mjs +157 -0
  75. package/server/routes/remote-relay-routes.mjs +15 -0
  76. package/server/routes/sessions-routes.mjs +164 -24
  77. package/server/server-runtime.mjs +274 -62
  78. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  79. package/server/services/explorer-entry-name.mjs +26 -0
  80. package/server/services/explorer-mutations.mjs +122 -0
  81. package/server/services/file-serving.mjs +155 -0
  82. package/server/services/message-pin-list.mjs +52 -0
  83. package/server/services/oar-cli-helpers.mjs +139 -4
  84. package/server/services/oar-cli-menu.mjs +240 -0
  85. package/server/services/oar-cli-wsl.mjs +129 -0
  86. package/server/services/preview-proxy-server.mjs +41 -23
  87. package/server/services/push-dispatch-service.mjs +18 -0
  88. package/server/services/relay-event-link-service.mjs +289 -0
  89. package/server/services/relay-event-service.mjs +1935 -0
  90. package/server/services/relay-shutdown-gate.mjs +31 -0
  91. package/server/services/remote-relay-caller-context.mjs +52 -7
  92. package/server/services/remote-relay-dispatcher.mjs +494 -2
  93. package/server/services/remote-relay-registry-service.mjs +11 -1
  94. package/server/services/safe-served-content.mjs +61 -11
  95. package/server/services/session-activity-tracker.mjs +487 -0
  96. package/server/services/session-history-refresh-service.mjs +31 -0
  97. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  98. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  99. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  100. package/server/services/subagent-record-retention.mjs +164 -0
  101. package/server/services/subagent-timeline-service.mjs +328 -0
  102. package/server/services/subagent-viewer-rooms.mjs +119 -0
  103. package/server/services/upgrade-socket-guard.mjs +16 -0
  104. package/shared/relay-events.mjs +371 -0
  105. package/shared/remote-relay-contract.mjs +157 -5
  106. package/shared/subagent-record-retention.mjs +31 -0
  107. package/shared/subagent-timeline.mjs +79 -0
@@ -53,6 +53,7 @@ import {
53
53
  hasPendingUserMessageForConversation,
54
54
  } from './store.js';
55
55
  import { createTranscriptResizeKeeper } from './transcript-viewport-anchor.mjs';
56
+ import { initTranscriptJumpToBottom } from './transcript-jump-bottom-view.mjs';
56
57
  import {
57
58
  verifyExistingSession,
58
59
  verifyToken,
@@ -103,6 +104,7 @@ import {
103
104
  renderMessages,
104
105
  appendMessage,
105
106
  compactCurrentConversation,
107
+ renderNoConversationState,
106
108
  sendMessage,
107
109
  handleKey,
108
110
  updateComposerSlashMenu,
@@ -122,10 +124,18 @@ import {
122
124
  jumpToImageParent,
123
125
  flushDeferredMessageRender,
124
126
  conversationHasActiveTurn,
127
+ focusConversationMessageById,
128
+ syncRenderedPinState,
129
+ isConversationWindowAtTail,
130
+ getTranscriptJumpEpoch,
131
+ getTranscriptLiveRowEpoch,
132
+ syncAgentExchangeFoldsNow,
125
133
  } from './conversation-view.js';
134
+ import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
135
+ import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
126
136
  import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
127
137
  import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
128
- 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';
138
+ 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';
129
139
  import { initEmojiPicker, toggleEmojiPicker } from './emoji-view.js';
130
140
  import { initSlashAutocomplete } from './slash-autocomplete.mjs';
131
141
  import { initMentionAutocomplete } from './mention-autocomplete.mjs';
@@ -168,6 +178,7 @@ import {
168
178
  openMessageSearchModal,
169
179
  closeMessageSearchModal,
170
180
  } from './message-search-view.js';
181
+ import { initSubagentViewer } from './subagent-viewer.mjs';
171
182
  import {
172
183
  initGitChangesView,
173
184
  openGitChangesModal,
@@ -198,7 +209,9 @@ import {
198
209
  adoptLegacyPwaAppName,
199
210
  registerPwaShell,
200
211
  updatePwaAppName,
212
+ isInstalledAppMode,
201
213
  } from './pwa-install.js';
214
+ import { armBackGuardOnFirstInteraction } from './back-guard.mjs';
202
215
  import { initScrollbarHover } from './scrollbar-hover.mjs';
203
216
  import {
204
217
  autoCompactWindowNotes,
@@ -267,6 +280,7 @@ import {
267
280
  updateTheme,
268
281
  openSettingsModal,
269
282
  closeSettingsModal,
283
+ isSuspendHostActionVisible,
270
284
  syncSuspendHostVisibility,
271
285
  updateShowSuspendHostSetting,
272
286
  syncDefaultSessionWorkspaceRootInput,
@@ -302,6 +316,7 @@ import {
302
316
  updateTurnCeilingSetting,
303
317
  previewBackgroundTaskTimeoutSetting,
304
318
  updateBackgroundTaskTimeoutSetting,
319
+ updateSubagentRecordRetentionSetting,
305
320
  } from './settings-modal.js';
306
321
  import {
307
322
  togglePushOnThisDevice,
@@ -349,14 +364,21 @@ import {
349
364
  subscribeClaudeCloudSettings,
350
365
  toggleClaudeCloudProvider,
351
366
  } from './claude-cloud-settings-ui.js';
352
- import { openCurrentCloudSession, syncClaudeCloudConversationUi } from './claude-cloud-conversation-ui.js';
367
+ import { isCurrentConversationClaudeCloud, openCurrentCloudSession, syncClaudeCloudConversationUi } from './claude-cloud-conversation-ui.js';
368
+ import {
369
+ CHAT_ACTIONS_MENU_ENTRY_IDS,
370
+ CHAT_ACTIONS_MENU_META_IDS,
371
+ resolveChatActionsMenuState,
372
+ } from './chat-actions-menu-state.mjs';
373
+ import { subscribeProviderReadiness, updateProviderReadinessInputs } from './provider-readiness.mjs';
353
374
  import { syncConversationOriginHeader } from './conversation-origin-ui.js';
375
+ import { loadRelayEventSubscriptions, onRelayEventSubscriptionsChanged, syncRelayEventHeader } from './relay-event-subscriptions-ui.js';
354
376
  import {
355
377
  previewAgentSessionsMaxWait,
356
378
  saveAgentSessionsMaxWait,
357
379
  toggleAgentSessions,
358
380
  } from './agent-sessions-settings-ui.js';
359
- import { CLAUDE_CLOUD_PROVIDER, claudeCloudComposerModelIds } from './claude-cloud-ui.mjs';
381
+ import { CLAUDE_CLOUD_PROVIDER, buildCloudLineModel, claudeCloudComposerModelIds } from './claude-cloud-ui.mjs';
360
382
  import {
361
383
  confirmCliInstall,
362
384
  runCliInstall,
@@ -370,6 +392,7 @@ import {
370
392
  dismissAvailableUpdate,
371
393
  dismissUpdateOutcome,
372
394
  } from './update-ui.js';
395
+ import { shouldAutofocusComposerOnLoad } from './composer-keyboard.mjs';
373
396
 
374
397
  const MODEL_STORAGE_KEY = 'copilot_selected_model';
375
398
  // The New Chat modal used to keep its own model key, so a selection made there
@@ -946,6 +969,10 @@ function applySharedReaderUi() {
946
969
  if (modelSelect) modelSelect.hidden = true;
947
970
  if (effortSelect) effortSelect.hidden = true;
948
971
  if (modeSelect) modeSelect.hidden = true;
972
+ // One source for the menu: the resolver's shared branch (Day/Night only,
973
+ // trigger usable). The loop below stays as a guard for entries the resolver
974
+ // does not know.
975
+ applyChatActionsMenuState(resolveChatActionsMenuState({ sharedMode: true }));
949
976
  if (chatMenu && sharedThemeBtn) {
950
977
  for (const child of Array.from(chatMenu.children)) {
951
978
  if (child !== sharedThemeBtn) hideSharedMenuEntry(child);
@@ -999,6 +1026,15 @@ function resolveStableScrollTopForLiveRefresh(messagesEl, capturedScrollTop) {
999
1026
  return captured;
1000
1027
  }
1001
1028
 
1029
+ // Whether a reload keeps the reader at the end: as it was when the reload was
1030
+ // asked for, unless they have moved while it was on its way (scrolled up, or
1031
+ // jumped back to the end); then as it is now.
1032
+ function resolveLiveRefreshAtBottom(messagesEl, capturedScrollTop, capturedAtBottom) {
1033
+ const current = Number(messagesEl?.scrollTop);
1034
+ if (!Number.isFinite(current) || Math.abs(current - Number(capturedScrollTop)) <= 2) return capturedAtBottom;
1035
+ return isMessagesAtBottom();
1036
+ }
1037
+
1002
1038
  async function refreshSharedConversation() {
1003
1039
  if (sharedConversationPollInFlight) return;
1004
1040
  const sharedToken = resolveSharedTokenFromLocation();
@@ -1006,11 +1042,17 @@ async function refreshSharedConversation() {
1006
1042
  sharedConversationPollInFlight = true;
1007
1043
  const requestSeq = ++sharedConversationRequestSeq;
1008
1044
  const messagesEl = document.getElementById('messages');
1009
- const preserveBottom = isMessagesAtBottom();
1045
+ const wasAtBottom = isMessagesAtBottom();
1010
1046
  const savedScrollTop = messagesEl?.scrollTop || 0;
1011
1047
  try {
1012
- const response = await loadSharedConversation(sharedToken, { limit: 120 });
1048
+ // The reader has no socket today, so nothing moves the count; the guard
1049
+ // keeps a rebuild from dropping a live row should one ever arrive.
1050
+ const { response, stale } = await loadKeepingLiveRows(
1051
+ () => loadSharedConversation(sharedToken, { limit: 120 }),
1052
+ getTranscriptLiveRowEpoch,
1053
+ );
1013
1054
  if (requestSeq < sharedConversationAppliedSeq) return;
1055
+ if (stale) return;
1014
1056
  if (!response?.ok) {
1015
1057
  const message = String(response?.error || 'Could not load shared conversation.').trim();
1016
1058
  if (message && message !== sharedConversationLastError) {
@@ -1043,6 +1085,7 @@ async function refreshSharedConversation() {
1043
1085
  return;
1044
1086
  }
1045
1087
  sharedConversationRenderKey = renderKey;
1088
+ const preserveBottom = resolveLiveRefreshAtBottom(messagesEl, savedScrollTop, wasAtBottom);
1046
1089
  const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, savedScrollTop);
1047
1090
  applyLoadedConversationState(convId, response, {
1048
1091
  restoreScroll: !preserveBottom,
@@ -1375,6 +1418,8 @@ function syncComposerPlaceholder() {
1375
1418
  if (!input) return;
1376
1419
  if (isSharedReaderMode()) return;
1377
1420
  if (imageEditTarget) return;
1421
+ // The composer is locked until a provider is ready; its text says why.
1422
+ if (input.dataset.setupLocked === '1') return;
1378
1423
  const rawValue = String(document.getElementById('model-select')?.value || '');
1379
1424
  const { baseModelId } = splitModelVariantId(rawValue);
1380
1425
  input.placeholder = composerPlaceholderFor({
@@ -1609,6 +1654,8 @@ function updateModelCatalogState(payload) {
1609
1654
  // Shared readers never load the catalog, so this would only surface
1610
1655
  // "metadata unavailable" warnings for a picker they cannot see.
1611
1656
  if (isSharedReaderMode()) return;
1657
+ // The catalog's `source` is the Copilot readiness signal (provider-readiness.mjs).
1658
+ updateProviderReadinessInputs({ catalog: payload && typeof payload === 'object' ? payload : null });
1612
1659
  const select = document.getElementById('model-select');
1613
1660
  if (!select) return;
1614
1661
  const models = Array.isArray(payload?.models)
@@ -2173,6 +2220,8 @@ function initModeSelector() {
2173
2220
  function refreshModelCatalog(force = false) {
2174
2221
  return loadModelCatalog().then((r) => {
2175
2222
  if (!r) {
2223
+ // A failed load leaves provider readiness unknown: no setup wall on a blip.
2224
+ updateProviderReadinessInputs({ catalog: null });
2176
2225
  if (isModelMetadataHealthy(modelCatalogState) || isModelMetadataHealthy(lastHealthyModelCatalogState)) {
2177
2226
  setModelBanner('⚠️ Could not refresh model metadata; using last known good catalog.');
2178
2227
  syncModelMetadataBlocker();
@@ -3592,6 +3641,15 @@ function startSessionWorkerStatusPolling() {
3592
3641
  }, 4000);
3593
3642
  }
3594
3643
 
3644
+ // The live poll and refreshCurrentView both reload the END of the
3645
+ // conversation. After a jump into the history (a search result, a pinned
3646
+ // message) that would put the end back over the window the user asked for,
3647
+ // until they have scrolled down to the newest message again. A message sent
3648
+ // from that window brings the present back at once: its bubble needs the poll.
3649
+ function transcriptIsInHistory(conversationId) {
3650
+ return !isConversationWindowAtTail() && !hasPendingUserMessageForConversation(conversationId);
3651
+ }
3652
+
3595
3653
  async function pollAuthenticatedCurrentConversationLive() {
3596
3654
  if (liveConversationPollInFlight) return;
3597
3655
  if (isSharedReaderMode()) return;
@@ -3609,17 +3667,30 @@ async function pollAuthenticatedCurrentConversationLive() {
3609
3667
  // Refreshing while the user selects or drags in the chat would rebuild the
3610
3668
  // DOM under the selection; the guard's release callback re-polls right away.
3611
3669
  if (isChatInteractionHeld()) return;
3670
+ if (transcriptIsInHistory(currentId)) return;
3612
3671
 
3613
3672
  liveConversationPollInFlight = true;
3614
3673
  try {
3615
3674
  const messagesEl = document.getElementById('messages');
3616
- const preserveBottom = isMessagesAtBottom();
3675
+ const wasAtBottom = isMessagesAtBottom();
3617
3676
  const savedScrollTop = messagesEl?.scrollTop || 0;
3618
3677
  const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
3619
3678
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
3620
- const response = await loadConversation(currentId, { limit: requestLimit });
3679
+ const jumpEpoch = getTranscriptJumpEpoch();
3680
+ const { response, stale } = await loadKeepingLiveRows(
3681
+ () => loadConversation(currentId, { limit: requestLimit }),
3682
+ getTranscriptLiveRowEpoch,
3683
+ );
3621
3684
  if (!response) return;
3622
3685
  if (String(currentConvId || '').trim() !== currentId) return;
3686
+ // The user jumped into the history while this request was on its way: the
3687
+ // answer is the end of the conversation as it was asked for before that,
3688
+ // and would overwrite the window they jumped to. The next round asks again.
3689
+ if (getTranscriptJumpEpoch() !== jumpEpoch) return;
3690
+ // Rows kept arriving through every request: the page has them, the answer
3691
+ // does not. The next round asks again.
3692
+ if (stale) return;
3693
+ const preserveBottom = resolveLiveRefreshAtBottom(messagesEl, savedScrollTop, wasAtBottom);
3623
3694
  const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, savedScrollTop);
3624
3695
  applyLoadedConversationState(currentId, response, {
3625
3696
  restoreScroll: !preserveBottom,
@@ -3771,6 +3842,19 @@ function initMessageScrollPersistence() {
3771
3842
  }, { passive: true });
3772
3843
  }
3773
3844
 
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.
3847
+ async function loadCurrentConversationEnd() {
3848
+ const currentId = String(currentConvId || '').trim();
3849
+ if (!currentId) return;
3850
+ const jumpEpoch = getTranscriptJumpEpoch();
3851
+ const response = await loadConversation(currentId, { limit: 20 });
3852
+ if (!response || String(currentConvId || '').trim() !== currentId) return;
3853
+ // A jump into the history while it was on its way wins.
3854
+ if (getTranscriptJumpEpoch() !== jumpEpoch) return;
3855
+ applyLoadedConversationState(currentId, response, { restoreScroll: false });
3856
+ }
3857
+
3774
3858
  async function runConversationHistoryRefresh({ source = 'menu' } = {}) {
3775
3859
  const currentId = String(currentConvId || '').trim();
3776
3860
  if (!currentId) {
@@ -3827,10 +3911,10 @@ let refreshViewVersion = 0;
3827
3911
 
3828
3912
  async function refreshCurrentView() {
3829
3913
  const capturedVersion = ++refreshViewVersion;
3914
+ const jumpEpoch = getTranscriptJumpEpoch();
3830
3915
  const messagesEl = document.getElementById('messages');
3831
- const preserveBottom = isMessagesAtBottom();
3916
+ const wasAtBottom = isMessagesAtBottom();
3832
3917
  const scrollTop = messagesEl?.scrollTop || 0;
3833
- const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, scrollTop);
3834
3918
 
3835
3919
  await refreshConversations();
3836
3920
 
@@ -3846,14 +3930,25 @@ async function refreshCurrentView() {
3846
3930
 
3847
3931
  const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
3848
3932
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
3849
- const r = await loadConversation(currentId, { limit: requestLimit });
3933
+ const { response: r, stale } = await loadKeepingLiveRows(
3934
+ () => loadConversation(currentId, { limit: requestLimit }),
3935
+ getTranscriptLiveRowEpoch,
3936
+ );
3850
3937
  if (capturedVersion < refreshViewVersion) return;
3851
3938
  if (String(currentConvId || '').trim() !== currentId) return;
3939
+ // The user jumped into the history, before this refresh or while it was on
3940
+ // its way: title, tasks, pins and the rest are refreshed, the transcript
3941
+ // and its scroll position are left as they are. So too when rows kept
3942
+ // arriving through every request: the page has rows the answer lacks.
3943
+ const keepTranscript = getTranscriptJumpEpoch() !== jumpEpoch || transcriptIsInHistory(currentId) || stale;
3944
+ const preserveBottom = resolveLiveRefreshAtBottom(messagesEl, scrollTop, wasAtBottom);
3945
+ const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, scrollTop);
3852
3946
  if (r) {
3853
3947
  applyLoadedConversationState(currentId, r, {
3854
3948
  restoreScroll: !preserveBottom,
3855
3949
  savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
3856
- followLiveUpdates: preserveBottom,
3950
+ followLiveUpdates: preserveBottom && !keepTranscript,
3951
+ keepTranscript,
3857
3952
  });
3858
3953
  } else {
3859
3954
  setRepoBrowserSessionInfo('', '');
@@ -3862,7 +3957,7 @@ async function refreshCurrentView() {
3862
3957
  await loadRelayQuestions(currentId);
3863
3958
  await loadRelayBoards();
3864
3959
  scheduleContextUsageRefresh(currentId, 0);
3865
- if (messagesEl && String(currentConvId || '').trim() === currentId) {
3960
+ if (messagesEl && !keepTranscript && String(currentConvId || '').trim() === currentId) {
3866
3961
  if (!preserveBottom && Number.isFinite(stableSavedScrollTop) && stableSavedScrollTop >= 0) {
3867
3962
  messagesEl.scrollTop = stableSavedScrollTop;
3868
3963
  }
@@ -3923,7 +4018,7 @@ function getChatTitleElements() {
3923
4018
  return {
3924
4019
  wrap: document.getElementById('chat-title-wrap'),
3925
4020
  title: document.getElementById('chat-title'),
3926
- editBtn: document.getElementById('chat-actions-menu-btn'),
4021
+ menuBtn: document.getElementById('chat-actions-menu-btn'),
3927
4022
  editor: document.getElementById('chat-title-editor'),
3928
4023
  input: document.getElementById('chat-title-input'),
3929
4024
  saveBtn: document.getElementById('chat-title-save-btn'),
@@ -3940,10 +4035,61 @@ function closeChatActionsMenu() {
3940
4035
  if (backdrop && !window.__chatActionsMenuShieldTimer) backdrop.classList.remove('visible');
3941
4036
  }
3942
4037
 
4038
+ // Writes one resolver decision to the DOM, the way the shared view hides its
4039
+ // entries: `hidden` alone for the text chips, plus `disabled`, `aria-hidden`
4040
+ // and the tab order for the buttons.
4041
+ function applyChatActionsMenuState(state) {
4042
+ const trigger = document.getElementById('chat-actions-menu-btn');
4043
+ if (trigger && state?.trigger) {
4044
+ trigger.hidden = !!state.trigger.hidden;
4045
+ trigger.disabled = !!state.trigger.disabled;
4046
+ }
4047
+ const entries = state?.entries || {};
4048
+ for (const id of CHAT_ACTIONS_MENU_ENTRY_IDS) {
4049
+ const element = document.getElementById(id);
4050
+ const decision = entries[id];
4051
+ if (!element || !decision) continue;
4052
+ element.hidden = !!decision.hidden;
4053
+ if (CHAT_ACTIONS_MENU_META_IDS.includes(id)) continue;
4054
+ if (decision.hidden) {
4055
+ element.setAttribute('aria-hidden', 'true');
4056
+ if ('disabled' in element) element.disabled = true;
4057
+ if ('tabIndex' in element) element.tabIndex = -1;
4058
+ continue;
4059
+ }
4060
+ element.removeAttribute('aria-hidden');
4061
+ if ('disabled' in element) element.disabled = !!decision.disabled;
4062
+ if ('tabIndex' in element) element.tabIndex = 0;
4063
+ }
4064
+ }
4065
+
4066
+ // The ⋯ menu as it should look right now: the trigger and every entry,
4067
+ // decided in chat-actions-menu-state.mjs from the conversation on screen.
4068
+ function syncChatActionsMenuState() {
4069
+ const convId = String(currentConvId || '').trim();
4070
+ const conversation = convId ? (conversations[convId] || null) : null;
4071
+ const isCloud = !!convId && isCurrentConversationClaudeCloud();
4072
+ const state = resolveChatActionsMenuState({
4073
+ sharedMode: isSharedReaderMode(),
4074
+ conversationId: convId,
4075
+ conversation,
4076
+ sdkSessionId: String(conversation?.sdkSessionId || '').trim(),
4077
+ isCloud,
4078
+ cloudSessionUrl: isCloud ? buildCloudLineModel(conversation?.cloud)?.sessionUrl || null : null,
4079
+ editing: !!convId && chatTitleEditingConversationId === convId,
4080
+ suspendVisible: isSuspendHostActionVisible(),
4081
+ });
4082
+ applyChatActionsMenuState(state);
4083
+ return state;
4084
+ }
4085
+
3943
4086
  function toggleChatActionsMenu() {
3944
4087
  const menu = document.getElementById('chat-actions-menu');
3945
4088
  const trigger = document.getElementById('chat-actions-menu-btn');
3946
- if (!menu || !trigger || trigger.hidden || trigger.disabled) return;
4089
+ if (!menu || !trigger) return;
4090
+ // The entries match the moment of the tap, as Refresh history already does.
4091
+ syncChatActionsMenuState();
4092
+ if (trigger.hidden || trigger.disabled) return;
3947
4093
  syncRefreshHistoryMenuState();
3948
4094
  syncThemeMenuLabel();
3949
4095
  const willOpen = !!menu.hidden;
@@ -4022,10 +4168,10 @@ function lockChatActionsMenuShield(ms = 300) {
4022
4168
 
4023
4169
 
4024
4170
  function syncChatTitleControls() {
4025
- const { title, editBtn, editor, input } = getChatTitleElements();
4171
+ const { title, editor, input } = getChatTitleElements();
4026
4172
  const convId = String(currentConvId || '').trim();
4027
- const killBtn = document.getElementById('chat-menu-kill-session');
4028
- const sharedMode = isSharedReaderMode();
4173
+ // No conversation open (deleted, new chat): its 📍 button goes with it.
4174
+ if (!convId) setPinsConversation(null);
4029
4175
  const conversation = convId ? (conversations[convId] || null) : null;
4030
4176
  const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
4031
4177
  if (title) {
@@ -4049,17 +4195,11 @@ function syncChatTitleControls() {
4049
4195
  const editing = convId && chatTitleEditingConversationId === convId;
4050
4196
  document.body.classList.toggle('chat-title-editing', !!editing);
4051
4197
  if (title) title.hidden = editing;
4052
- if (editBtn) {
4053
- editBtn.hidden = !convId;
4054
- editBtn.disabled = !convId || editing;
4055
- }
4056
- if (killBtn) {
4057
- killBtn.disabled = sharedMode || !convId || !sdkSessionId;
4058
- killBtn.hidden = sharedMode || !convId;
4059
- }
4198
+ // The ⋯ trigger and its entries: relay-wide ones stay with no conversation
4199
+ // open, so Settings is always one tap away.
4200
+ syncChatActionsMenuState();
4060
4201
  const archiveBtn = document.getElementById('chat-menu-archive');
4061
4202
  if (archiveBtn) {
4062
- archiveBtn.hidden = sharedMode || !convId;
4063
4203
  archiveBtn.textContent = conversation?.archived === true ? '📂 Unarchive conversation' : '🗄 Archive conversation';
4064
4204
  }
4065
4205
  if (editor) {
@@ -4082,19 +4222,21 @@ function syncChatTitleControls() {
4082
4222
  syncClaudeCloudConversationUi();
4083
4223
  // "via agent": the conversation an agent on this relay started this one from.
4084
4224
  syncConversationOriginHeader();
4225
+ // Relay events: "watching N sessions" on a watcher, "watched" on a watched one.
4226
+ syncRelayEventHeader();
4085
4227
  syncChatTitleWatcherIndicator();
4086
4228
  }
4087
4229
 
4088
4230
  function openChatTitleEditor() {
4089
4231
  const convId = String(currentConvId || '').trim();
4090
4232
  if (!convId) return;
4091
- const { title, editBtn, editor, input } = getChatTitleElements();
4233
+ const { title, menuBtn, editor, input } = getChatTitleElements();
4092
4234
  closeChatActionsMenu();
4093
4235
  const currentTitle = String(conversations[convId]?.title || title?.textContent || convId).trim() || convId;
4094
4236
  chatTitleEditingConversationId = convId;
4095
4237
  document.body.classList.add('chat-title-editing');
4096
4238
  if (title) title.hidden = true;
4097
- if (editBtn) editBtn.disabled = true;
4239
+ if (menuBtn) menuBtn.disabled = true;
4098
4240
  if (editor) editor.hidden = false;
4099
4241
  if (input) {
4100
4242
  input.maxLength = CHAT_TITLE_MAX_LENGTH;
@@ -4113,11 +4255,11 @@ function openChatTitleEditor() {
4113
4255
  function closeChatTitleEditor() {
4114
4256
  chatTitleEditingConversationId = null;
4115
4257
  document.body.classList.remove('chat-title-editing');
4116
- const { title, editBtn, editor, input } = getChatTitleElements();
4258
+ const { title, editor, input } = getChatTitleElements();
4117
4259
  closeChatActionsMenu();
4118
4260
  if (editor) editor.hidden = true;
4119
4261
  if (title) title.hidden = !String(currentConvId || '').trim();
4120
- if (editBtn) editBtn.disabled = !String(currentConvId || '').trim();
4262
+ // The trigger is decided by the syncChatTitleControls() call below.
4121
4263
  if (input) {
4122
4264
  const convId = String(currentConvId || '').trim();
4123
4265
  input.value = convId ? String(conversations[convId]?.title || title?.textContent || convId) : '';
@@ -4526,6 +4668,7 @@ async function initApp() {
4526
4668
  initConversationHistoryLazyLoading();
4527
4669
  initBubbleActionHandlers();
4528
4670
  initMessageScrollPersistence();
4671
+ initTranscriptJumpToBottom();
4529
4672
  syncChatTitleControls();
4530
4673
  await initSharedConversationReader();
4531
4674
  return;
@@ -4548,14 +4691,25 @@ async function initApp() {
4548
4691
  const status = await refreshWorkspaceRootHints();
4549
4692
  syncQueueStatusMenuEntry(status);
4550
4693
  setSessionWorkerStatesFromStatusPayload(status?.sessionWorker || null);
4551
- await refreshOpenAISettingsState();
4552
- await refreshCursorSettingsState();
4553
- await refreshGrokSettingsState();
4694
+ const openAISettings = await refreshOpenAISettingsState();
4695
+ const cursorSettings = await refreshCursorSettingsState();
4696
+ const grokSettings = await refreshGrokSettingsState();
4697
+ // Loaded here for provider readiness: whether any provider can run a chat
4698
+ // decides what the empty screen, New Chat and the composer offer.
4699
+ const claudeSettings = await refreshClaudeSettingsState();
4700
+ updateProviderReadinessInputs({
4701
+ openai: openAISettings || null,
4702
+ cursor: cursorSettings || null,
4703
+ grok: grokSettings || null,
4704
+ claude: claudeSettings || null,
4705
+ });
4554
4706
  // Not awaited: only a Claude Cloud conversation needs it (its model list),
4555
4707
  // and the subscription below rebuilds that list when the answer arrives. A
4556
4708
  // relay without the provider answers nothing and nothing changes.
4557
4709
  subscribeClaudeCloudSettings(refreshClaudeCloudComposerModels);
4558
- void refreshClaudeCloudSettingsState();
4710
+ void refreshClaudeCloudSettingsState().then((state) => {
4711
+ updateProviderReadinessInputs({ claudeCloud: state || null });
4712
+ });
4559
4713
  await refreshModelCatalog(true);
4560
4714
  initFullscreenButton();
4561
4715
  initInstallButton();
@@ -4569,10 +4723,24 @@ async function initApp() {
4569
4723
  initConversationHistoryLazyLoading();
4570
4724
  initBubbleActionHandlers();
4571
4725
  initMessageScrollPersistence();
4726
+ initTranscriptJumpToBottom({ loadEnd: loadCurrentConversationEnd });
4572
4727
  initComposerAttachmentInput();
4573
4728
  initMessageSearchView({ openConversation });
4729
+ initSubagentViewer();
4730
+ initPinnedMessagesView({
4731
+ openConversation,
4732
+ focusMessage: focusConversationMessageById,
4733
+ syncRenderedPinState,
4734
+ });
4574
4735
  initGitChangesView();
4575
4736
  syncChatTitleControls();
4737
+ // A provider turned on or off (a Settings save, a catalog change) redraws
4738
+ // the empty screen and the composer without a reload.
4739
+ subscribeProviderReadiness(() => {
4740
+ renderNoConversationState();
4741
+ syncComposerButtonState();
4742
+ syncComposerPlaceholder();
4743
+ });
4576
4744
  connectSocket();
4577
4745
  startRelayConnectionWatchdog();
4578
4746
  startDeviceVisibilityHeartbeat();
@@ -4584,6 +4752,9 @@ async function initApp() {
4584
4752
  // Browsers without Background Sync replay the outbox from the page instead.
4585
4753
  void initOutboxFallbackReplay();
4586
4754
  await loadConversations();
4755
+ // The sidebar's "watched" badges follow the subscriptions.
4756
+ onRelayEventSubscriptionsChanged(() => renderConvList());
4757
+ void loadRelayEventSubscriptions();
4587
4758
  const pushConversationId = consumePushConversationDeepLink();
4588
4759
  if (pushConversationId) {
4589
4760
  await openConversation(pushConversationId).catch(() => {});
@@ -4591,7 +4762,19 @@ async function initApp() {
4591
4762
  await loadRelayQuestions(currentConvId);
4592
4763
  await loadRelayBoards();
4593
4764
  updateCompactButton();
4594
- document.getElementById('msg-input').focus();
4765
+ // Nothing opened (fresh client, fresh relay, a last conversation that is
4766
+ // gone): the static markup gives way to the real empty screen.
4767
+ renderNoConversationState();
4768
+ syncComposerButtonState();
4769
+ syncComposerPlaceholder();
4770
+ // A phone shows no keyboard for this focus, but the next tap that keeps it
4771
+ // (the transcript's ⌄) would raise one.
4772
+ if (shouldAutofocusComposerOnLoad({
4773
+ coarsePointer: !!window.matchMedia?.('(pointer: coarse)')?.matches,
4774
+ phoneLayout: isMobileComposerViewport(),
4775
+ })) {
4776
+ document.getElementById('msg-input').focus();
4777
+ }
4595
4778
  }
4596
4779
 
4597
4780
  async function doAuth() {
@@ -4627,6 +4810,15 @@ async function bootstrap() {
4627
4810
  }
4628
4811
  const urlToken = getTokenFromUrl();
4629
4812
  if (urlToken) stripTokenFromUrl();
4813
+ // In the installed app the system back button must never close the app;
4814
+ // a tab keeps its own back button. After the token is gone from the
4815
+ // address, so the entries the guard lays down carry the clean one.
4816
+ armBackGuardOnFirstInteraction({
4817
+ history: window.history,
4818
+ enabled: isInstalledAppMode(),
4819
+ target: window,
4820
+ onFloor: () => showTransientRelayNotice('Press back again to close the app', 2500),
4821
+ });
4630
4822
  const persistedToken = consumeLegacyPersistedAuthToken();
4631
4823
  const bootstrapToken = String(urlToken || persistedToken || '').trim();
4632
4824
  const existingSession = await verifyExistingSession(bootstrapToken);
@@ -4695,6 +4887,7 @@ window.previewTurnCeilingSetting = previewTurnCeilingSetting;
4695
4887
  window.updateTurnCeilingSetting = updateTurnCeilingSetting;
4696
4888
  window.previewBackgroundTaskTimeoutSetting = previewBackgroundTaskTimeoutSetting;
4697
4889
  window.updateBackgroundTaskTimeoutSetting = updateBackgroundTaskTimeoutSetting;
4890
+ window.updateSubagentRecordRetentionSetting = updateSubagentRecordRetentionSetting;
4698
4891
  window.togglePushOnThisDevice = togglePushOnThisDevice;
4699
4892
  window.updatePushPreferencesFromControls = updatePushPreferencesFromControls;
4700
4893
  window.openSettingsModal = openSettingsModal;
@@ -4771,6 +4964,7 @@ window.toggleRepoBrowserHidden = toggleRepoBrowserHidden;
4771
4964
  window.toggleRepoBrowserHeavy = toggleRepoBrowserHeavy;
4772
4965
  window.focusRepoTree = focusRepoTree;
4773
4966
  window.setRepoCurrentPath = setRepoCurrentPath;
4967
+ window.startRepoNewFolder = startRepoNewFolder;
4774
4968
  window.confirmRepoBrowserCwdPick = confirmRepoBrowserCwdPick;
4775
4969
  window.toggleEmojiPicker = toggleEmojiPicker;
4776
4970
  window.submitRelayQuestionChoice = submitRelayQuestionChoice;
@@ -4783,6 +4977,7 @@ window.setRelayQuestionMultiMode = setRelayQuestionMultiMode;
4783
4977
  window.handleRelayQuestionKey = handleRelayQuestionKey;
4784
4978
  window.openPendingQuestionFromBanner = openPendingQuestionFromBanner;
4785
4979
  window.submitRelayBoardAction = submitRelayBoardAction;
4980
+ window.syncAgentExchangeFolds = syncAgentExchangeFoldsNow;
4786
4981
  window.compactCurrentConversation = compactCurrentConversation;
4787
4982
  window.sendMessage = sendMessage;
4788
4983
  window.syncComposerButtonState = syncComposerButtonState;