@oar-sh/oar 0.9.9 → 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 (85) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/README.md +78 -9
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +2 -2
  5. package/server/README.md +286 -3
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +35 -1
  8. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  9. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  10. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  12. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  13. package/server/db-schema.mjs +36 -0
  14. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  15. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  16. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  17. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  18. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  19. package/server/public/app/api-client.js +61 -0
  20. package/server/public/app/archive-confirm.mjs +28 -0
  21. package/server/public/app/ask-user-view.js +3 -0
  22. package/server/public/app/attachments-view.js +516 -6
  23. package/server/public/app/background-tasks-view.mjs +18 -26
  24. package/server/public/app/bootstrap.js +186 -35
  25. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  26. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  27. package/server/public/app/composer-control-state.mjs +16 -0
  28. package/server/public/app/composer-keyboard.mjs +54 -0
  29. package/server/public/app/conversation-view.js +361 -85
  30. package/server/public/app/explorer-edit-state.mjs +68 -0
  31. package/server/public/app/host-suspend-ui.js +6 -3
  32. package/server/public/app/journal-view.js +146 -26
  33. package/server/public/app/no-conversation-state.mjs +58 -0
  34. package/server/public/app/provider-readiness.mjs +120 -0
  35. package/server/public/app/relay-board-view.js +3 -0
  36. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  37. package/server/public/app/relay-event-view.mjs +234 -0
  38. package/server/public/app/settings-modal.js +53 -1
  39. package/server/public/app/socket-handlers.js +59 -28
  40. package/server/public/app/store.js +19 -2
  41. package/server/public/app/subagent-row.mjs +138 -0
  42. package/server/public/app/subagent-timeline-view.mjs +186 -0
  43. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  44. package/server/public/app/subagent-viewer.mjs +534 -0
  45. package/server/public/app/task-row-format.mjs +29 -0
  46. package/server/public/app/thinking-follow.js +4 -0
  47. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  48. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  49. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  50. package/server/public/app/update-ui.js +1 -1
  51. package/server/public/app/viewer-history.mjs +5 -3
  52. package/server/public/index.html +396 -5
  53. package/server/repositories/message-repository.mjs +29 -3
  54. package/server/repositories/question-repository.mjs +9 -3
  55. package/server/repositories/remote-relay-repository.mjs +6 -2
  56. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  57. package/server/routes/ask-user-routes.mjs +3 -0
  58. package/server/routes/messages-routes.mjs +515 -50
  59. package/server/routes/relay-event-routes.mjs +157 -0
  60. package/server/routes/remote-relay-routes.mjs +15 -0
  61. package/server/routes/sessions-routes.mjs +72 -0
  62. package/server/server-runtime.mjs +245 -62
  63. package/server/services/explorer-entry-name.mjs +26 -0
  64. package/server/services/explorer-mutations.mjs +122 -0
  65. package/server/services/preview-proxy-server.mjs +41 -23
  66. package/server/services/push-dispatch-service.mjs +18 -0
  67. package/server/services/relay-event-link-service.mjs +289 -0
  68. package/server/services/relay-event-service.mjs +1935 -0
  69. package/server/services/relay-shutdown-gate.mjs +31 -0
  70. package/server/services/remote-relay-caller-context.mjs +52 -7
  71. package/server/services/remote-relay-dispatcher.mjs +494 -2
  72. package/server/services/remote-relay-registry-service.mjs +11 -1
  73. package/server/services/session-activity-tracker.mjs +487 -0
  74. package/server/services/session-history-refresh-service.mjs +7 -0
  75. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  76. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  77. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  78. package/server/services/subagent-record-retention.mjs +164 -0
  79. package/server/services/subagent-timeline-service.mjs +328 -0
  80. package/server/services/subagent-viewer-rooms.mjs +119 -0
  81. package/server/services/upgrade-socket-guard.mjs +16 -0
  82. package/shared/relay-events.mjs +371 -0
  83. package/shared/remote-relay-contract.mjs +157 -5
  84. package/shared/subagent-record-retention.mjs +31 -0
  85. package/shared/subagent-timeline.mjs +79 -0
