@oar-sh/oar 0.9.9 → 0.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +561 -1
  2. package/README.md +162 -909
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +14 -14
  5. package/server/README.md +369 -13
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +121 -9
  8. package/server/claude-worker/claude-session-worker.mjs +9 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
  13. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  14. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  15. package/server/db-schema.mjs +95 -2
  16. package/server/grok-worker/acp-client.mjs +2 -1
  17. package/server/grok-worker/acp-host-services.mjs +4 -3
  18. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  19. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  20. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  21. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  22. package/server/migrations/0013-previews.mjs +130 -0
  23. package/server/migrations/0014-conversation-groups.mjs +89 -0
  24. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  25. package/server/migrations/0016-share-access-log.mjs +110 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +187 -0
  30. package/server/public/app/api-client.js +312 -16
  31. package/server/public/app/archive-confirm.mjs +28 -0
  32. package/server/public/app/ask-user-view.js +17 -12
  33. package/server/public/app/attachments-view.js +516 -6
  34. package/server/public/app/background-tasks-view.mjs +18 -26
  35. package/server/public/app/bootstrap.js +336 -59
  36. package/server/public/app/chat-actions-menu-state.mjs +126 -0
  37. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  38. package/server/public/app/composer-control-state.mjs +16 -0
  39. package/server/public/app/composer-keyboard.mjs +54 -0
  40. package/server/public/app/context-menu.mjs +90 -6
  41. package/server/public/app/conversation-group-drag.mjs +191 -0
  42. package/server/public/app/conversation-group-order.mjs +265 -0
  43. package/server/public/app/conversation-group-state.mjs +100 -0
  44. package/server/public/app/conversation-list-drag.mjs +449 -0
  45. package/server/public/app/conversation-turn-state.mjs +81 -0
  46. package/server/public/app/conversation-view.js +436 -117
  47. package/server/public/app/cwd-picker.js +1 -1
  48. package/server/public/app/explorer-edit-state.mjs +68 -0
  49. package/server/public/app/hidden-messages-view.mjs +312 -0
  50. package/server/public/app/host-suspend-ui.js +13 -4
  51. package/server/public/app/journal-view.js +930 -45
  52. package/server/public/app/no-conversation-state.mjs +58 -0
  53. package/server/public/app/preview-cards.mjs +117 -31
  54. package/server/public/app/preview-command.mjs +10 -1
  55. package/server/public/app/provider-readiness.mjs +120 -0
  56. package/server/public/app/relay-board-view.js +19 -3
  57. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  58. package/server/public/app/relay-event-view.mjs +234 -0
  59. package/server/public/app/relay-restart-error.mjs +16 -0
  60. package/server/public/app/settings-modal.js +93 -9
  61. package/server/public/app/settings-tabs.js +1 -1
  62. package/server/public/app/share-format.mjs +49 -0
  63. package/server/public/app/share-preview-marks.mjs +167 -0
  64. package/server/public/app/share-state.js +109 -0
  65. package/server/public/app/shared-conversations-settings.js +480 -0
  66. package/server/public/app/socket-handlers.js +137 -40
  67. package/server/public/app/store.js +115 -32
  68. package/server/public/app/subagent-row.mjs +138 -0
  69. package/server/public/app/subagent-timeline-view.mjs +186 -0
  70. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  71. package/server/public/app/subagent-viewer.mjs +558 -0
  72. package/server/public/app/task-row-format.mjs +29 -0
  73. package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
  74. package/server/public/app/transcript-jump-bottom.mjs +210 -0
  75. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  76. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  77. package/server/public/app/update-ui.js +1 -1
  78. package/server/public/app/viewer-history.mjs +5 -3
  79. package/server/public/index.html +774 -12
  80. package/server/relay-tools.md +2 -1
  81. package/server/relay.mjs +2 -1
  82. package/server/repositories/conversation-group-repository.mjs +444 -0
  83. package/server/repositories/message-repository.mjs +30 -3
  84. package/server/repositories/question-repository.mjs +9 -3
  85. package/server/repositories/remote-relay-repository.mjs +6 -2
  86. package/server/repositories/session-repository.mjs +12 -37
  87. package/server/repositories/share-repository.mjs +322 -0
  88. package/server/repositories/share-visibility-statements.mjs +10 -0
  89. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  90. package/server/routes/about-routes.mjs +15 -0
  91. package/server/routes/ask-user-routes.mjs +3 -0
  92. package/server/routes/cache-routes.mjs +4 -0
  93. package/server/routes/conversation-group-routes.mjs +171 -0
  94. package/server/routes/messages-routes.mjs +698 -114
  95. package/server/routes/preview-routes.mjs +9 -1
  96. package/server/routes/relay-event-routes.mjs +157 -0
  97. package/server/routes/remote-relay-routes.mjs +15 -0
  98. package/server/routes/sessions-routes.mjs +843 -193
  99. package/server/server-runtime.mjs +559 -160
  100. package/server/services/about-service.mjs +461 -0
  101. package/server/services/background-task-store.mjs +128 -0
  102. package/server/services/background-work-view.mjs +228 -0
  103. package/server/services/cache-rebuild-service.mjs +30 -0
  104. package/server/services/delete-archive-service.mjs +4 -1
  105. package/server/services/detached-worker-watch-service.mjs +150 -0
  106. package/server/services/explorer-entry-name.mjs +26 -0
  107. package/server/services/explorer-mutations.mjs +122 -0
  108. package/server/services/hidden-message-list.mjs +21 -0
  109. package/server/services/host-suspend-command.mjs +2 -1
  110. package/server/services/message-pin-list.mjs +1 -1
  111. package/server/services/preview-health-probe.mjs +8 -1
  112. package/server/services/preview-proxy-server.mjs +49 -27
  113. package/server/services/preview-registry-service.mjs +676 -26
  114. package/server/services/preview-static-handler.mjs +40 -7
  115. package/server/services/preview-store.mjs +274 -0
  116. package/server/services/push-dispatch-service.mjs +18 -0
  117. package/server/services/rate-limit-service.mjs +12 -1
  118. package/server/services/relay-auth.mjs +183 -0
  119. package/server/services/relay-cli-launcher-service.mjs +3 -1
  120. package/server/services/relay-event-link-service.mjs +289 -0
  121. package/server/services/relay-event-service.mjs +1952 -0
  122. package/server/services/relay-shutdown-gate.mjs +31 -0
  123. package/server/services/remote-relay-caller-context.mjs +52 -7
  124. package/server/services/remote-relay-dispatcher.mjs +551 -7
  125. package/server/services/remote-relay-registry-service.mjs +11 -1
  126. package/server/services/request-boundary.mjs +183 -0
  127. package/server/services/session-activity-tracker.mjs +695 -0
  128. package/server/services/session-history-refresh-service.mjs +7 -0
  129. package/server/services/session-worker-launch-service.mjs +190 -6
  130. package/server/services/session-worker-process-service.mjs +10 -6
  131. package/server/services/share-access-log-service.mjs +123 -0
  132. package/server/services/share-path-redaction.mjs +424 -0
  133. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  134. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  135. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  136. package/server/services/status-event-service.mjs +7 -3
  137. package/server/services/subagent-record-retention.mjs +164 -0
  138. package/server/services/subagent-timeline-service.mjs +328 -0
  139. package/server/services/subagent-viewer-rooms.mjs +119 -0
  140. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  141. package/server/services/upgrade-socket-guard.mjs +16 -0
  142. package/server/services/windows-autostart-service.mjs +6 -1
  143. package/server/services/windows-boot-autostart-service.mjs +207 -23
  144. package/shared/preview-tool-core.mjs +15 -5
  145. package/shared/relay-events.mjs +371 -0
  146. package/shared/remote-relay-contract.mjs +160 -6
  147. package/shared/share-path-privacy.mjs +30 -0
  148. package/shared/subagent-record-retention.mjs +31 -0
  149. package/shared/subagent-timeline.mjs +79 -0
  150. package/shared/windows-system-tools.mjs +106 -0
  151. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  152. package/shared/worker-runtime/heartbeat.mjs +24 -0
  153. package/shared/worker-runtime/kept-posts.mjs +83 -0
  154. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -23,7 +23,6 @@ import {
23
23
  updateWorkspaceRootHints,
24
24
  applyContextUsageBar,
25
25
  scrollBottom,
26
- scrollBottomAfterSend,
27
26
  isMobileComposerViewport,
28
27
  releaseComposerFocusAfterSend,
29
28
  autoResize,
@@ -47,6 +46,7 @@ import {
47
46
  getSessionWorkerState,
48
47
  openSummaryModal,
49
48
  closeSummaryModal,
49
+ toggleSidebar,
50
50
  } from './store.js';
51
51
  import { getPendingQuestionCountsByConversation } from './ask-user-view.js';
52
52
  import { sendMessage as sendMessageApi, cancelConversationTurn, cancelQueuedConversationTurn, cancelSubagentRun, compactConversation as compactConversationApi, scheduleContextUsageRefresh, loadConversation as loadConversationApi, loadSharedConversation, updateConversationDraft as updateConversationDraftApi, updateMessageShareVisibility, launchSessionWorker } from './api-client.js';
@@ -55,12 +55,25 @@ import { linkifyWorkspaceMentionsInNode, parseAppFileHref, renderMarkdownPreview
55
55
  import { renderAttachmentMarkup, removeComposerAttachments, uploadAttachments, setComposerAttachments, setRepoBrowserSessionInfo, openGalleryItem } from './attachments-view.js';
56
56
  import { chatMediaGallery, chatMediaItemFromNode } from './viewer-chat-gallery.mjs';
57
57
  import { buildWorkflowRunCard } from './background-tasks-view.mjs';
58
+ import { mergeLiveSubagentRun, subagentRowsMarkup } from './subagent-row.mjs';
59
+ import { rememberSubagentRuns, syncSubagentRowClock } from './subagent-viewer.mjs';
58
60
  import { isMessagePinned, toggleMessagePin } from './pinned-messages-view.mjs';
61
+ import { isMessageHiddenFromShares, setConversationHiddenMessages } from './hidden-messages-view.mjs';
59
62
  import { parsePreviewCommand, runPreviewCommand } from './preview-command.mjs';
60
63
  import { buildTranscriptPreviewCard } from './preview-cards.mjs';
61
64
  import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomplete } from './slash-autocomplete.mjs';
62
65
  import { closeMentionAutocomplete, handleMentionAutocompleteKey, updateMentionAutocomplete } from './mention-autocomplete.mjs';
63
66
  import { renderRemoteOriginBadgeHtml } from './remote-relay-origin-view.mjs';
67
+ import { isRelayEventMessage, renderRelayEventRowHtml } from './relay-event-view.mjs';
68
+ import {
69
+ AGENT_EXCHANGE_CLOSED_CLASS,
70
+ AGENT_EXCHANGE_HEAD_CLASS,
71
+ agentExchangeFoldOpen,
72
+ agentExchangeRowOf,
73
+ agentExchangeSummary,
74
+ planAgentExchangeFolds,
75
+ renderAgentExchangeRowHtml,
76
+ } from './agent-exchange-fold-view.mjs';
64
77
  import { stripRemoteRelayDisplayArtifacts } from './relay-prompt-sanitizer.mjs';
65
78
  import { evaluateUnknownCommandGuard } from './slash-commands.mjs';
66
79
  import { attachCodeCopyButtons } from './code-copy.mjs';
@@ -98,9 +111,11 @@ import {
98
111
  syncSeparatorRail,
99
112
  syncTranscriptSeparators,
100
113
  } from './transcript-separators.mjs';
101
- import { ABSORBED_MSG_CLASS, STEER_MARKER_CLASS_BY_KIND, syncSteeredTurnMerge } from './steered-turn-merge.mjs';
114
+ import { ABSORBED_MSG_CLASS, STEERED_MSG_CLASS, STEER_MARKER_CLASS_BY_KIND, syncSteeredTurnMerge } from './steered-turn-merge.mjs';
102
115
  import { resolveSubagentFold, subagentFoldSummary, toggledSubagentFoldChoice } from './subagent-fold.mjs';
103
116
  import { deriveComposerControlState, hasComposerDraft, hasUploadingAttachments } from './composer-control-state.mjs';
117
+ import { resolveNoConversationState } from './no-conversation-state.mjs';
118
+ import { getProviderReadiness, isProviderSetupRequired } from './provider-readiness.mjs';
104
119
  import { buildLiveMessageFingerprint } from './live-message-dedupe.mjs';
105
120
  import { createInfiniteLoader } from './infinite-loader.js';
106
121
  import { normalizeDraftTimestampMs, isIncomingDraftTimestampStale } from './conversation-draft-timestamp-utils.mjs';
@@ -116,6 +131,7 @@ import {
116
131
  shouldApplyIncomingDraftToComposer,
117
132
  } from './conversation-draft-sync.mjs';
118
133
  import { isChatInteractionHeld, selectionIntersectsNode } from './selection-guard.mjs';
134
+ import { captureReadingBlock, restoreReadingBlock } from './transcript-viewport-anchor.mjs';
119
135
  import {
120
136
  applyThinkingFollow,
121
137
  getThinkingFollowMode,
@@ -126,6 +142,7 @@ import {
126
142
  toggleThinkingFollowMode,
127
143
  unobserveThinkingBubble,
128
144
  } from './thinking-follow.js';
145
+ import { beginOwnSend, followsTranscriptEnd, nameOwnSendConversation, ownSendFollows, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
129
146
  import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
130
147
  import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
131
148
  import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
@@ -150,6 +167,8 @@ function syncSeparatorsNow() {
150
167
  if (!el) return;
151
168
  syncTranscriptSeparators(el);
152
169
  syncSteeredTurnMerge(el);
170
+ // After the merge pass: a steered row's class is one of its inputs.
171
+ syncAgentExchangeFolds(el);
153
172
  observeTranscriptResize(el);
154
173
  }
155
174
 
@@ -265,6 +284,7 @@ export function jumpToImageParent(messageId) {
265
284
  showTransientRelayNotice('Load earlier messages to view the source image.');
266
285
  return;
267
286
  }
287
+ revealAgentExchangeMessage(id);
268
288
  node.scrollIntoView({ behavior: 'smooth', block: 'center' });
269
289
  node.classList.add('message-highlight');
270
290
  window.setTimeout(() => node.classList.remove('message-highlight'), 1400);
@@ -355,6 +375,8 @@ const conversationHistoryLoader = createInfiniteLoader({
355
375
  // A steering pair split across the page boundary heals here (the absorbed
356
376
  // reply just arrived above its already-rendered steered user row).
357
377
  syncSteeredTurnMerge(el);
378
+ // The page's agent exchanges fold before the height delta is measured.
379
+ syncAgentExchangeFolds(el);
358
380
  requestAnimationFrame(() => {
359
381
  if (!el || String(currentConvId || '').trim() !== currentId) return;
360
382
  const nextScrollHeight = el.scrollHeight;
@@ -555,11 +577,28 @@ function syncSendButtonState() {
555
577
  steeringHeld: steeringHold.held,
556
578
  steeringHoldReason: steeringHold.reason,
557
579
  archived: conversations[String(currentConvId || '').trim()]?.archived === true,
580
+ setupRequired: !String(currentConvId || '').trim() && isProviderSetupRequired(),
558
581
  });
559
582
  btn.disabled = state.disabled;
560
583
  btn.dataset.action = state.action;
561
584
  btn.textContent = state.label;
562
585
  btn.title = state.title;
586
+ // The textarea follows: locked with the setup placeholder while no provider
587
+ // is ready, given back the moment readiness flips or a chat opens. The
588
+ // shared view owns its own read-only field and is left alone.
589
+ const input = document.getElementById('msg-input');
590
+ if (input && !IS_SHARED_VIEW) {
591
+ const lock = state.composerDisabled === true;
592
+ if (lock) {
593
+ input.disabled = true;
594
+ input.placeholder = state.placeholder;
595
+ input.dataset.setupLocked = '1';
596
+ } else if (input.dataset.setupLocked === '1') {
597
+ delete input.dataset.setupLocked;
598
+ input.disabled = false;
599
+ window.syncComposerPlaceholder?.();
600
+ }
601
+ }
563
602
  }
564
603
 
565
604
  // Button-only refresh for status, poll and question paths. It must never save
@@ -1226,6 +1265,147 @@ function splitVariantId(modelVariantId = '') {
1226
1265
  };
1227
1266
  }
1228
1267
 
1268
+ // The user's fold of an event row, by message id: a re-render of the
1269
+ // transcript must not open a row the user folded (or fold one they opened).
1270
+ const relayEventFoldChoices = new Map();
1271
+
1272
+ function createRelayEventNode(msg, msgId) {
1273
+ const div = document.createElement('div');
1274
+ div.className = 'msg relay-event';
1275
+ if (msgId) div.dataset.messageId = msgId;
1276
+ const fingerprint = buildLiveMessageFingerprint({ ...msg, id: msgId || msg?.id || '' });
1277
+ div.dataset.messageRole = fingerprint.role || '';
1278
+ div.dataset.messageTextFingerprint = fingerprint.text || '';
1279
+ div.dataset.messageTimestamp = String(msg?.timestamp || '').trim();
1280
+ div.innerHTML = renderRelayEventRowHtml(msg, { escHtml, timeText: msg?.timestamp ? fmtDate(msg.timestamp) : '' });
1281
+ const row = div.querySelector('details');
1282
+ if (row && msgId) {
1283
+ if (relayEventFoldChoices.has(msgId)) row.open = relayEventFoldChoices.get(msgId);
1284
+ row.addEventListener('toggle', () => relayEventFoldChoices.set(msgId, row.open));
1285
+ }
1286
+ setAgentExchangeRow(div, msg, msgId);
1287
+ return div;
1288
+ }
1289
+
1290
+ // Agent exchanges (agent-exchange-fold-view.mjs): an agent's prompt folds
1291
+ // into one row; this session's reply to it always shows. What the fold pass
1292
+ // needs of a message is kept on its node when the row is built, so the pass
1293
+ // reads the transcript itself and not the payload it came from.
1294
+ const AGENT_EXCHANGE_ROW_KEY = '__agentExchangeRow';
1295
+ // The user's toggle of a folded prompt, by its id: true opened, false
1296
+ // closed, unset none (agentExchangeFoldOpen: open while it works, else
1297
+ // folded). Kept through any re-render.
1298
+ const agentExchangeFoldChoices = new Map();
1299
+ // Each folded prompt's status, by its id, as of the last pass.
1300
+ let agentExchangeStatusByPromptId = new Map();
1301
+
1302
+ function setAgentExchangeRow(div, msg, msgId) {
1303
+ div[AGENT_EXCHANGE_ROW_KEY] = agentExchangeRowOf(msg, { id: msgId || msg?.id || '' });
1304
+ }
1305
+
1306
+ // The rows the plan reads, in order, and the node of each. The live bubble
1307
+ // is the running turn's row; cards and boards without a reply are not rows.
1308
+ function agentExchangeTranscriptRows(el) {
1309
+ const rows = [];
1310
+ const nodes = new Map();
1311
+ for (const node of el.children) {
1312
+ if (!node.classList?.contains('msg')) continue;
1313
+ let row = null;
1314
+ if (node.id === 'thinking-indicator') {
1315
+ const id = String(node.dataset.messageId || '').trim();
1316
+ if (id) row = { id, role: 'assistant', live: true };
1317
+ } else if (node[AGENT_EXCHANGE_ROW_KEY]) {
1318
+ row = { ...node[AGENT_EXCHANGE_ROW_KEY], steered: node.classList.contains(STEERED_MSG_CLASS) };
1319
+ }
1320
+ if (!row) continue;
1321
+ rows.push(row);
1322
+ nodes.set(row, node);
1323
+ }
1324
+ return { rows, nodes };
1325
+ }
1326
+
1327
+ /**
1328
+ * Fold the transcript's agent prompts. One idempotent pass, run after every
1329
+ * render and update of the list like the separator and merge passes. The
1330
+ * folded row lives in the prompt's own node, as its first child, so no
1331
+ * insertion path has to place it; closed, a class hides the prompt's bubble.
1332
+ * The replies and the live bubble are read for the row's status and are
1333
+ * otherwise left alone.
1334
+ */
1335
+ function syncAgentExchangeFolds(el = getMessagesElement()) {
1336
+ if (!el) return;
1337
+ const { rows, nodes } = agentExchangeTranscriptRows(el);
1338
+ const plan = planAgentExchangeFolds(rows, {
1339
+ runningIds: [getActiveTurnForConversation(currentConvId)?.messageId],
1340
+ });
1341
+ const states = new Map();
1342
+ const statusByPromptId = new Map();
1343
+ for (const group of plan) {
1344
+ if (!group.fold) continue;
1345
+ const open = agentExchangeFoldOpen(group.status, agentExchangeFoldChoices.get(group.promptId));
1346
+ states.set(nodes.get(group.prompt), { group, open });
1347
+ statusByPromptId.set(group.promptId, group.status);
1348
+ }
1349
+ agentExchangeStatusByPromptId = statusByPromptId;
1350
+ for (const node of el.children) {
1351
+ if (node.classList?.contains('msg')) applyAgentExchangeState(node, states.get(node) || null);
1352
+ }
1353
+ }
1354
+
1355
+ // For the paths that change the list outside a render: the live bubble
1356
+ // coming and going. A bulk render runs the pass once at its end.
1357
+ export function syncAgentExchangeFoldsNow() {
1358
+ if (separatorSyncSuspended > 0) return;
1359
+ syncAgentExchangeFolds();
1360
+ }
1361
+
1362
+ function applyAgentExchangeState(node, state) {
1363
+ node.classList.toggle(AGENT_EXCHANGE_HEAD_CLASS, !!state);
1364
+ node.classList.toggle(AGENT_EXCHANGE_CLOSED_CLASS, !!state && !state.open);
1365
+ const existing = node.querySelector(':scope > .agent-exchange-row');
1366
+ if (!state) {
1367
+ existing?.remove();
1368
+ return;
1369
+ }
1370
+ const { group, open } = state;
1371
+ const summary = agentExchangeSummary(group.prompt.origin, group.status, { conversations });
1372
+ const timeText = fmtDate(node.dataset.messageTimestamp);
1373
+ const key = JSON.stringify([summary.text, timeText]);
1374
+ let row = existing;
1375
+ if (!row || row.dataset.summaryKey !== key) {
1376
+ const holder = document.createElement('div');
1377
+ holder.innerHTML = renderAgentExchangeRowHtml(summary, { escHtml, timeText, open });
1378
+ const fresh = holder.firstElementChild;
1379
+ fresh.dataset.summaryKey = key;
1380
+ fresh.dataset.promptId = group.promptId;
1381
+ fresh.addEventListener('toggle', () => onAgentExchangeToggle(fresh));
1382
+ if (row) row.replaceWith(fresh);
1383
+ else node.insertBefore(fresh, node.firstChild);
1384
+ row = fresh;
1385
+ }
1386
+ if (row.open !== open) row.open = open;
1387
+ }
1388
+
1389
+ function onAgentExchangeToggle(row) {
1390
+ const promptId = String(row.dataset.promptId || '').trim();
1391
+ // The pass setting `open` fires this too, and a row it has since replaced
1392
+ // (its status changed) fires it late; only the user's toggle is news.
1393
+ if (!promptId || !row.isConnected) return;
1394
+ const shown = agentExchangeFoldOpen(agentExchangeStatusByPromptId.get(promptId), agentExchangeFoldChoices.get(promptId));
1395
+ if (shown === row.open) return;
1396
+ agentExchangeFoldChoices.set(promptId, row.open);
1397
+ syncAgentExchangeFolds();
1398
+ }
1399
+
1400
+ // A search result, a pin or a jump to a folded prompt opens it first, so
1401
+ // the message jumped to is the one on screen.
1402
+ function revealAgentExchangeMessage(messageId) {
1403
+ const promptId = String(messageId || '').trim();
1404
+ if (!agentExchangeStatusByPromptId.has(promptId) || agentExchangeFoldChoices.get(promptId) === true) return;
1405
+ agentExchangeFoldChoices.set(promptId, true);
1406
+ syncAgentExchangeFolds();
1407
+ }
1408
+
1229
1409
  function createMessageNode(msg, msgId = null, force = false) {
1230
1410
  const el = getMessagesElement();
1231
1411
  if (!el) return null;
@@ -1237,6 +1417,10 @@ function createMessageNode(msg, msgId = null, force = false) {
1237
1417
  seenMessageIds.add(msgId);
1238
1418
  }
1239
1419
 
1420
+ // An event message the relay wrote into a watcher's conversation: a
1421
+ // compact, foldable row, never a bubble of the user's.
1422
+ if (isRelayEventMessage(msg)) return createRelayEventNode(msg, msgId);
1423
+
1240
1424
  // A prompt another relay's agent sent carries `origin`: its header line is
1241
1425
  // hidden (the badge below says the same), and a stray remote-relay mention
1242
1426
  // hint never shows on any user bubble.
@@ -1258,6 +1442,7 @@ function createMessageNode(msg, msgId = null, force = false) {
1258
1442
  div.dataset.messageTextFingerprint = fingerprint.text || '';
1259
1443
  div.dataset.messageTimestamp = String(msg?.timestamp || '').trim();
1260
1444
  if (fingerprint.sourceMessageId) div.dataset.sourceMessageId = fingerprint.sourceMessageId;
1445
+ setAgentExchangeRow(div, msg, msgId);
1261
1446
 
1262
1447
  const label = msg.role === 'user' ? 'You' : '';
1263
1448
  const { baseModelId, reasoningEffort } = splitVariantId(msg.model);
@@ -1305,10 +1490,11 @@ function createMessageNode(msg, msgId = null, force = false) {
1305
1490
  // (e.g. the Copilot relay answering a Cursor conversation) must be visible,
1306
1491
  // not silent: it ran on another plan than the header indicates.
1307
1492
  const executedProvider = String(msg?.executedProvider || '').trim().toLowerCase();
1308
- const boundProvider = String(conversations[currentConvId]?.runtimeProviderType || 'github').trim().toLowerCase();
1493
+ const boundProvider = String(conversations[currentConvId]?.runtimeProviderType || '').trim().toLowerCase();
1309
1494
  // 'unknown' means the responder identity did not resolve, not that another
1310
- // provider ran the turn — no chip for it.
1311
- const crossProviderTag = (msg.role === 'assistant' && executedProvider && executedProvider !== 'unknown' && executedProvider !== boundProvider)
1495
+ // provider ran the turn — no chip for it. Nor without a bound provider to
1496
+ // compare with (a shared view of a conversation that has none).
1497
+ const crossProviderTag = (msg.role === 'assistant' && executedProvider && executedProvider !== 'unknown' && boundProvider && executedProvider !== boundProvider)
1312
1498
  ? ` <span class="msg-provider-mismatch" title="This turn was executed by the ${escHtml(executedProvider)} provider, not the conversation's ${escHtml(boundProvider)} provider.">ran on ${escHtml(executedProvider)}</span>`
1313
1499
  : '';
1314
1500
  const usage = (msg.role === 'assistant' && msg?.usage && typeof msg.usage === 'object') ? msg.usage : null;
@@ -1361,7 +1547,14 @@ function createMessageNode(msg, msgId = null, force = false) {
1361
1547
  const mainThoughts = thoughts.filter((t) => !t?.subagentRunId);
1362
1548
  const activityHtml = mainActivities.length ? renderActivityMarkup(mainActivities, promotedBoundaryEntry) : '';
1363
1549
  const thoughtsHtml = mainThoughts.length ? renderThoughtsMarkup(mainThoughts) : '';
1364
- const subagentHtml = renderSubagentRunsMarkup(subagentRuns, activities, thoughts);
1550
+ // One row per sub-agent the turn spawned, as in the composer's task panel;
1551
+ // its title opens the agent's whole record. The store's copy wins: status
1552
+ // events keep it current after the bubble was built.
1553
+ rememberSubagentRuns(subagentRuns);
1554
+ const subagentHtml = subagentRowsMarkup(
1555
+ subagentRuns.map((run) => mergeLiveSubagentRun(run, getSubagentRun(run.subagentRunId))),
1556
+ { clickable: true },
1557
+ );
1365
1558
  // Finished background workflows persisted with this assistant message
1366
1559
  // (docs/plans/workflow-progress-tree.md, Phase 4). The template only
1367
1560
  // reserves a placeholder; the cards are DOM-built below so digest text
@@ -1389,7 +1582,9 @@ function createMessageNode(msg, msgId = null, force = false) {
1389
1582
  && cancellableSteerIdsForConversation(currentConvId).has(msgId);
1390
1583
  const showsCancelButton = isQueuedUserMessage || isCancellableSteer;
1391
1584
  const isCancelInFlight = showsCancelButton && bubbleCancelInFlight.has(msgId);
1392
- const hiddenFromShares = msg?.hiddenFromShares === true;
1585
+ // The conversation's hidden list once it is known (a change on another
1586
+ // device arrives there), else the message's own mark.
1587
+ const hiddenFromShares = isMessageHiddenFromShares(currentConvId, msgId) ?? (msg?.hiddenFromShares === true);
1393
1588
  const activeTurnMessageId = String(getActiveTurnForConversation(currentConvId)?.messageId || '').trim();
1394
1589
  const sourceMessageId = String(msg?.sourceMessageId || '').trim();
1395
1590
  const belongsToActiveTurn = !!activeTurnMessageId
@@ -1402,8 +1597,8 @@ function createMessageNode(msg, msgId = null, force = false) {
1402
1597
  const shareVisibilityActionHtml = canToggleShareVisibility
1403
1598
  ? `<div class="msg-share-visibility">
1404
1599
  ${buildMessagePinControlsHtml(msgId, pinned)}
1405
- ${hiddenFromShares ? '<span class="msg-hidden-label">Hidden from shared viewers</span>' : ''}
1406
- <button type="button" class="msg-share-visibility-btn" data-action="toggle-share-visibility" data-message-id="${escHtml(msgId)}" data-hidden-from-shares="${hiddenFromShares ? 'true' : 'false'}" title="${hiddenFromShares ? 'Shows this message in shared conversations' : 'Hides this message from shared conversations'}">${hiddenFromShares ? 'Unhide' : 'Hide'}</button>
1600
+ ${hiddenFromShares ? HIDDEN_LABEL_HTML : ''}
1601
+ <button type="button" class="msg-share-visibility-btn" data-action="toggle-share-visibility" data-message-id="${escHtml(msgId)}" data-hidden-from-shares="${hiddenFromShares ? 'true' : 'false'}" title="${shareVisibilityButtonTitle(hiddenFromShares)}">${hiddenFromShares ? 'Unhide' : 'Hide'}</button>
1407
1602
  </div>`
1408
1603
  : '';
1409
1604
  // A terminal failure whose fix lives in the relay UI gets the fix as a button
@@ -1440,6 +1635,8 @@ function createMessageNode(msg, msgId = null, force = false) {
1440
1635
  <div class="msg-label">${label}${modelTag}${reasoningTag}${modeTag}${autoTag}${continuationTag}${crossProviderTag}${usageTurnTag}${usageRemainingTag}${usageStaleTag} · ${fmtDate(msg.timestamp)}</div>`;
1441
1636
 
1442
1637
  const bubble = div.querySelector('.msg-bubble');
1638
+ // A running sub-agent's row keeps its clock going once the bubble is placed.
1639
+ if (subagentHtml.includes('data-started-at')) setTimeout(syncSubagentRowClock, 0);
1443
1640
  rewriteLocalAssetUrlsInNode(bubble, { preferDrive: msg.role === 'assistant' });
1444
1641
  linkifyWorkspaceMentionsInNode(bubble);
1445
1642
  // One collapsed "Finished background task" card per persisted run, sharing
@@ -1536,21 +1733,12 @@ function prependMessageNodes(msgs) {
1536
1733
  return { inserted, firstMessageId };
1537
1734
  }
1538
1735
 
1736
+ // A shared view's tool lines arrive filtered by the relay's path privacy
1737
+ // setting (server/services/share-path-redaction.mjs); they are shown as sent.
1539
1738
  export function decorateActivityText(text) {
1540
1739
  const value = String(text || '').trim();
1541
1740
  if (!value) return '';
1542
- const maskSharedPathSegments = (input) => {
1543
- const source = String(input || '');
1544
- const tokenMasked = source.replace(/@(file|folder):([^\s`]+)/gi, (_m, kind, rawPath) => {
1545
- const normalized = String(rawPath || '').replace(/\\/g, '/').replace(/\/+$/, '');
1546
- const segments = normalized.split('/').filter(Boolean);
1547
- const basename = segments[segments.length - 1] || normalized;
1548
- return `@${String(kind || '').toLowerCase()}:${basename}`;
1549
- });
1550
- return tokenMasked.replace(/([A-Za-z]:)?(?:[\\/~.]?[\\/])(?:[^\\/\s]+[\\/])+([^\\/\s]+)/g, (_m, _prefix, basename) => basename);
1551
- };
1552
- const sharedSafeValue = IS_SHARED_VIEW ? maskSharedPathSegments(value) : value;
1553
- return prefixToolActivityEmoji(sharedSafeValue);
1741
+ return prefixToolActivityEmoji(value);
1554
1742
  }
1555
1743
 
1556
1744
  export function renderThoughtsMarkup(thoughts) {
@@ -1648,83 +1836,6 @@ export function renderActivityMarkup(activities, promotedBoundaryEntry = undefin
1648
1836
  return `${progressHtml}${toolsHtml}`;
1649
1837
  }
1650
1838
 
1651
- // Persisted nested subagent sections: groups a finished message's activities
1652
- // and thoughts by subagentRunId and renders one collapsible block per run,
1653
- // nested by parentSubagentId — mirroring the live sub-bubbles.
1654
- export function renderSubagentRunsMarkup(subagentRuns, activities, thoughts) {
1655
- const activityByRun = new Map();
1656
- for (const item of (Array.isArray(activities) ? activities : [])) {
1657
- const entry = normalizeRelayActivityEntry(item);
1658
- if (!entry?.subagentRunId) continue;
1659
- const list = activityByRun.get(entry.subagentRunId) || [];
1660
- list.push(entry.text);
1661
- activityByRun.set(entry.subagentRunId, list);
1662
- }
1663
- const thoughtsByRun = new Map();
1664
- for (const thought of (Array.isArray(thoughts) ? thoughts : [])) {
1665
- const runId = thought?.subagentRunId ? String(thought.subagentRunId).trim() : '';
1666
- const text = String(thought?.text || '').trim();
1667
- if (!runId || !text) continue;
1668
- const list = thoughtsByRun.get(runId) || [];
1669
- list.push({ reasoningId: String(thought?.reasoningId || '').trim(), text });
1670
- thoughtsByRun.set(runId, list);
1671
- }
1672
- const runsById = new Map();
1673
- for (const run of (Array.isArray(subagentRuns) ? subagentRuns : [])) {
1674
- const runId = String(run?.subagentRunId || '').trim();
1675
- if (!runId) continue;
1676
- runsById.set(runId, {
1677
- subagentRunId: runId,
1678
- parentSubagentId: run?.parentSubagentId ? String(run.parentSubagentId).trim() : null,
1679
- displayName: String(run?.displayName || '').trim() || `Subagent ${runId.slice(0, 8)}`,
1680
- status: String(run?.status || 'completed').trim().toLowerCase(),
1681
- });
1682
- }
1683
- // Items may reference runs whose metadata rows are unavailable; synthesize.
1684
- for (const runId of new Set([...activityByRun.keys(), ...thoughtsByRun.keys()])) {
1685
- if (!runsById.has(runId)) {
1686
- runsById.set(runId, {
1687
- subagentRunId: runId,
1688
- parentSubagentId: null,
1689
- displayName: `Subagent ${runId.slice(0, 8)}`,
1690
- status: 'completed',
1691
- });
1692
- }
1693
- }
1694
- if (!runsById.size) return '';
1695
- const childrenByParent = new Map();
1696
- const roots = [];
1697
- for (const run of runsById.values()) {
1698
- const parentId = run.parentSubagentId && runsById.has(run.parentSubagentId) ? run.parentSubagentId : null;
1699
- if (parentId) {
1700
- const children = childrenByParent.get(parentId) || [];
1701
- children.push(run);
1702
- childrenByParent.set(parentId, children);
1703
- } else {
1704
- roots.push(run);
1705
- }
1706
- }
1707
- const renderRun = (run) => {
1708
- const runActivities = activityByRun.get(run.subagentRunId) || [];
1709
- const runThoughts = thoughtsByRun.get(run.subagentRunId) || [];
1710
- const children = childrenByParent.get(run.subagentRunId) || [];
1711
- const thoughtsBlock = runThoughts.length
1712
- ? `<details class="msg-thoughts msg-subagent-thoughts"><summary>💭 Thoughts (${runThoughts.length})</summary><div class="msg-thoughts-list">${runThoughts.map((thought) => `<div class="msg-thought-item"${thought.reasoningId ? ` data-reasoning-id="${escHtml(thought.reasoningId)}"` : ''}>${renderMarkdownPreview(thought.text, false)}</div>`).join('')}</div></details>`
1713
- : '';
1714
- const activitiesBlock = runActivities.length
1715
- ? `<div class="msg-activity-list">${runActivities.map((text) => `<div class="msg-activity-item">${escHtml(decorateActivityText(text))}</div>`).join('')}</div>`
1716
- : '';
1717
- const childrenBlock = children.map(renderRun).join('');
1718
- const statusLabel = run.status && run.status !== 'completed' ? ` · ${escHtml(run.status)}` : '';
1719
- return `
1720
- <details class="msg-activity msg-subagent-run" data-subagent-run-id="${escHtml(run.subagentRunId)}">
1721
- <summary>🤖 ${escHtml(run.displayName)}${statusLabel}${runActivities.length ? ` (${runActivities.length})` : ''}</summary>
1722
- ${thoughtsBlock}${activitiesBlock}${childrenBlock}
1723
- </details>`;
1724
- };
1725
- return roots.map(renderRun).join('');
1726
- }
1727
-
1728
1839
  // Places the live bubble directly under its owning user message, or leaves a
1729
1840
  // bubble already in the transcript where it is when that row is not rendered.
1730
1841
  // Idempotent so the reuse path can call it every tick: a bubble built before
@@ -1745,6 +1856,8 @@ function anchorThinkingBubble(div, messageId) {
1745
1856
  if (next) el.insertBefore(div, next);
1746
1857
  else el.appendChild(div);
1747
1858
  if (div.dataset.compactBoundary) syncSeparatorsNow();
1859
+ // A folded agent prompt's row says the turn is working.
1860
+ else syncAgentExchangeFoldsNow();
1748
1861
  } else if (div.parentNode !== el) {
1749
1862
  el.appendChild(div);
1750
1863
  }
@@ -1820,6 +1933,7 @@ export function showThinking(messageId = null, autoScroll = true) {
1820
1933
  export function removeThinking() {
1821
1934
  thinkingMessageId = null;
1822
1935
  lastInFlightSnapshotKey = '';
1936
+ const hadBubble = !!document.getElementById('thinking-indicator');
1823
1937
  const wasCompacting = !!document.getElementById('thinking-indicator')?.dataset?.compactBoundary;
1824
1938
  unobserveThinkingBubble();
1825
1939
  document.getElementById('thinking-indicator')?.remove();
@@ -1827,6 +1941,45 @@ export function removeThinking() {
1827
1941
  // caller's same-tick work: a reply appended right after this call takes
1828
1942
  // the line over (relabelled in place) instead of it being dropped first.
1829
1943
  if (wasCompacting) queueMicrotask(syncSeparatorsNow);
1944
+ // A turn that ended without a reply (cancelled) stops saying "working".
1945
+ if (hadBubble) syncAgentExchangeFoldsNow();
1946
+ }
1947
+
1948
+ // The turn ends under a reader who is not at the end: the live bubble is
1949
+ // taken off the page and the reply row is put in its place. The page is
1950
+ // shorter for a moment (scrollTop can be clamped), and the reply is not as
1951
+ // tall as the bubble was (its thoughts are folded). Whatever the reader looks
1952
+ // at stays where it is: the block of text at the top of the viewport is noted
1953
+ // before the swap and put back after it. Read in the bubble's stream, it is
1954
+ // found again in the reply; in a row above or below the bubble, in that row.
1955
+ export function captureLiveReplyReadingPosition() {
1956
+ const el = getMessagesElement();
1957
+ if (!el) return null;
1958
+ const bubble = document.getElementById('thinking-indicator');
1959
+ return {
1960
+ scrollTop: el.scrollTop,
1961
+ block: captureReadingBlock(el),
1962
+ // Below the bubble the pixel position is the wrong fallback: everything
1963
+ // the reader sees moves up or down with the swap, and scrollTop with it.
1964
+ belowBubble: !!bubble && bubble.getBoundingClientRect().bottom <= el.getBoundingClientRect().top,
1965
+ };
1966
+ }
1967
+
1968
+ export function restoreLiveReplyReadingPosition(position) {
1969
+ const el = getMessagesElement();
1970
+ if (!el || !position || isChatInteractionHeld()) return;
1971
+ if (restoreReadingBlock(el, position.block)) return;
1972
+ if (!position.belowBubble) el.scrollTop = position.scrollTop;
1973
+ }
1974
+
1975
+ // A rebuild of the transcript on the page that nobody asked to scroll: the
1976
+ // reader keeps the text they are at, or failing that the pixel position.
1977
+ function renderMessagesInPlace(msgs, meta) {
1978
+ const el = getMessagesElement();
1979
+ const reading = el ? { scrollTop: el.scrollTop, block: captureReadingBlock(el) } : null;
1980
+ const rendered = renderMessages(msgs, false, meta);
1981
+ if (el && reading && rendered && !restoreReadingBlock(el, reading.block)) el.scrollTop = reading.scrollTop;
1982
+ return rendered;
1830
1983
  }
1831
1984
 
1832
1985
  // The live turn's compaction line: the bubble stamps the turn's compaction
@@ -2045,6 +2198,9 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
2045
2198
  return;
2046
2199
  }
2047
2200
  lastInFlightSnapshotKey = snapshotKey;
2201
+ // The snapshot may have been read before the newest live frame was stored.
2202
+ const liveStreamSeq = relayStreamStateByMessageId.get(messageId)?.seq || 0;
2203
+ const liveStreamText = relayStreamTextByMessageId.get(messageId);
2048
2204
  clearRelayStreamState();
2049
2205
  setConversationTurnState(currentConvId, { messageId, status: 'processing' });
2050
2206
  const activities = mergeRelayActivityTexts(
@@ -2086,14 +2242,19 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
2086
2242
  // Seed the per-thread stream text before the bubbles are built so both the
2087
2243
  // reply preview and each subagent bubble repaint on reload.
2088
2244
  const streamTextByThread = deriveInFlightStreamTextByThread(inFlight);
2089
- if (streamTextByThread.main) {
2245
+ if (streamTextByThread.main && streamTextByThread.main.seq >= liveStreamSeq) {
2090
2246
  relayStreamTextByMessageId.set(messageId, streamTextByThread.main.text);
2247
+ } else if (liveStreamText !== undefined) {
2248
+ relayStreamTextByMessageId.set(messageId, liveStreamText);
2091
2249
  }
2092
2250
  for (const [runId, entry] of streamTextByThread.bySubagentRunId) {
2093
2251
  upsertSubagentRun({ subagentRunId: runId, messageId, conversationId: currentConvId });
2094
2252
  setSubagentStreamText(runId, entry.text);
2095
2253
  }
2096
2254
  showThinking(messageId, autoScroll);
2255
+ // relay_stream frames are volatile, so a dropped one reaches the bubble only
2256
+ // from here; showThinking does not repaint a bubble it reuses.
2257
+ renderThinkingStream();
2097
2258
  renderThinkingActivities();
2098
2259
  renderThinkingThoughts();
2099
2260
  renderRestoredSubagentBubbles(messageId);
@@ -2578,16 +2739,23 @@ export function applyRelayStreamEvent({
2578
2739
  if (id !== thinkingMessageId) return true;
2579
2740
  if (runId) renderSubagentStream(runId, text);
2580
2741
  else renderThinkingStream();
2742
+ // Again once the text is in: it grew below the end the status scrolled to.
2743
+ if (autoScroll && !getThinkingFollowMode()) scrollBottom();
2581
2744
  return true;
2582
2745
  }
2583
2746
 
2584
2747
  export function flushDeferredMessageRender() {
2585
2748
  if (!deferredMessageRender) return;
2586
- const { msgs, scroll, meta } = deferredMessageRender;
2749
+ const { msgs, meta } = deferredMessageRender;
2587
2750
  deferredMessageRender = null;
2588
2751
  const deferredConversationId = String(meta?.conversationId || '').trim();
2589
2752
  if (deferredConversationId && deferredConversationId !== String(currentConvId || '').trim()) return;
2590
- renderMessages(msgs, scroll, meta);
2753
+ // Whoever asked for this render has long returned, and with it whatever
2754
+ // would have put the reader back; and where the reader was when it was
2755
+ // held back is not where they are now. Decided as it runs: at the end it
2756
+ // follows, anywhere else it leaves them at the text they read.
2757
+ if (followsTranscriptEnd()) renderMessages(msgs, true, meta);
2758
+ else renderMessagesInPlace(msgs, meta);
2591
2759
  }
2592
2760
 
2593
2761
  // Blacklists the id from further live relay_stream frames. Kept separate from
@@ -2905,10 +3073,13 @@ export function replaceRenderedMessage(msg, msgId) {
2905
3073
  seenMessageIds.delete(id);
2906
3074
  const fresh = createMessageNode(msg, id, true);
2907
3075
  placeholder.replaceWith(fresh || existing);
3076
+ // The rebuilt row takes its merge and fold classes back.
3077
+ if (fresh) syncSeparatorsNow();
2908
3078
  return Boolean(fresh);
2909
3079
  }
2910
3080
 
2911
3081
  async function resendStoppedSteer(conversationId, markerId) {
3082
+ const send = beginOwnSend();
2912
3083
  const conversationKey = String(conversationId || '').trim();
2913
3084
  const id = String(markerId || '').trim();
2914
3085
  if (!conversationKey || !id || stoppedSteerResends.has(id)) return;
@@ -2960,7 +3131,7 @@ async function resendStoppedSteer(conversationId, markerId) {
2960
3131
  mode: selectedMode,
2961
3132
  timestamp: new Date().toISOString(),
2962
3133
  attachments,
2963
- }, true, clientMessageId, true);
3134
+ }, ownSendFollows(send), clientMessageId, true);
2964
3135
  bubbleAppended = true;
2965
3136
  }
2966
3137
  const r = await sendMessageApi({
@@ -3004,7 +3175,7 @@ async function resendStoppedSteer(conversationId, markerId) {
3004
3175
  showTransientRelayNotice(`Resent without ${dropped} attachment${dropped === 1 ? '' : 's'} that ${dropped === 1 ? 'is' : 'are'} no longer available.`, 7000);
3005
3176
  }
3006
3177
  if (cliOnline && isCurrentConversation(conversationKey) && !getActiveTurnForConversation(conversationKey)?.messageId) {
3007
- showThinking(r.messageId || null);
3178
+ showThinking(r.messageId || null, ownSendFollows(send));
3008
3179
  }
3009
3180
  } catch (error) {
3010
3181
  if (!hasSendEchoArrived(clientMessageId)) {
@@ -3173,6 +3344,35 @@ export function syncRenderedPinState(pinnedIds) {
3173
3344
  }
3174
3345
  }
3175
3346
 
3347
+ const HIDDEN_LABEL_HTML = '<span class="msg-hidden-label">Hidden from shared viewers</span>';
3348
+
3349
+ function shareVisibilityButtonTitle(hidden) {
3350
+ return hidden ? 'Shows this message in shared conversations' : 'Hides this message from shared conversations';
3351
+ }
3352
+
3353
+ // The hidden list changed (this page, another device): bring the bubbles on
3354
+ // the page in line without rebuilding them. A button whose own round trip is
3355
+ // still out keeps its "Hiding…" until the answer.
3356
+ export function syncRenderedShareVisibility(hiddenIds) {
3357
+ const el = getMessagesElement();
3358
+ if (!el) return;
3359
+ const ids = hiddenIds instanceof Set ? hiddenIds : new Set(hiddenIds || []);
3360
+ for (const button of el.querySelectorAll('.msg-share-visibility-btn')) {
3361
+ const messageId = String(button.dataset.messageId || '').trim();
3362
+ if (shareVisibilityInFlight.has(messageId)) continue;
3363
+ const hidden = ids.has(messageId);
3364
+ if ((button.dataset.hiddenFromShares === 'true') === hidden) continue;
3365
+ button.dataset.hiddenFromShares = hidden ? 'true' : 'false';
3366
+ button.disabled = false;
3367
+ button.textContent = hidden ? 'Unhide' : 'Hide';
3368
+ button.title = shareVisibilityButtonTitle(hidden);
3369
+ const sibling = button.previousElementSibling;
3370
+ const label = sibling?.classList?.contains('msg-hidden-label') ? sibling : null;
3371
+ if (hidden && !label) button.insertAdjacentHTML('beforebegin', HIDDEN_LABEL_HTML);
3372
+ if (!hidden && label) label.remove();
3373
+ }
3374
+ }
3375
+
3176
3376
  async function toggleMessageShareVisibility(conversationId, messageId, hiddenFromShares) {
3177
3377
  const conversationKey = String(conversationId || '').trim();
3178
3378
  const targetMessageId = String(messageId || '').trim();
@@ -3191,13 +3391,17 @@ async function toggleMessageShareVisibility(conversationId, messageId, hiddenFro
3191
3391
  button.disabled = false;
3192
3392
  button.textContent = hiddenFromShares ? 'Unhide' : 'Hide';
3193
3393
  }
3194
- showTransientRelayNotice('Could not update shared-message visibility.');
3394
+ showTransientRelayNotice(String(result?.error || '').trim() || 'Could not update shared-message visibility.');
3195
3395
  return;
3196
3396
  }
3197
3397
 
3198
3398
  showTransientRelayNotice(result.hiddenFromShares
3199
3399
  ? 'Message hidden from shared viewers.'
3200
3400
  : 'Message visible to shared viewers.');
3401
+ // Stored under its own conversation: the 🙈 count, even after a switch.
3402
+ if ('hiddenMessages' in result) {
3403
+ setConversationHiddenMessages(conversationKey, result.hiddenMessages, result.hiddenMessagesRevision);
3404
+ }
3201
3405
  // The reload below only refreshes the open view; if the user switched
3202
3406
  // conversations while the toggle round trip was in flight, rendering it
3203
3407
  // would replace the new conversation's messages with this one's.
@@ -3207,7 +3411,7 @@ async function toggleMessageShareVisibility(conversationId, messageId, hiddenFro
3207
3411
  });
3208
3412
  if (String(currentConvId || '').trim() !== conversationKey) return;
3209
3413
  if (response?.messages) {
3210
- renderMessages(response.messages, false, response);
3414
+ renderMessagesInPlace(response.messages, response);
3211
3415
  }
3212
3416
  }
3213
3417
 
@@ -3376,6 +3580,14 @@ export function appendMessage(msg, scroll = true, msgId = null, force = false, i
3376
3580
  if (empty) empty.remove();
3377
3581
  const node = createMessageNode(msg, msgId, force);
3378
3582
  const isNewNode = !!node && !node.parentNode;
3583
+ // A reply the socket delivers without its prompt's id in the message (a
3584
+ // failed turn's note) is placed under that prompt, and an agent prompt's
3585
+ // row reads its status from it.
3586
+ const exchangeRow = isNewNode ? node[AGENT_EXCHANGE_ROW_KEY] : null;
3587
+ const anchorId = String(insertAfterId || '').trim();
3588
+ if (exchangeRow?.role === 'assistant' && !exchangeRow.sourceMessageId && anchorId && anchorId !== String(msgId || '').trim()) {
3589
+ exchangeRow.sourceMessageId = anchorId;
3590
+ }
3379
3591
  // Insert without scrolling: the separator pass below can add a row above
3380
3592
  // this message, and scrolling before it runs leaves the viewport a
3381
3593
  // separator-height short of the bottom (which then reads as "not at
@@ -3491,6 +3703,91 @@ export function noteTranscriptJump() {
3491
3703
  transcriptJumpEpoch += 1;
3492
3704
  }
3493
3705
 
3706
+ // Counts rows the socket put on the page: another client's message, a reply,
3707
+ // a row rewritten in place. A reload whose request was on its way when one
3708
+ // landed may not have it, and its rebuild would take it off the page again
3709
+ // (transcript-reload-freshness.mjs).
3710
+ let transcriptLiveRowEpoch = 0;
3711
+
3712
+ export function getTranscriptLiveRowEpoch() {
3713
+ return transcriptLiveRowEpoch;
3714
+ }
3715
+
3716
+ export function noteTranscriptLiveRow() {
3717
+ transcriptLiveRowEpoch += 1;
3718
+ }
3719
+
3720
+ function liveConversationCount() {
3721
+ return Object.values(conversations || {}).filter((conversation) => conversation && conversation.archived !== true).length;
3722
+ }
3723
+
3724
+ function runEmptyStateAction(action) {
3725
+ switch (String(action || '').trim()) {
3726
+ case 'open-settings':
3727
+ openSettingsModal('providers');
3728
+ return;
3729
+ case 'new-chat':
3730
+ void window.newConversation?.();
3731
+ return;
3732
+ case 'show-conversations':
3733
+ toggleSidebar(true);
3734
+ return;
3735
+ default:
3736
+ }
3737
+ }
3738
+
3739
+ function buildEmptyStateNode(model) {
3740
+ const node = document.createElement('div');
3741
+ node.className = 'empty-state';
3742
+ node.dataset.variant = model.variant;
3743
+ const actions = model.actions.map((action) => {
3744
+ const classes = ['empty-state-btn'];
3745
+ if (action.primary) classes.push('empty-state-btn-accent');
3746
+ if (action.link) classes.push('empty-state-link');
3747
+ return `<button type="button" id="${escHtml(action.id)}" class="${classes.join(' ')}" data-empty-action="${escHtml(action.action)}">${escHtml(action.label)}</button>`;
3748
+ }).join('');
3749
+ node.innerHTML = `
3750
+ <div class="icon">${escHtml(model.icon)}</div>
3751
+ <h3>${escHtml(model.heading)}</h3>
3752
+ <p>${escHtml(model.text)}</p>
3753
+ <div class="empty-state-actions">${actions}</div>`;
3754
+ for (const button of node.querySelectorAll('[data-empty-action]')) {
3755
+ button.addEventListener('click', (event) => {
3756
+ event.preventDefault();
3757
+ runEmptyStateAction(button.dataset.emptyAction);
3758
+ });
3759
+ }
3760
+ return node;
3761
+ }
3762
+
3763
+ /**
3764
+ * Draws the "no conversation open" screen into the transcript, or redraws it
3765
+ * when readiness or the conversation list changed. A no-op with a
3766
+ * conversation open, in the shared view, or while the status view owns the
3767
+ * transcript. Idempotent: the same model leaves the node alone.
3768
+ */
3769
+ export function renderNoConversationState() {
3770
+ if (IS_SHARED_VIEW) return false;
3771
+ if (String(currentConvId || '').trim()) return false;
3772
+ const el = getMessagesElement();
3773
+ if (!el || el.querySelector('.status-view')) return false;
3774
+ const existing = el.querySelector('.empty-state');
3775
+ // A transcript with rows in it belongs to something else (a render in flight).
3776
+ if (!existing && el.children.length) return false;
3777
+ const model = resolveNoConversationState({
3778
+ readiness: getProviderReadiness(),
3779
+ conversationCount: liveConversationCount(),
3780
+ mobile: isMobileComposerViewport(),
3781
+ });
3782
+ const key = JSON.stringify(model);
3783
+ if (existing && existing.dataset.emptyStateKey === key) return true;
3784
+ const node = buildEmptyStateNode(model);
3785
+ node.dataset.emptyStateKey = key;
3786
+ if (existing) existing.replaceWith(node);
3787
+ else el.appendChild(node);
3788
+ return true;
3789
+ }
3790
+
3494
3791
  export function renderMessages(msgs, scroll = true, meta = {}) {
3495
3792
  const el = getMessagesElement();
3496
3793
  if (!el) return false;
@@ -3571,11 +3868,17 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
3571
3868
  && thinkingNode.dataset.conversationId === renderConversationKey) ? thinkingNode : null;
3572
3869
  if (thinkingNodeInTranscript && !preservedThinkingNode) removeThinking();
3573
3870
  if (!ordered.length && !preservedPendingNodes.length && !preservedThinkingNode) {
3574
- el.innerHTML = `<div class="empty-state">
3575
- <div class="icon">${currentConvId ? '💬' : '🚀'}</div>
3576
- <h3>${currentConvId ? 'No messages yet' : 'New Conversation'}</h3>
3577
- <p>${currentConvId ? 'Start the conversation below' : 'Type your first message below'}</p>
3578
- </div>`;
3871
+ if (!currentConvId && !IS_SHARED_VIEW) {
3872
+ // Nothing open: the screen says what can be done here (no-conversation-state.mjs).
3873
+ el.innerHTML = '';
3874
+ renderNoConversationState();
3875
+ } else {
3876
+ el.innerHTML = `<div class="empty-state">
3877
+ <div class="icon">${currentConvId ? '💬' : '🚀'}</div>
3878
+ <h3>${currentConvId ? 'No messages yet' : 'New Conversation'}</h3>
3879
+ <p>${currentConvId ? 'Start the conversation below' : 'Type your first message below'}</p>
3880
+ </div>`;
3881
+ }
3579
3882
  resetConversationHistoryState();
3580
3883
  renderRelayQuestions();
3581
3884
  renderRelayBoards();
@@ -3680,6 +3983,8 @@ export function focusConversationMessageById(messageId, { behavior = 'smooth', b
3680
3983
  if (!id) return false;
3681
3984
  const el = getMessagesElement();
3682
3985
  if (!el) return false;
3986
+ // A folded agent prompt is opened before it is scrolled to.
3987
+ revealAgentExchangeMessage(id);
3683
3988
  const target = el.querySelector(`.msg[data-message-id="${CSS.escape(id)}"]`);
3684
3989
  if (!target) return false;
3685
3990
  target.scrollIntoView({ behavior, block, inline: 'nearest' });
@@ -3721,6 +4026,9 @@ export function compactCurrentConversation() {
3721
4026
  let unknownCommandWarned = null;
3722
4027
 
3723
4028
  export async function sendMessage() {
4029
+ // The reader's own send, as of now: what follows it scrolls to the end
4030
+ // only while they have not scrolled since.
4031
+ const send = beginOwnSend();
3724
4032
  const input = document.getElementById('msg-input');
3725
4033
  const originalComposerText = String(input?.value || '');
3726
4034
  const text = originalComposerText.trim();
@@ -3733,6 +4041,13 @@ export async function sendMessage() {
3733
4041
  return;
3734
4042
  }
3735
4043
  if (!hasDraft) return;
4044
+ // No conversation open and no provider ready: a send would create a
4045
+ // Copilot chat that cannot run. The composer is locked in this state; this
4046
+ // guard covers a programmatic send, before any upload starts.
4047
+ if (!String(currentConvId || '').trim() && isProviderSetupRequired()) {
4048
+ showTransientRelayNotice('Set up a provider in Settings → Providers before starting a chat.', 6000);
4049
+ return;
4050
+ }
3736
4051
  closeSlashAutocomplete();
3737
4052
  closeMentionAutocomplete();
3738
4053
 
@@ -3754,7 +4069,7 @@ export async function sendMessage() {
3754
4069
  releaseComposerFocusAfterSend(input);
3755
4070
  void flushConversationDraft(currentConvId);
3756
4071
  compactCurrentConversation();
3757
- scrollBottomAfterSend();
4072
+ scrollAfterOwnSend(send);
3758
4073
  return;
3759
4074
  }
3760
4075
 
@@ -3831,8 +4146,10 @@ export async function sendMessage() {
3831
4146
  input.value = '';
3832
4147
  autoResize(input);
3833
4148
  releaseComposerFocusAfterSend(input);
3834
- appendMessage({ role: 'user', text, model: selectedModel, mode: selectedMode, timestamp: msgTimestamp, attachments }, true, clientMessageId, true);
3835
- scrollBottomAfterSend();
4149
+ // Asked before the bubble is on the page.
4150
+ const follow = ownSendFollows(send);
4151
+ appendMessage({ role: 'user', text, model: selectedModel, mode: selectedMode, timestamp: msgTimestamp, attachments }, follow, clientMessageId, true);
4152
+ scrollAfterOwnSend(send);
3836
4153
  }
3837
4154
 
3838
4155
  const body = {
@@ -3920,7 +4237,8 @@ export async function sendMessage() {
3920
4237
  await window.openConversation?.(r.compactedConversationId);
3921
4238
  removeComposerAttachments(draftAttachments);
3922
4239
  if (!mobileSend) input.focus();
3923
- scrollBottomAfterSend();
4240
+ // The send goes on in the conversation the compaction opened.
4241
+ scrollAfterOwnSend({ ...send, conversationId: String(r.compactedConversationId || '').trim() });
3924
4242
  }
3925
4243
  return;
3926
4244
  }
@@ -3941,6 +4259,7 @@ export async function sendMessage() {
3941
4259
  if (adoptNewConversation) {
3942
4260
  setCurrentConv(r.conversationId);
3943
4261
  composerConversationId = r.conversationId;
4262
+ nameOwnSendConversation(send, r.conversationId);
3944
4263
  }
3945
4264
  conversations[r.conversationId] = {
3946
4265
  id: r.conversationId,
@@ -3987,7 +4306,7 @@ export async function sendMessage() {
3987
4306
  // pinned to the newest message. Re-pin now rather than after the draft
3988
4307
  // save's round trip below, or the new bubble sits under the composer
3989
4308
  // for as long as that request takes.
3990
- if (viewingSendConversation()) scrollBottom();
4309
+ if (viewingSendConversation() && ownSendFollows(send)) scrollBottom();
3991
4310
  const persistedConversationId = String(r.conversationId || targetConversationId || '').trim();
3992
4311
  if (persistedConversationId) {
3993
4312
  restorableDraftByConversation.delete(persistedConversationId);
@@ -4019,7 +4338,7 @@ export async function sendMessage() {
4019
4338
  // A turn already running here owns the live bubble; adopting the
4020
4339
  // just-queued id would re-anchor it under the newest user message.
4021
4340
  const activeTurn = getActiveTurnForConversation(composerConversationId);
4022
- if (!activeTurn?.messageId) showThinking(r.messageId || null);
4341
+ if (!activeTurn?.messageId) showThinking(r.messageId || null, ownSendFollows(send));
4023
4342
  }
4024
4343
 
4025
4344
  // Only what this message took leaves the composer: attachments added (or
@@ -4028,7 +4347,7 @@ export async function sendMessage() {
4028
4347
  if (viewingSendConversation()) {
4029
4348
  removeComposerAttachments(draftAttachments);
4030
4349
  if (!mobileSend) input.focus();
4031
- scrollBottomAfterSend();
4350
+ scrollAfterOwnSend(send);
4032
4351
  }
4033
4352
  } catch (e) {
4034
4353
  const sendLanded = hasSendEchoArrived(clientMessageId);