@@ -5,8 +5,12 @@
5
5
  // `backgroundTasks` on reload; hidden whenever the set is empty.
6
6
 
7
7
  import { apiFetch } from './api-client.js';
8
- import { humanizeModelLabel } from './model-selector-options.mjs';
8
+ import { formatTaskModelLabel, formatTaskTokens } from './task-row-format.mjs';
9
9
  import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
10
+
11
+ // Shared with the sub-agent rows (subagent-row.mjs); re-exported for the
12
+ // transcript's finished-workflow card and the existing importers.
13
+ export { formatTaskModelLabel, formatTaskTokens };
10
14
  import {
11
15
  getPreviewsForConversation,
12
16
  renderPreviewRowsInto,
@@ -71,16 +75,6 @@ function taskKindLabel(task) {
71
75
  return label === 'Subagent' && subagentType ? subagentType : label;
72
76
  }
73
77
 
74
- // "claude-opus-5-5" → "Opus 5.5". A bare alias ("opus") is what a spawn pin
75
- // carries until the subagent's first answer names the real model.
76
- export function formatTaskModelLabel(model) {
77
- const value = String(model || '').trim();
78
- if (!value) return '';
79
- if (/^claude-/i.test(value)) return humanizeModelLabel(value);
80
- if (/^[a-z]+(\[[^\]]+\])?$/i.test(value)) return value.charAt(0).toUpperCase() + value.slice(1);
81
- return humanizeModelLabel(value);
82
- }
83
-
84
78
  // Detail line: the live tool call when the worker saw one ("🪓 Tool
85
79
  // (PowerShell): cd …", cropped to the row by CSS), else the SDK's progress
86
80
  // summary, else the bare tool name.
@@ -93,20 +87,6 @@ export function taskActivityText(task) {
93
87
  return { text: lastToolName ? `using ${lastToolName}` : '', toolCall: false };
94
88
  }
95
89
 
96
- // Compact token counts: "812 tok", "62.2k tok", "1.2M tok". Exported for the
97
- // transcript's finished-workflow card, which shares the panel's formatting.
98
- export function formatTaskTokens(totalTokens) {
99
- const tokens = Number(totalTokens);
100
- if (!Number.isFinite(tokens) || tokens <= 0) return '';
101
- if (tokens < 1000) return `${tokens} tok`;
102
- if (tokens < 1_000_000) {
103
- const thousands = tokens / 1000;
104
- return `${thousands >= 100 ? Math.round(thousands) : thousands.toFixed(1)}k tok`;
105
- }
106
- const millions = tokens / 1_000_000;
107
- return `${millions >= 100 ? Math.round(millions) : millions.toFixed(1)}M tok`;
108
- }
109
-
110
90
  function escHtml(value) {
111
91
  return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
112
92
  '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
@@ -377,6 +357,16 @@ async function stopBackgroundTask(conversationId, taskId) {
377
357
  }
378
358
  }
379
359
 
360
+ // A sub-agent task opens its record in the viewer: the task's spawning
361
+ // tool_use id is the run's id. Bash tasks, monitors and workflows have no
362
+ // record, and Copilot's tasks carry no run id.
363
+ export function subagentRunIdForTask(task) {
364
+ const runId = String(task?.toolUseId || '').trim();
365
+ if (!runId) return '';
366
+ const type = String(task?.taskType || '').trim();
367
+ return type === 'local_agent' || type === 'agent' || type === 'subagent' ? runId : '';
368
+ }
369
+
380
370
  function renderTaskRow(conversationId, task) {
381
371
  const key = `${conversationId}:${task.taskId}`;
382
372
  const stopping = stopsInFlight.has(key);
@@ -403,7 +393,9 @@ function renderTaskRow(conversationId, task) {
403
393
  <div class="bg-task-row" data-task-id="${escHtml(task.taskId)}">
404
394
  ${foldButton}<span class="bg-task-icon" title="${escHtml(task.taskType || 'task')}">${taskIcon(task.taskType)}</span>
405
395
  <span class="bg-task-main">
406
- <span class="bg-task-desc">${escHtml(task.description || task.taskId)}</span>
396
+ ${subagentRunIdForTask(task)
397
+ ? `<button type="button" class="subagent-open bg-task-desc" data-subagent-open="${escHtml(subagentRunIdForTask(task))}" title="Open this sub-agent's thoughts, tool calls and reply">${escHtml(task.description || task.taskId)}</button>`
398
+ : `<span class="bg-task-desc">${escHtml(task.description || task.taskId)}</span>`}
407
399
  ${activity.text ? `<span class="bg-task-detail${activity.toolCall ? ' bg-task-toolcall' : ''}" title="${escHtml(activity.text)}">${escHtml(activity.text)}</span>` : ''}
408
400
  </span>
409
401
  <span class="bg-task-side">
@@ -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,
@@ -126,11 +128,14 @@ import {
126
128
  syncRenderedPinState,
127
129
  isConversationWindowAtTail,
128
130
  getTranscriptJumpEpoch,
131
+ getTranscriptLiveRowEpoch,
132
+ syncAgentExchangeFoldsNow,
129
133
  } from './conversation-view.js';
134
+ import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
130
135
  import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
131
136
  import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
132
137
  import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
133
- import { handleAttachmentInput, retryAttachmentUpload, openAnnotateEditorForPending, openAnnotateEditorForPreview, handleComposerPaste, handleComposerDrop, refreshComposerAttachmentWarning, removeAttachment, clearAttachments, openUploadedAttachmentViewer, setFilePreviewMode, toggleFilePreviewHtml, copyFilePreviewImage, closeFilePreview, goBackFilePreview, openWorkspaceFilePreview, openWorkspaceFilePreviewFromRepo, setRepoBrowserRoot, setRepoBrowserViewMode, toggleRepoBrowserHidden, toggleRepoBrowserHeavy, refreshRepoBrowser, focusRepoTree, setRepoCurrentPath, confirmRepoBrowserCwdPick } from './attachments-view.js';
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';
134
139
  import { initEmojiPicker, toggleEmojiPicker } from './emoji-view.js';
135
140
  import { initSlashAutocomplete } from './slash-autocomplete.mjs';
136
141
  import { initMentionAutocomplete } from './mention-autocomplete.mjs';
@@ -173,6 +178,7 @@ import {
173
178
  openMessageSearchModal,
174
179
  closeMessageSearchModal,
175
180
  } from './message-search-view.js';
181
+ import { initSubagentViewer } from './subagent-viewer.mjs';
176
182
  import {
177
183
  initGitChangesView,
178
184
  openGitChangesModal,
@@ -274,6 +280,7 @@ import {
274
280
  updateTheme,
275
281
  openSettingsModal,
276
282
  closeSettingsModal,
283
+ isSuspendHostActionVisible,
277
284
  syncSuspendHostVisibility,
278
285
  updateShowSuspendHostSetting,
279
286
  syncDefaultSessionWorkspaceRootInput,
@@ -309,6 +316,7 @@ import {
309
316
  updateTurnCeilingSetting,
310
317
  previewBackgroundTaskTimeoutSetting,
311
318
  updateBackgroundTaskTimeoutSetting,
319
+ updateSubagentRecordRetentionSetting,
312
320
  } from './settings-modal.js';
313
321
  import {
314
322
  togglePushOnThisDevice,
@@ -356,14 +364,21 @@ import {
356
364
  subscribeClaudeCloudSettings,
357
365
  toggleClaudeCloudProvider,
358
366
  } from './claude-cloud-settings-ui.js';
359
- 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';
360
374
  import { syncConversationOriginHeader } from './conversation-origin-ui.js';
375
+ import { loadRelayEventSubscriptions, onRelayEventSubscriptionsChanged, syncRelayEventHeader } from './relay-event-subscriptions-ui.js';
361
376
  import {
362
377
  previewAgentSessionsMaxWait,
363
378
  saveAgentSessionsMaxWait,
364
379
  toggleAgentSessions,
365
380
  } from './agent-sessions-settings-ui.js';
366
- import { CLAUDE_CLOUD_PROVIDER, claudeCloudComposerModelIds } from './claude-cloud-ui.mjs';
381
+ import { CLAUDE_CLOUD_PROVIDER, buildCloudLineModel, claudeCloudComposerModelIds } from './claude-cloud-ui.mjs';
367
382
  import {
368
383
  confirmCliInstall,
369
384
  runCliInstall,
@@ -377,6 +392,7 @@ import {
377
392
  dismissAvailableUpdate,
378
393
  dismissUpdateOutcome,
379
394
  } from './update-ui.js';
395
+ import { shouldAutofocusComposerOnLoad } from './composer-keyboard.mjs';
380
396
 
381
397
  const MODEL_STORAGE_KEY = 'copilot_selected_model';
382
398
  // The New Chat modal used to keep its own model key, so a selection made there
@@ -953,6 +969,10 @@ function applySharedReaderUi() {
953
969
  if (modelSelect) modelSelect.hidden = true;
954
970
  if (effortSelect) effortSelect.hidden = true;
955
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 }));
956
976
  if (chatMenu && sharedThemeBtn) {
957
977
  for (const child of Array.from(chatMenu.children)) {
958
978
  if (child !== sharedThemeBtn) hideSharedMenuEntry(child);
@@ -1006,6 +1026,15 @@ function resolveStableScrollTopForLiveRefresh(messagesEl, capturedScrollTop) {
1006
1026
  return captured;
1007
1027
  }
1008
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
+
1009
1038
  async function refreshSharedConversation() {
1010
1039
  if (sharedConversationPollInFlight) return;
1011
1040
  const sharedToken = resolveSharedTokenFromLocation();
@@ -1013,11 +1042,17 @@ async function refreshSharedConversation() {
1013
1042
  sharedConversationPollInFlight = true;
1014
1043
  const requestSeq = ++sharedConversationRequestSeq;
1015
1044
  const messagesEl = document.getElementById('messages');
1016
- const preserveBottom = isMessagesAtBottom();
1045
+ const wasAtBottom = isMessagesAtBottom();
1017
1046
  const savedScrollTop = messagesEl?.scrollTop || 0;
1018
1047
  try {
1019
- 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
+ );
1020
1054
  if (requestSeq < sharedConversationAppliedSeq) return;
1055
+ if (stale) return;
1021
1056
  if (!response?.ok) {
1022
1057
  const message = String(response?.error || 'Could not load shared conversation.').trim();
1023
1058
  if (message && message !== sharedConversationLastError) {
@@ -1050,6 +1085,7 @@ async function refreshSharedConversation() {
1050
1085
  return;
1051
1086
  }
1052
1087
  sharedConversationRenderKey = renderKey;
1088
+ const preserveBottom = resolveLiveRefreshAtBottom(messagesEl, savedScrollTop, wasAtBottom);
1053
1089
  const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, savedScrollTop);
1054
1090
  applyLoadedConversationState(convId, response, {
1055
1091
  restoreScroll: !preserveBottom,
@@ -1382,6 +1418,8 @@ function syncComposerPlaceholder() {
1382
1418
  if (!input) return;
1383
1419
  if (isSharedReaderMode()) return;
1384
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;
1385
1423
  const rawValue = String(document.getElementById('model-select')?.value || '');
1386
1424
  const { baseModelId } = splitModelVariantId(rawValue);
1387
1425
  input.placeholder = composerPlaceholderFor({
@@ -1616,6 +1654,8 @@ function updateModelCatalogState(payload) {
1616
1654
  // Shared readers never load the catalog, so this would only surface
1617
1655
  // "metadata unavailable" warnings for a picker they cannot see.
1618
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 });
1619
1659
  const select = document.getElementById('model-select');
1620
1660
  if (!select) return;
1621
1661
  const models = Array.isArray(payload?.models)
@@ -2180,6 +2220,8 @@ function initModeSelector() {
2180
2220
  function refreshModelCatalog(force = false) {
2181
2221
  return loadModelCatalog().then((r) => {
2182
2222
  if (!r) {
2223
+ // A failed load leaves provider readiness unknown: no setup wall on a blip.
2224
+ updateProviderReadinessInputs({ catalog: null });
2183
2225
  if (isModelMetadataHealthy(modelCatalogState) || isModelMetadataHealthy(lastHealthyModelCatalogState)) {
2184
2226
  setModelBanner('⚠️ Could not refresh model metadata; using last known good catalog.');
2185
2227
  syncModelMetadataBlocker();
@@ -3630,18 +3672,25 @@ async function pollAuthenticatedCurrentConversationLive() {
3630
3672
  liveConversationPollInFlight = true;
3631
3673
  try {
3632
3674
  const messagesEl = document.getElementById('messages');
3633
- const preserveBottom = isMessagesAtBottom();
3675
+ const wasAtBottom = isMessagesAtBottom();
3634
3676
  const savedScrollTop = messagesEl?.scrollTop || 0;
3635
3677
  const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
3636
3678
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
3637
3679
  const jumpEpoch = getTranscriptJumpEpoch();
3638
- const response = await loadConversation(currentId, { limit: requestLimit });
3680
+ const { response, stale } = await loadKeepingLiveRows(
3681
+ () => loadConversation(currentId, { limit: requestLimit }),
3682
+ getTranscriptLiveRowEpoch,
3683
+ );
3639
3684
  if (!response) return;
3640
3685
  if (String(currentConvId || '').trim() !== currentId) return;
3641
3686
  // The user jumped into the history while this request was on its way: the
3642
3687
  // answer is the end of the conversation as it was asked for before that,
3643
3688
  // and would overwrite the window they jumped to. The next round asks again.
3644
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);
3645
3694
  const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, savedScrollTop);
3646
3695
  applyLoadedConversationState(currentId, response, {
3647
3696
  restoreScroll: !preserveBottom,
@@ -3793,6 +3842,19 @@ function initMessageScrollPersistence() {
3793
3842
  }, { passive: true });
3794
3843
  }
3795
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
+
3796
3858
  async function runConversationHistoryRefresh({ source = 'menu' } = {}) {
3797
3859
  const currentId = String(currentConvId || '').trim();
3798
3860
  if (!currentId) {
@@ -3851,9 +3913,8 @@ async function refreshCurrentView() {
3851
3913
  const capturedVersion = ++refreshViewVersion;
3852
3914
  const jumpEpoch = getTranscriptJumpEpoch();
3853
3915
  const messagesEl = document.getElementById('messages');
3854
- const preserveBottom = isMessagesAtBottom();
3916
+ const wasAtBottom = isMessagesAtBottom();
3855
3917
  const scrollTop = messagesEl?.scrollTop || 0;
3856
- const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, scrollTop);
3857
3918
 
3858
3919
  await refreshConversations();
3859
3920
 
@@ -3869,13 +3930,19 @@ async function refreshCurrentView() {
3869
3930
 
3870
3931
  const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
3871
3932
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
3872
- const r = await loadConversation(currentId, { limit: requestLimit });
3933
+ const { response: r, stale } = await loadKeepingLiveRows(
3934
+ () => loadConversation(currentId, { limit: requestLimit }),
3935
+ getTranscriptLiveRowEpoch,
3936
+ );
3873
3937
  if (capturedVersion < refreshViewVersion) return;
3874
3938
  if (String(currentConvId || '').trim() !== currentId) return;
3875
3939
  // The user jumped into the history, before this refresh or while it was on
3876
3940
  // its way: title, tasks, pins and the rest are refreshed, the transcript
3877
- // and its scroll position are left as they are.
3878
- const keepTranscript = getTranscriptJumpEpoch() !== jumpEpoch || transcriptIsInHistory(currentId);
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);
3879
3946
  if (r) {
3880
3947
  applyLoadedConversationState(currentId, r, {
3881
3948
  restoreScroll: !preserveBottom,
@@ -3951,7 +4018,7 @@ function getChatTitleElements() {
3951
4018
  return {
3952
4019
  wrap: document.getElementById('chat-title-wrap'),
3953
4020
  title: document.getElementById('chat-title'),
3954
- editBtn: document.getElementById('chat-actions-menu-btn'),
4021
+ menuBtn: document.getElementById('chat-actions-menu-btn'),
3955
4022
  editor: document.getElementById('chat-title-editor'),
3956
4023
  input: document.getElementById('chat-title-input'),
3957
4024
  saveBtn: document.getElementById('chat-title-save-btn'),
@@ -3968,10 +4035,61 @@ function closeChatActionsMenu() {
3968
4035
  if (backdrop && !window.__chatActionsMenuShieldTimer) backdrop.classList.remove('visible');
3969
4036
  }
3970
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
+
3971
4086
  function toggleChatActionsMenu() {
3972
4087
  const menu = document.getElementById('chat-actions-menu');
3973
4088
  const trigger = document.getElementById('chat-actions-menu-btn');
3974
- 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;
3975
4093
  syncRefreshHistoryMenuState();
3976
4094
  syncThemeMenuLabel();
3977
4095
  const willOpen = !!menu.hidden;
@@ -4050,12 +4168,10 @@ function lockChatActionsMenuShield(ms = 300) {
4050
4168
 
4051
4169
 
4052
4170
  function syncChatTitleControls() {
4053
- const { title, editBtn, editor, input } = getChatTitleElements();
4171
+ const { title, editor, input } = getChatTitleElements();
4054
4172
  const convId = String(currentConvId || '').trim();
4055
4173
  // No conversation open (deleted, new chat): its 📍 button goes with it.
4056
4174
  if (!convId) setPinsConversation(null);
4057
- const killBtn = document.getElementById('chat-menu-kill-session');
4058
- const sharedMode = isSharedReaderMode();
4059
4175
  const conversation = convId ? (conversations[convId] || null) : null;
4060
4176
  const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
4061
4177
  if (title) {
@@ -4079,17 +4195,11 @@ function syncChatTitleControls() {
4079
4195
  const editing = convId && chatTitleEditingConversationId === convId;
4080
4196
  document.body.classList.toggle('chat-title-editing', !!editing);
4081
4197
  if (title) title.hidden = editing;
4082
- if (editBtn) {
4083
- editBtn.hidden = !convId;
4084
- editBtn.disabled = !convId || editing;
4085
- }
4086
- if (killBtn) {
4087
- killBtn.disabled = sharedMode || !convId || !sdkSessionId;
4088
- killBtn.hidden = sharedMode || !convId;
4089
- }
4198
+ // The ⋯ trigger and its entries: relay-wide ones stay with no conversation
4199
+ // open, so Settings is always one tap away.
4200
+ syncChatActionsMenuState();
4090
4201
  const archiveBtn = document.getElementById('chat-menu-archive');
4091
4202
  if (archiveBtn) {
4092
- archiveBtn.hidden = sharedMode || !convId;
4093
4203
  archiveBtn.textContent = conversation?.archived === true ? '📂 Unarchive conversation' : '🗄 Archive conversation';
4094
4204
  }
4095
4205
  if (editor) {
@@ -4112,19 +4222,21 @@ function syncChatTitleControls() {
4112
4222
  syncClaudeCloudConversationUi();
4113
4223
  // "via agent": the conversation an agent on this relay started this one from.
4114
4224
  syncConversationOriginHeader();
4225
+ // Relay events: "watching N sessions" on a watcher, "watched" on a watched one.
4226
+ syncRelayEventHeader();
4115
4227
  syncChatTitleWatcherIndicator();
4116
4228
  }
4117
4229
 
4118
4230
  function openChatTitleEditor() {
4119
4231
  const convId = String(currentConvId || '').trim();
4120
4232
  if (!convId) return;
4121
- const { title, editBtn, editor, input } = getChatTitleElements();
4233
+ const { title, menuBtn, editor, input } = getChatTitleElements();
4122
4234
  closeChatActionsMenu();
4123
4235
  const currentTitle = String(conversations[convId]?.title || title?.textContent || convId).trim() || convId;
4124
4236
  chatTitleEditingConversationId = convId;
4125
4237
  document.body.classList.add('chat-title-editing');
4126
4238
  if (title) title.hidden = true;
4127
- if (editBtn) editBtn.disabled = true;
4239
+ if (menuBtn) menuBtn.disabled = true;
4128
4240
  if (editor) editor.hidden = false;
4129
4241
  if (input) {
4130
4242
  input.maxLength = CHAT_TITLE_MAX_LENGTH;
@@ -4143,11 +4255,11 @@ function openChatTitleEditor() {
4143
4255
  function closeChatTitleEditor() {
4144
4256
  chatTitleEditingConversationId = null;
4145
4257
  document.body.classList.remove('chat-title-editing');
4146
- const { title, editBtn, editor, input } = getChatTitleElements();
4258
+ const { title, editor, input } = getChatTitleElements();
4147
4259
  closeChatActionsMenu();
4148
4260
  if (editor) editor.hidden = true;
4149
4261
  if (title) title.hidden = !String(currentConvId || '').trim();
4150
- if (editBtn) editBtn.disabled = !String(currentConvId || '').trim();
4262
+ // The trigger is decided by the syncChatTitleControls() call below.
4151
4263
  if (input) {
4152
4264
  const convId = String(currentConvId || '').trim();
4153
4265
  input.value = convId ? String(conversations[convId]?.title || title?.textContent || convId) : '';
@@ -4556,6 +4668,7 @@ async function initApp() {
4556
4668
  initConversationHistoryLazyLoading();
4557
4669
  initBubbleActionHandlers();
4558
4670
  initMessageScrollPersistence();
4671
+ initTranscriptJumpToBottom();
4559
4672
  syncChatTitleControls();
4560
4673
  await initSharedConversationReader();
4561
4674
  return;
@@ -4578,14 +4691,25 @@ async function initApp() {
4578
4691
  const status = await refreshWorkspaceRootHints();
4579
4692
  syncQueueStatusMenuEntry(status);
4580
4693
  setSessionWorkerStatesFromStatusPayload(status?.sessionWorker || null);
4581
- await refreshOpenAISettingsState();
4582
- await refreshCursorSettingsState();
4583
- 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
+ });
4584
4706
  // Not awaited: only a Claude Cloud conversation needs it (its model list),
4585
4707
  // and the subscription below rebuilds that list when the answer arrives. A
4586
4708
  // relay without the provider answers nothing and nothing changes.
4587
4709
  subscribeClaudeCloudSettings(refreshClaudeCloudComposerModels);
4588
- void refreshClaudeCloudSettingsState();
4710
+ void refreshClaudeCloudSettingsState().then((state) => {
4711
+ updateProviderReadinessInputs({ claudeCloud: state || null });
4712
+ });
4589
4713
  await refreshModelCatalog(true);
4590
4714
  initFullscreenButton();
4591
4715
  initInstallButton();
@@ -4599,8 +4723,10 @@ async function initApp() {
4599
4723
  initConversationHistoryLazyLoading();
4600
4724
  initBubbleActionHandlers();
4601
4725
  initMessageScrollPersistence();
4726
+ initTranscriptJumpToBottom({ loadEnd: loadCurrentConversationEnd });
4602
4727
  initComposerAttachmentInput();
4603
4728
  initMessageSearchView({ openConversation });
4729
+ initSubagentViewer();
4604
4730
  initPinnedMessagesView({
4605
4731
  openConversation,
4606
4732
  focusMessage: focusConversationMessageById,
@@ -4608,6 +4734,13 @@ async function initApp() {
4608
4734
  });
4609
4735
  initGitChangesView();
4610
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
+ });
4611
4744
  connectSocket();
4612
4745
  startRelayConnectionWatchdog();
4613
4746
  startDeviceVisibilityHeartbeat();
@@ -4619,6 +4752,9 @@ async function initApp() {
4619
4752
  // Browsers without Background Sync replay the outbox from the page instead.
4620
4753
  void initOutboxFallbackReplay();
4621
4754
  await loadConversations();
4755
+ // The sidebar's "watched" badges follow the subscriptions.
4756
+ onRelayEventSubscriptionsChanged(() => renderConvList());
4757
+ void loadRelayEventSubscriptions();
4622
4758
  const pushConversationId = consumePushConversationDeepLink();
4623
4759
  if (pushConversationId) {
4624
4760
  await openConversation(pushConversationId).catch(() => {});
@@ -4626,7 +4762,19 @@ async function initApp() {
4626
4762
  await loadRelayQuestions(currentConvId);
4627
4763
  await loadRelayBoards();
4628
4764
  updateCompactButton();
4629
- 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
+ }
4630
4778
  }
4631
4779
 
4632
4780
  async function doAuth() {
@@ -4739,6 +4887,7 @@ window.previewTurnCeilingSetting = previewTurnCeilingSetting;
4739
4887
  window.updateTurnCeilingSetting = updateTurnCeilingSetting;
4740
4888
  window.previewBackgroundTaskTimeoutSetting = previewBackgroundTaskTimeoutSetting;
4741
4889
  window.updateBackgroundTaskTimeoutSetting = updateBackgroundTaskTimeoutSetting;
4890
+ window.updateSubagentRecordRetentionSetting = updateSubagentRecordRetentionSetting;
4742
4891
  window.togglePushOnThisDevice = togglePushOnThisDevice;
4743
4892
  window.updatePushPreferencesFromControls = updatePushPreferencesFromControls;
4744
4893
  window.openSettingsModal = openSettingsModal;
@@ -4815,6 +4964,7 @@ window.toggleRepoBrowserHidden = toggleRepoBrowserHidden;
4815
4964
  window.toggleRepoBrowserHeavy = toggleRepoBrowserHeavy;
4816
4965
  window.focusRepoTree = focusRepoTree;
4817
4966
  window.setRepoCurrentPath = setRepoCurrentPath;
4967
+ window.startRepoNewFolder = startRepoNewFolder;
4818
4968
  window.confirmRepoBrowserCwdPick = confirmRepoBrowserCwdPick;
4819
4969
  window.toggleEmojiPicker = toggleEmojiPicker;
4820
4970
  window.submitRelayQuestionChoice = submitRelayQuestionChoice;
@@ -4827,6 +4977,7 @@ window.setRelayQuestionMultiMode = setRelayQuestionMultiMode;
4827
4977
  window.handleRelayQuestionKey = handleRelayQuestionKey;
4828
4978
  window.openPendingQuestionFromBanner = openPendingQuestionFromBanner;
4829
4979
  window.submitRelayBoardAction = submitRelayBoardAction;
4980
+ window.syncAgentExchangeFolds = syncAgentExchangeFoldsNow;
4830
4981
  window.compactCurrentConversation = compactCurrentConversation;
4831
4982
  window.sendMessage = sendMessage;
4832
4983
  window.syncComposerButtonState = syncComposerButtonState;