@oar-sh/oar 0.9.10 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/CHANGELOG.md +440 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +112 -25
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +217 -11
  8. package/server/claude-worker/claude-session-worker.mjs +26 -1
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +68 -2
  15. package/server/grok-worker/acp-client.mjs +2 -1
  16. package/server/grok-worker/acp-host-services.mjs +46 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0013-previews.mjs +130 -0
  22. package/server/migrations/0014-conversation-groups.mjs +89 -0
  23. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  24. package/server/migrations/0016-share-access-log.mjs +110 -0
  25. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  30. package/server/public/app/api-client.js +264 -16
  31. package/server/public/app/ask-user-view.js +17 -15
  32. package/server/public/app/background-tasks-view.mjs +285 -16
  33. package/server/public/app/bootstrap.js +253 -37
  34. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  35. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  36. package/server/public/app/context-menu.mjs +90 -6
  37. package/server/public/app/conversation-group-drag.mjs +191 -0
  38. package/server/public/app/conversation-group-order.mjs +265 -0
  39. package/server/public/app/conversation-group-state.mjs +100 -0
  40. package/server/public/app/conversation-list-drag.mjs +449 -0
  41. package/server/public/app/conversation-payload-cache.mjs +357 -0
  42. package/server/public/app/conversation-turn-state.mjs +81 -0
  43. package/server/public/app/conversation-view.js +567 -94
  44. package/server/public/app/conversation-window-retention.mjs +96 -0
  45. package/server/public/app/cwd-picker.js +1 -1
  46. package/server/public/app/hidden-messages-view.mjs +312 -0
  47. package/server/public/app/host-suspend-ui.js +7 -1
  48. package/server/public/app/journal-view.js +1033 -82
  49. package/server/public/app/preview-cards.mjs +117 -31
  50. package/server/public/app/preview-command.mjs +10 -1
  51. package/server/public/app/relay-board-view.js +19 -6
  52. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  53. package/server/public/app/relay-restart-error.mjs +16 -0
  54. package/server/public/app/settings-modal.js +150 -10
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/share-format.mjs +49 -0
  57. package/server/public/app/share-preview-marks.mjs +167 -0
  58. package/server/public/app/share-state.js +109 -0
  59. package/server/public/app/shared-conversations-settings.js +480 -0
  60. package/server/public/app/socket-handlers.js +138 -20
  61. package/server/public/app/store.js +121 -30
  62. package/server/public/app/subagent-row.mjs +3 -3
  63. package/server/public/app/subagent-viewer.mjs +37 -7
  64. package/server/public/app/task-console-render.mjs +237 -0
  65. package/server/public/app/task-console-watch.mjs +42 -0
  66. package/server/public/app/task-console.mjs +630 -0
  67. package/server/public/app/task-output-text.mjs +234 -0
  68. package/server/public/app/thinking-follow.js +0 -4
  69. package/server/public/app/transcript-island.mjs +73 -0
  70. package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
  71. package/server/public/app/transcript-jump-bottom.mjs +170 -22
  72. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  73. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  74. package/server/public/app/transcript-viewport-anchor.mjs +159 -0
  75. package/server/public/index.html +625 -21
  76. package/server/relay-tools.md +3 -1
  77. package/server/relay.mjs +2 -1
  78. package/server/repositories/conversation-group-repository.mjs +444 -0
  79. package/server/repositories/message-repository.mjs +9 -0
  80. package/server/repositories/question-repository.mjs +4 -0
  81. package/server/repositories/session-repository.mjs +12 -37
  82. package/server/repositories/share-repository.mjs +322 -0
  83. package/server/repositories/share-visibility-statements.mjs +10 -0
  84. package/server/routes/about-routes.mjs +15 -0
  85. package/server/routes/cache-routes.mjs +4 -0
  86. package/server/routes/conversation-group-routes.mjs +171 -0
  87. package/server/routes/messages-routes.mjs +433 -68
  88. package/server/routes/preview-routes.mjs +9 -1
  89. package/server/routes/sessions-routes.mjs +901 -253
  90. package/server/server-runtime.mjs +392 -99
  91. package/server/services/about-service.mjs +461 -0
  92. package/server/services/background-task-store.mjs +365 -0
  93. package/server/services/background-work-view.mjs +228 -0
  94. package/server/services/cache-rebuild-service.mjs +30 -0
  95. package/server/services/cli-process-runner.mjs +4 -21
  96. package/server/services/conversation-history-page.mjs +199 -0
  97. package/server/services/delete-archive-service.mjs +4 -1
  98. package/server/services/detached-worker-watch-service.mjs +150 -0
  99. package/server/services/hidden-message-list.mjs +21 -0
  100. package/server/services/host-suspend-command.mjs +2 -1
  101. package/server/services/message-pin-list.mjs +1 -1
  102. package/server/services/preview-health-probe.mjs +8 -1
  103. package/server/services/preview-proxy-server.mjs +8 -4
  104. package/server/services/preview-registry-service.mjs +676 -26
  105. package/server/services/preview-static-handler.mjs +40 -7
  106. package/server/services/preview-store.mjs +274 -0
  107. package/server/services/rate-limit-service.mjs +12 -1
  108. package/server/services/relay-auth.mjs +183 -0
  109. package/server/services/relay-cli-launcher-service.mjs +3 -1
  110. package/server/services/relay-event-service.mjs +17 -0
  111. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  112. package/server/services/remote-relay-dispatcher.mjs +187 -7
  113. package/server/services/request-boundary.mjs +183 -0
  114. package/server/services/session-activity-tracker.mjs +231 -19
  115. package/server/services/session-worker-launch-service.mjs +190 -6
  116. package/server/services/session-worker-process-service.mjs +10 -6
  117. package/server/services/share-access-log-service.mjs +123 -0
  118. package/server/services/share-path-redaction.mjs +424 -0
  119. package/server/services/status-event-service.mjs +7 -3
  120. package/server/services/task-output-rooms.mjs +259 -0
  121. package/server/services/task-output-service.mjs +562 -0
  122. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  123. package/server/services/windows-autostart-service.mjs +6 -1
  124. package/server/services/windows-boot-autostart-service.mjs +207 -23
  125. package/shared/claude-background-command-limit.mjs +83 -0
  126. package/shared/preview-tool-core.mjs +15 -5
  127. package/shared/remote-relay-contract.mjs +35 -3
  128. package/shared/share-path-privacy.mjs +30 -0
  129. package/shared/task-output-text.mjs +737 -0
  130. package/shared/windows-system-tools.mjs +106 -0
  131. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  132. package/shared/worker-runtime/heartbeat.mjs +24 -0
  133. package/shared/worker-runtime/kept-posts.mjs +83 -0
  134. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  135. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -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,10 +46,9 @@ import {
47
46
  getSessionWorkerState,
48
47
  openSummaryModal,
49
48
  closeSummaryModal,
50
- parseTimestampMs,
51
- relayQuestions,
52
- relayBoards,
53
49
  toggleSidebar,
50
+ hasPendingUserMessageForConversation,
51
+ isMessagesNearBottom,
54
52
  } from './store.js';
55
53
  import { getPendingQuestionCountsByConversation } from './ask-user-view.js';
56
54
  import { sendMessage as sendMessageApi, cancelConversationTurn, cancelQueuedConversationTurn, cancelSubagentRun, compactConversation as compactConversationApi, scheduleContextUsageRefresh, loadConversation as loadConversationApi, loadSharedConversation, updateConversationDraft as updateConversationDraftApi, updateMessageShareVisibility, launchSessionWorker } from './api-client.js';
@@ -62,6 +60,7 @@ import { buildWorkflowRunCard } from './background-tasks-view.mjs';
62
60
  import { mergeLiveSubagentRun, subagentRowsMarkup } from './subagent-row.mjs';
63
61
  import { rememberSubagentRuns, syncSubagentRowClock } from './subagent-viewer.mjs';
64
62
  import { isMessagePinned, toggleMessagePin } from './pinned-messages-view.mjs';
63
+ import { isMessageHiddenFromShares, setConversationHiddenMessages } from './hidden-messages-view.mjs';
65
64
  import { parsePreviewCommand, runPreviewCommand } from './preview-command.mjs';
66
65
  import { buildTranscriptPreviewCard } from './preview-cards.mjs';
67
66
  import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomplete } from './slash-autocomplete.mjs';
@@ -71,7 +70,6 @@ import { isRelayEventMessage, renderRelayEventRowHtml } from './relay-event-view
71
70
  import {
72
71
  AGENT_EXCHANGE_CLOSED_CLASS,
73
72
  AGENT_EXCHANGE_HEAD_CLASS,
74
- AGENT_EXCHANGE_MEMBER_CLASS,
75
73
  agentExchangeFoldOpen,
76
74
  agentExchangeRowOf,
77
75
  agentExchangeSummary,
@@ -135,6 +133,8 @@ import {
135
133
  shouldApplyIncomingDraftToComposer,
136
134
  } from './conversation-draft-sync.mjs';
137
135
  import { isChatInteractionHeld, selectionIntersectsNode } from './selection-guard.mjs';
136
+ import { captureReadingAnchor, readingAnchorContentTop, readingAnchorHoldStep, readingAnchorScrollDelta } from './transcript-reading-anchor.mjs';
137
+ import { captureReadingBlock, restoreReadingBlock } from './transcript-viewport-anchor.mjs';
138
138
  import {
139
139
  applyThinkingFollow,
140
140
  getThinkingFollowMode,
@@ -145,9 +145,12 @@ import {
145
145
  toggleThinkingFollowMode,
146
146
  unobserveThinkingBubble,
147
147
  } from './thinking-follow.js';
148
+ import { beginOwnSend, followsTranscriptEnd, jumpTranscriptToBottom, nameOwnSendConversation, ownSendFollows, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
149
+ import { createHeldBackLiveRows, newerPageReachesEnd } from './transcript-island.mjs';
148
150
  import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
149
151
  import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
150
152
  import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
153
+ import { conversationPayloadCache } from './conversation-payload-cache.mjs';
151
154
 
152
155
  const CONVERSATION_HISTORY_PAGE_SIZE = 20;
153
156
  const HISTORY_LOAD_MORE_ID = 'history-load-more';
@@ -241,6 +244,20 @@ let conversationHistoryState = {
241
244
  loadingOlder: false,
242
245
  loadingNewer: false,
243
246
  };
247
+ // Live rows for a window that ends above the conversation's end: held back
248
+ // until the newer pages bring them (transcript-island.mjs).
249
+ const heldBackLiveRows = createHeldBackLiveRows();
250
+
251
+ // After every change of conversationHistoryState: a window that meets the
252
+ // end, or another conversation's, holds no rows back. The button's count
253
+ // changes without the page changing, so it is told.
254
+ function syncHeldBackLiveRows() {
255
+ const released = heldBackLiveRows.follow({
256
+ conversationId: conversationHistoryState.conversationId,
257
+ atTail: !conversationHistoryState.hasMoreNewer,
258
+ });
259
+ if (released) refreshJumpButton();
260
+ }
244
261
 
245
262
  function renderImageEditTarget() {
246
263
  const chip = document.getElementById('image-edit-target');
@@ -356,6 +373,8 @@ const conversationHistoryLoader = createInfiniteLoader({
356
373
  const previousScrollTop = el.scrollTop;
357
374
  const previousScrollHeight = el.scrollHeight;
358
375
  const inserted = prependMessageNodes(page.items || []);
376
+ // The kept window grows with the page: a switch back shows these rows too.
377
+ if (!IS_SHARED_VIEW) conversationPayloadCache.addOlder(currentId, page.items || [], { hasMoreOlder: page.hasMore });
359
378
  setConversationHistoryState({
360
379
  conversationId: currentId,
361
380
  hasMoreOlder: page.hasMore,
@@ -383,6 +402,10 @@ const conversationHistoryLoader = createInfiniteLoader({
383
402
  if (!el || String(currentConvId || '').trim() !== currentId) return;
384
403
  const nextScrollHeight = el.scrollHeight;
385
404
  el.scrollTop = previousScrollTop + (nextScrollHeight - previousScrollHeight);
405
+ // The page's images have no height yet: the place read is held while
406
+ // they come in, as after a restore.
407
+ const place = captureConversationReadingAnchor();
408
+ if (place?.conversationId === currentId) holdReadingAnchorWhileMediaLoads(el, place.anchor);
386
409
  void conversationHistoryLoader.handleBoundaryDistance(el.scrollTop);
387
410
  });
388
411
  },
@@ -429,6 +452,24 @@ const conversationFutureLoader = createInfiniteLoader({
429
452
  const currentId = String(currentConvId || '').trim();
430
453
  if (!currentId || conversationPaginationCursorIsStale(currentId)) return;
431
454
  const ordered = sortConversationMessages(page.items || []);
455
+ // The last page may predate a row held back meanwhile: one more is asked
456
+ // for, from this page's newest row. The loader reads hasMore and the
457
+ // cursor off this page once it is applied.
458
+ const heldBackIds = heldBackLiveRows.ids(currentId);
459
+ if (!page.hasMore && heldBackIds.length && !newerPageReachesEnd({
460
+ pageHasMore: page.hasMore,
461
+ pageMessageIds: ordered.map((m) => m?.id),
462
+ heldBackIds,
463
+ renderedIds: renderedMessageIds(),
464
+ })) {
465
+ const newest = ordered[ordered.length - 1];
466
+ page.hasMore = true;
467
+ page.nextCursor = {
468
+ afterMessageId: String(newest?.id || '').trim() || null,
469
+ afterTimestamp: String(newest?.timestamp || '').trim() || null,
470
+ };
471
+ }
472
+ if (!IS_SHARED_VIEW) conversationPayloadCache.addNewer(currentId, ordered, { hasMoreNewer: page.hasMore });
432
473
  let inserted = 0;
433
474
  withSuspendedSeparatorSync(() => {
434
475
  for (const m of ordered) {
@@ -450,6 +491,9 @@ const conversationFutureLoader = createInfiniteLoader({
450
491
  loadingOlder: conversationHistoryState.loadingOlder,
451
492
  loadingNewer: false,
452
493
  });
494
+ // The window meets the end: a turn still running gets its live bubble,
495
+ // which was not drawn under the window while it ended above.
496
+ if (!page.hasMore) showLiveBubbleOfRunningTurn();
453
497
  },
454
498
  onError: (error, { mode }) => {
455
499
  if (mode === 'load') {
@@ -462,6 +506,7 @@ const conversationFutureLoader = createInfiniteLoader({
462
506
  hasMoreNewer: state.hasMore,
463
507
  loadingNewer: state.isLoading,
464
508
  };
509
+ syncHeldBackLiveRows();
465
510
  },
466
511
  });
467
512
 
@@ -1179,6 +1224,7 @@ function resetConversationHistoryState() {
1179
1224
  conversationFutureLoader.reset({ hasMore: false, nextCursor: null });
1180
1225
  saveConversationLoadedMessageCount(conversationId, 0);
1181
1226
  syncHistoryLoadMoreControl();
1227
+ syncHeldBackLiveRows();
1182
1228
  }
1183
1229
 
1184
1230
  function setConversationHistoryState(next = {}) {
@@ -1199,6 +1245,7 @@ function setConversationHistoryState(next = {}) {
1199
1245
  conversationHistoryState.loadedMessageCount,
1200
1246
  );
1201
1247
  syncHistoryLoadMoreControl();
1248
+ syncHeldBackLiveRows();
1202
1249
  }
1203
1250
 
1204
1251
  function getConversationHistoryCursor() {
@@ -1289,38 +1336,20 @@ function createRelayEventNode(msg, msgId) {
1289
1336
  return div;
1290
1337
  }
1291
1338
 
1292
- // Agent exchanges (agent-exchange-fold-view.mjs): an agent's prompt and this
1293
- // session's reply fold into one row. What the fold pass needs of a message is
1294
- // kept on its node when the row is built, so the pass reads the transcript
1295
- // itself and not the payload it came from.
1339
+ // Agent exchanges (agent-exchange-fold-view.mjs): an agent's prompt folds
1340
+ // into one row; this session's reply to it always shows. What the fold pass
1341
+ // needs of a message is kept on its node when the row is built, so the pass
1342
+ // reads the transcript itself and not the payload it came from.
1296
1343
  const AGENT_EXCHANGE_ROW_KEY = '__agentExchangeRow';
1297
- // The user's toggle of an exchange, by its prompt's id: true opened, false
1344
+ // The user's toggle of a folded prompt, by its id: true opened, false
1298
1345
  // closed, unset none (agentExchangeFoldOpen: open while it works, else
1299
1346
  // folded). Kept through any re-render.
1300
1347
  const agentExchangeFoldChoices = new Map();
1301
- // Exchanges shown for the user while this page lives: a turn that had an open
1302
- // card, or ran while a message of the user's waited, stays shown once that
1303
- // has passed instead of folding away under them. A reload folds it again.
1304
- const agentExchangesShownForUser = new Set();
1305
- // Each folded row's prompt id, by message id, and each folded exchange's
1306
- // status, by prompt id, as of the last pass.
1307
- let agentExchangePromptIdByMessageId = new Map();
1348
+ // Each folded prompt's status, by its id, as of the last pass.
1308
1349
  let agentExchangeStatusByPromptId = new Map();
1309
1350
 
1310
1351
  function setAgentExchangeRow(div, msg, msgId) {
1311
- div[AGENT_EXCHANGE_ROW_KEY] = agentExchangeRowOf(msg, { id: msgId || msg?.id || '', parseTimestamp: parseTimestampMs });
1312
- }
1313
-
1314
- // The turns an open question or approval card (a plan board) belongs to.
1315
- function openCardMessageIds() {
1316
- const ids = new Set();
1317
- for (const card of [...relayQuestions.values(), ...relayBoards.values()]) {
1318
- if (card?.status !== 'pending') continue;
1319
- for (const id of [card.messageId, card.queueId]) {
1320
- if (id) ids.add(String(id).trim());
1321
- }
1322
- }
1323
- return ids;
1352
+ div[AGENT_EXCHANGE_ROW_KEY] = agentExchangeRowOf(msg, { id: msgId || msg?.id || '' });
1324
1353
  }
1325
1354
 
1326
1355
  // The rows the plan reads, in order, and the node of each. The live bubble
@@ -1345,58 +1374,45 @@ function agentExchangeTranscriptRows(el) {
1345
1374
  }
1346
1375
 
1347
1376
  /**
1348
- * Fold the transcript's agent exchanges. One idempotent pass, run after every
1377
+ * Fold the transcript's agent prompts. One idempotent pass, run after every
1349
1378
  * render and update of the list like the separator and merge passes. The
1350
1379
  * folded row lives in the prompt's own node, as its first child, so no
1351
- * insertion path has to place it; closed, classes hide the prompt's bubble and
1352
- * the exchange's other rows, which stay in the list and go on updating.
1380
+ * insertion path has to place it; closed, a class hides the prompt's bubble.
1381
+ * The replies and the live bubble are read for the row's status and are
1382
+ * otherwise left alone.
1353
1383
  */
1354
1384
  function syncAgentExchangeFolds(el = getMessagesElement()) {
1355
1385
  if (!el) return;
1356
1386
  const { rows, nodes } = agentExchangeTranscriptRows(el);
1357
1387
  const plan = planAgentExchangeFolds(rows, {
1358
1388
  runningIds: [getActiveTurnForConversation(currentConvId)?.messageId],
1359
- openCardIds: openCardMessageIds(),
1360
- keptShownIds: agentExchangesShownForUser,
1361
1389
  });
1362
1390
  const states = new Map();
1363
- const promptIdByMessageId = new Map();
1364
1391
  const statusByPromptId = new Map();
1365
1392
  for (const group of plan) {
1366
- if (group.transient) agentExchangesShownForUser.add(group.promptId);
1367
1393
  if (!group.fold) continue;
1368
1394
  const open = agentExchangeFoldOpen(group.status, agentExchangeFoldChoices.get(group.promptId));
1369
- states.set(nodes.get(group.prompt), { group, head: true, open });
1370
- promptIdByMessageId.set(group.promptId, group.promptId);
1395
+ states.set(nodes.get(group.prompt), { group, open });
1371
1396
  statusByPromptId.set(group.promptId, group.status);
1372
- for (const member of group.members) {
1373
- states.set(nodes.get(member), { group, head: false, open });
1374
- promptIdByMessageId.set(member.id, group.promptId);
1375
- }
1376
1397
  }
1377
- agentExchangePromptIdByMessageId = promptIdByMessageId;
1378
1398
  agentExchangeStatusByPromptId = statusByPromptId;
1379
1399
  for (const node of el.children) {
1380
1400
  if (node.classList?.contains('msg')) applyAgentExchangeState(node, states.get(node) || null);
1381
1401
  }
1382
1402
  }
1383
1403
 
1384
- // For the paths that change the list outside a render: the live bubble, a
1385
- // card opening. A bulk render runs the pass once at its end.
1404
+ // For the paths that change the list outside a render: the live bubble
1405
+ // coming and going. A bulk render runs the pass once at its end.
1386
1406
  export function syncAgentExchangeFoldsNow() {
1387
1407
  if (separatorSyncSuspended > 0) return;
1388
1408
  syncAgentExchangeFolds();
1389
1409
  }
1390
1410
 
1391
1411
  function applyAgentExchangeState(node, state) {
1392
- node.classList.toggle(AGENT_EXCHANGE_HEAD_CLASS, !!state?.head);
1393
- node.classList.toggle(AGENT_EXCHANGE_MEMBER_CLASS, !!state && !state.head);
1412
+ node.classList.toggle(AGENT_EXCHANGE_HEAD_CLASS, !!state);
1394
1413
  node.classList.toggle(AGENT_EXCHANGE_CLOSED_CLASS, !!state && !state.open);
1395
- // The jump-to-bottom badge counts a fold's rows as one by this id.
1396
- if (state) node.dataset.agentExchangePromptId = state.group.promptId;
1397
- else delete node.dataset.agentExchangePromptId;
1398
1414
  const existing = node.querySelector(':scope > .agent-exchange-row');
1399
- if (!state?.head) {
1415
+ if (!state) {
1400
1416
  existing?.remove();
1401
1417
  return;
1402
1418
  }
@@ -1430,11 +1446,11 @@ function onAgentExchangeToggle(row) {
1430
1446
  syncAgentExchangeFolds();
1431
1447
  }
1432
1448
 
1433
- // A search result, a pin or a jump inside a folded exchange opens it first:
1434
- // a hidden row has nowhere to scroll to.
1449
+ // A search result, a pin or a jump to a folded prompt opens it first, so
1450
+ // the message jumped to is the one on screen.
1435
1451
  function revealAgentExchangeMessage(messageId) {
1436
- const promptId = agentExchangePromptIdByMessageId.get(String(messageId || '').trim());
1437
- if (!promptId || agentExchangeFoldChoices.get(promptId) === true) return;
1452
+ const promptId = String(messageId || '').trim();
1453
+ if (!agentExchangeStatusByPromptId.has(promptId) || agentExchangeFoldChoices.get(promptId) === true) return;
1438
1454
  agentExchangeFoldChoices.set(promptId, true);
1439
1455
  syncAgentExchangeFolds();
1440
1456
  }
@@ -1523,10 +1539,11 @@ function createMessageNode(msg, msgId = null, force = false) {
1523
1539
  // (e.g. the Copilot relay answering a Cursor conversation) must be visible,
1524
1540
  // not silent: it ran on another plan than the header indicates.
1525
1541
  const executedProvider = String(msg?.executedProvider || '').trim().toLowerCase();
1526
- const boundProvider = String(conversations[currentConvId]?.runtimeProviderType || 'github').trim().toLowerCase();
1542
+ const boundProvider = String(conversations[currentConvId]?.runtimeProviderType || '').trim().toLowerCase();
1527
1543
  // 'unknown' means the responder identity did not resolve, not that another
1528
- // provider ran the turn — no chip for it.
1529
- const crossProviderTag = (msg.role === 'assistant' && executedProvider && executedProvider !== 'unknown' && executedProvider !== boundProvider)
1544
+ // provider ran the turn — no chip for it. Nor without a bound provider to
1545
+ // compare with (a shared view of a conversation that has none).
1546
+ const crossProviderTag = (msg.role === 'assistant' && executedProvider && executedProvider !== 'unknown' && boundProvider && executedProvider !== boundProvider)
1530
1547
  ? ` <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>`
1531
1548
  : '';
1532
1549
  const usage = (msg.role === 'assistant' && msg?.usage && typeof msg.usage === 'object') ? msg.usage : null;
@@ -1585,7 +1602,7 @@ function createMessageNode(msg, msgId = null, force = false) {
1585
1602
  rememberSubagentRuns(subagentRuns);
1586
1603
  const subagentHtml = subagentRowsMarkup(
1587
1604
  subagentRuns.map((run) => mergeLiveSubagentRun(run, getSubagentRun(run.subagentRunId))),
1588
- { clickable: !IS_SHARED_VIEW },
1605
+ { clickable: true },
1589
1606
  );
1590
1607
  // Finished background workflows persisted with this assistant message
1591
1608
  // (docs/plans/workflow-progress-tree.md, Phase 4). The template only
@@ -1614,7 +1631,9 @@ function createMessageNode(msg, msgId = null, force = false) {
1614
1631
  && cancellableSteerIdsForConversation(currentConvId).has(msgId);
1615
1632
  const showsCancelButton = isQueuedUserMessage || isCancellableSteer;
1616
1633
  const isCancelInFlight = showsCancelButton && bubbleCancelInFlight.has(msgId);
1617
- const hiddenFromShares = msg?.hiddenFromShares === true;
1634
+ // The conversation's hidden list once it is known (a change on another
1635
+ // device arrives there), else the message's own mark.
1636
+ const hiddenFromShares = isMessageHiddenFromShares(currentConvId, msgId) ?? (msg?.hiddenFromShares === true);
1618
1637
  const activeTurnMessageId = String(getActiveTurnForConversation(currentConvId)?.messageId || '').trim();
1619
1638
  const sourceMessageId = String(msg?.sourceMessageId || '').trim();
1620
1639
  const belongsToActiveTurn = !!activeTurnMessageId
@@ -1627,8 +1646,8 @@ function createMessageNode(msg, msgId = null, force = false) {
1627
1646
  const shareVisibilityActionHtml = canToggleShareVisibility
1628
1647
  ? `<div class="msg-share-visibility">
1629
1648
  ${buildMessagePinControlsHtml(msgId, pinned)}
1630
- ${hiddenFromShares ? '<span class="msg-hidden-label">Hidden from shared viewers</span>' : ''}
1631
- <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>
1649
+ ${hiddenFromShares ? HIDDEN_LABEL_HTML : ''}
1650
+ <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>
1632
1651
  </div>`
1633
1652
  : '';
1634
1653
  // A terminal failure whose fix lives in the relay UI gets the fix as a button
@@ -1763,21 +1782,12 @@ function prependMessageNodes(msgs) {
1763
1782
  return { inserted, firstMessageId };
1764
1783
  }
1765
1784
 
1785
+ // A shared view's tool lines arrive filtered by the relay's path privacy
1786
+ // setting (server/services/share-path-redaction.mjs); they are shown as sent.
1766
1787
  export function decorateActivityText(text) {
1767
1788
  const value = String(text || '').trim();
1768
1789
  if (!value) return '';
1769
- const maskSharedPathSegments = (input) => {
1770
- const source = String(input || '');
1771
- const tokenMasked = source.replace(/@(file|folder):([^\s`]+)/gi, (_m, kind, rawPath) => {
1772
- const normalized = String(rawPath || '').replace(/\\/g, '/').replace(/\/+$/, '');
1773
- const segments = normalized.split('/').filter(Boolean);
1774
- const basename = segments[segments.length - 1] || normalized;
1775
- return `@${String(kind || '').toLowerCase()}:${basename}`;
1776
- });
1777
- return tokenMasked.replace(/([A-Za-z]:)?(?:[\\/~.]?[\\/])(?:[^\\/\s]+[\\/])+([^\\/\s]+)/g, (_m, _prefix, basename) => basename);
1778
- };
1779
- const sharedSafeValue = IS_SHARED_VIEW ? maskSharedPathSegments(value) : value;
1780
- return prefixToolActivityEmoji(sharedSafeValue);
1790
+ return prefixToolActivityEmoji(value);
1781
1791
  }
1782
1792
 
1783
1793
  export function renderThoughtsMarkup(thoughts) {
@@ -1895,8 +1905,7 @@ function anchorThinkingBubble(div, messageId) {
1895
1905
  if (next) el.insertBefore(div, next);
1896
1906
  else el.appendChild(div);
1897
1907
  if (div.dataset.compactBoundary) syncSeparatorsNow();
1898
- // Under a folded agent prompt the bubble folds with it, and the row says
1899
- // the turn is working.
1908
+ // A folded agent prompt's row says the turn is working.
1900
1909
  else syncAgentExchangeFoldsNow();
1901
1910
  } else if (div.parentNode !== el) {
1902
1911
  el.appendChild(div);
@@ -1913,6 +1922,16 @@ export function showThinking(messageId = null, autoScroll = true) {
1913
1922
  const nextMessageId = String(messageId || '').trim();
1914
1923
  if (nextMessageId) thinkingMessageId = nextMessageId;
1915
1924
  const existing = document.getElementById('thinking-indicator');
1925
+ // A window that ends above the conversation's end shows no live bubble:
1926
+ // under it, it would sit below rows that are not loaded. The turn's
1927
+ // thoughts, tools and text are kept all the same; the bubble is drawn once
1928
+ // the window meets the end.
1929
+ if (!isConversationWindowAtTail()) {
1930
+ // Through the same path as removeThinking, so the separators and the
1931
+ // agent folds no longer show the bubble either; the turn's id stays.
1932
+ if (existing) takeThinkingBubbleOff();
1933
+ return;
1934
+ }
1916
1935
  if (existing
1917
1936
  && existing.dataset.conversationId === thinkingConversationKey()
1918
1937
  && (!nextMessageId || String(existing.dataset.messageId || '') === nextMessageId)) {
@@ -1973,6 +1992,14 @@ export function showThinking(messageId = null, autoScroll = true) {
1973
1992
  export function removeThinking() {
1974
1993
  thinkingMessageId = null;
1975
1994
  lastInFlightSnapshotKey = '';
1995
+ takeThinkingBubbleOff();
1996
+ }
1997
+
1998
+ // The live bubble leaves the page, with what hangs on it (its compaction
1999
+ // line, the agent folds' "working"). The turn it shows is not forgotten:
2000
+ // removeThinking does that; a window that stops reaching the end only hides
2001
+ // the bubble until it meets the end again (showThinking).
2002
+ function takeThinkingBubbleOff() {
1976
2003
  const hadBubble = !!document.getElementById('thinking-indicator');
1977
2004
  const wasCompacting = !!document.getElementById('thinking-indicator')?.dataset?.compactBoundary;
1978
2005
  unobserveThinkingBubble();
@@ -1985,6 +2012,43 @@ export function removeThinking() {
1985
2012
  if (hadBubble) syncAgentExchangeFoldsNow();
1986
2013
  }
1987
2014
 
2015
+ // The turn ends under a reader who is not at the end: the live bubble is
2016
+ // taken off the page and the reply row is put in its place. The page is
2017
+ // shorter for a moment (scrollTop can be clamped), and the reply is not as
2018
+ // tall as the bubble was (its thoughts are folded). Whatever the reader looks
2019
+ // at stays where it is: the block of text at the top of the viewport is noted
2020
+ // before the swap and put back after it. Read in the bubble's stream, it is
2021
+ // found again in the reply; in a row above or below the bubble, in that row.
2022
+ export function captureLiveReplyReadingPosition() {
2023
+ const el = getMessagesElement();
2024
+ if (!el) return null;
2025
+ const bubble = document.getElementById('thinking-indicator');
2026
+ return {
2027
+ scrollTop: el.scrollTop,
2028
+ block: captureReadingBlock(el),
2029
+ // Below the bubble the pixel position is the wrong fallback: everything
2030
+ // the reader sees moves up or down with the swap, and scrollTop with it.
2031
+ belowBubble: !!bubble && bubble.getBoundingClientRect().bottom <= el.getBoundingClientRect().top,
2032
+ };
2033
+ }
2034
+
2035
+ export function restoreLiveReplyReadingPosition(position) {
2036
+ const el = getMessagesElement();
2037
+ if (!el || !position || isChatInteractionHeld()) return;
2038
+ if (restoreReadingBlock(el, position.block)) return;
2039
+ if (!position.belowBubble) el.scrollTop = position.scrollTop;
2040
+ }
2041
+
2042
+ // A rebuild of the transcript on the page that nobody asked to scroll: the
2043
+ // reader keeps the text they are at, or failing that the pixel position.
2044
+ function renderMessagesInPlace(msgs, meta) {
2045
+ const el = getMessagesElement();
2046
+ const reading = el ? { scrollTop: el.scrollTop, block: captureReadingBlock(el) } : null;
2047
+ const rendered = renderMessages(msgs, false, meta);
2048
+ if (el && reading && rendered && !restoreReadingBlock(el, reading.block)) el.scrollTop = reading.scrollTop;
2049
+ return rendered;
2050
+ }
2051
+
1988
2052
  // The live turn's compaction line: the bubble stamps the turn's compaction
1989
2053
  // for the separator pass — "Compacting context…" while the last compaction
1990
2054
  // entry is a start without an end, the final label once the boundary is in —
@@ -2749,11 +2813,16 @@ export function applyRelayStreamEvent({
2749
2813
 
2750
2814
  export function flushDeferredMessageRender() {
2751
2815
  if (!deferredMessageRender) return;
2752
- const { msgs, scroll, meta } = deferredMessageRender;
2816
+ const { msgs, meta } = deferredMessageRender;
2753
2817
  deferredMessageRender = null;
2754
2818
  const deferredConversationId = String(meta?.conversationId || '').trim();
2755
2819
  if (deferredConversationId && deferredConversationId !== String(currentConvId || '').trim()) return;
2756
- renderMessages(msgs, scroll, meta);
2820
+ // Whoever asked for this render has long returned, and with it whatever
2821
+ // would have put the reader back; and where the reader was when it was
2822
+ // held back is not where they are now. Decided as it runs: at the end it
2823
+ // follows, anywhere else it leaves them at the text they read.
2824
+ if (followsTranscriptEnd()) renderMessages(msgs, true, meta);
2825
+ else renderMessagesInPlace(msgs, meta);
2757
2826
  }
2758
2827
 
2759
2828
  // Blacklists the id from further live relay_stream frames. Kept separate from
@@ -3077,6 +3146,7 @@ export function replaceRenderedMessage(msg, msgId) {
3077
3146
  }
3078
3147
 
3079
3148
  async function resendStoppedSteer(conversationId, markerId) {
3149
+ const send = beginOwnSend();
3080
3150
  const conversationKey = String(conversationId || '').trim();
3081
3151
  const id = String(markerId || '').trim();
3082
3152
  if (!conversationKey || !id || stoppedSteerResends.has(id)) return;
@@ -3118,9 +3188,12 @@ async function resendStoppedSteer(conversationId, markerId) {
3118
3188
  showTransientRelayNotice('Select a reasoning effort after refreshing model metadata.');
3119
3189
  return;
3120
3190
  }
3191
+ // As a send from the composer (loadEndForOwnSend): the Resend goes to
3192
+ // the end.
3193
+ if (isCurrentConversation(conversationKey)) await loadEndForOwnSend();
3121
3194
  trackPendingUserMessage(clientMessageId, conversationKey, text, { attachments });
3122
3195
  pendingUserMessageIds.add(clientMessageId);
3123
- if (isCurrentConversation(conversationKey)) {
3196
+ if (isCurrentConversation(conversationKey) && isConversationWindowAtTail()) {
3124
3197
  appendMessage({
3125
3198
  role: 'user',
3126
3199
  text,
@@ -3128,7 +3201,7 @@ async function resendStoppedSteer(conversationId, markerId) {
3128
3201
  mode: selectedMode,
3129
3202
  timestamp: new Date().toISOString(),
3130
3203
  attachments,
3131
- }, true, clientMessageId, true);
3204
+ }, ownSendFollows(send), clientMessageId, true);
3132
3205
  bubbleAppended = true;
3133
3206
  }
3134
3207
  const r = await sendMessageApi({
@@ -3172,7 +3245,7 @@ async function resendStoppedSteer(conversationId, markerId) {
3172
3245
  showTransientRelayNotice(`Resent without ${dropped} attachment${dropped === 1 ? '' : 's'} that ${dropped === 1 ? 'is' : 'are'} no longer available.`, 7000);
3173
3246
  }
3174
3247
  if (cliOnline && isCurrentConversation(conversationKey) && !getActiveTurnForConversation(conversationKey)?.messageId) {
3175
- showThinking(r.messageId || null);
3248
+ showThinking(r.messageId || null, ownSendFollows(send));
3176
3249
  }
3177
3250
  } catch (error) {
3178
3251
  if (!hasSendEchoArrived(clientMessageId)) {
@@ -3341,6 +3414,35 @@ export function syncRenderedPinState(pinnedIds) {
3341
3414
  }
3342
3415
  }
3343
3416
 
3417
+ const HIDDEN_LABEL_HTML = '<span class="msg-hidden-label">Hidden from shared viewers</span>';
3418
+
3419
+ function shareVisibilityButtonTitle(hidden) {
3420
+ return hidden ? 'Shows this message in shared conversations' : 'Hides this message from shared conversations';
3421
+ }
3422
+
3423
+ // The hidden list changed (this page, another device): bring the bubbles on
3424
+ // the page in line without rebuilding them. A button whose own round trip is
3425
+ // still out keeps its "Hiding…" until the answer.
3426
+ export function syncRenderedShareVisibility(hiddenIds) {
3427
+ const el = getMessagesElement();
3428
+ if (!el) return;
3429
+ const ids = hiddenIds instanceof Set ? hiddenIds : new Set(hiddenIds || []);
3430
+ for (const button of el.querySelectorAll('.msg-share-visibility-btn')) {
3431
+ const messageId = String(button.dataset.messageId || '').trim();
3432
+ if (shareVisibilityInFlight.has(messageId)) continue;
3433
+ const hidden = ids.has(messageId);
3434
+ if ((button.dataset.hiddenFromShares === 'true') === hidden) continue;
3435
+ button.dataset.hiddenFromShares = hidden ? 'true' : 'false';
3436
+ button.disabled = false;
3437
+ button.textContent = hidden ? 'Unhide' : 'Hide';
3438
+ button.title = shareVisibilityButtonTitle(hidden);
3439
+ const sibling = button.previousElementSibling;
3440
+ const label = sibling?.classList?.contains('msg-hidden-label') ? sibling : null;
3441
+ if (hidden && !label) button.insertAdjacentHTML('beforebegin', HIDDEN_LABEL_HTML);
3442
+ if (!hidden && label) label.remove();
3443
+ }
3444
+ }
3445
+
3344
3446
  async function toggleMessageShareVisibility(conversationId, messageId, hiddenFromShares) {
3345
3447
  const conversationKey = String(conversationId || '').trim();
3346
3448
  const targetMessageId = String(messageId || '').trim();
@@ -3359,13 +3461,17 @@ async function toggleMessageShareVisibility(conversationId, messageId, hiddenFro
3359
3461
  button.disabled = false;
3360
3462
  button.textContent = hiddenFromShares ? 'Unhide' : 'Hide';
3361
3463
  }
3362
- showTransientRelayNotice('Could not update shared-message visibility.');
3464
+ showTransientRelayNotice(String(result?.error || '').trim() || 'Could not update shared-message visibility.');
3363
3465
  return;
3364
3466
  }
3365
3467
 
3366
3468
  showTransientRelayNotice(result.hiddenFromShares
3367
3469
  ? 'Message hidden from shared viewers.'
3368
3470
  : 'Message visible to shared viewers.');
3471
+ // Stored under its own conversation: the 🙈 count, even after a switch.
3472
+ if ('hiddenMessages' in result) {
3473
+ setConversationHiddenMessages(conversationKey, result.hiddenMessages, result.hiddenMessagesRevision);
3474
+ }
3369
3475
  // The reload below only refreshes the open view; if the user switched
3370
3476
  // conversations while the toggle round trip was in flight, rendering it
3371
3477
  // would replace the new conversation's messages with this one's.
@@ -3375,7 +3481,7 @@ async function toggleMessageShareVisibility(conversationId, messageId, hiddenFro
3375
3481
  });
3376
3482
  if (String(currentConvId || '').trim() !== conversationKey) return;
3377
3483
  if (response?.messages) {
3378
- renderMessages(response.messages, false, response);
3484
+ renderMessagesInPlace(response.messages, response);
3379
3485
  }
3380
3486
  }
3381
3487
 
@@ -3545,8 +3651,8 @@ export function appendMessage(msg, scroll = true, msgId = null, force = false, i
3545
3651
  const node = createMessageNode(msg, msgId, force);
3546
3652
  const isNewNode = !!node && !node.parentNode;
3547
3653
  // A reply the socket delivers without its prompt's id in the message (a
3548
- // failed turn's note) is placed under that prompt, and belongs to its
3549
- // agent exchange too.
3654
+ // failed turn's note) is placed under that prompt, and an agent prompt's
3655
+ // row reads its status from it.
3550
3656
  const exchangeRow = isNewNode ? node[AGENT_EXCHANGE_ROW_KEY] : null;
3551
3657
  const anchorId = String(insertAfterId || '').trim();
3552
3658
  if (exchangeRow?.role === 'assistant' && !exchangeRow.sourceMessageId && anchorId && anchorId !== String(msgId || '').trim()) {
@@ -3771,6 +3877,8 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
3771
3877
  return false;
3772
3878
  }
3773
3879
  deferredMessageRender = null;
3880
+ // Other rows: whoever renders them puts the reader in place.
3881
+ releaseReadingAnchorHold();
3774
3882
  const messageById = new Map(
3775
3883
  ordered
3776
3884
  .map((item) => [String(item?.id || '').trim(), item])
@@ -3828,7 +3936,9 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
3828
3936
  // in as an extra row, and another conversation's bubble must not leak into
3829
3937
  // this one (it is dropped instead, so the next restore builds afresh).
3830
3938
  const thinkingNodeInTranscript = !!thinkingNode && thinkingNode.parentNode === el;
3939
+ // Nor under a window that ends above the conversation's end (showThinking).
3831
3940
  const preservedThinkingNode = (thinkingNodeInTranscript
3941
+ && !hasMoreNewer
3832
3942
  && thinkingNode.dataset.conversationId === renderConversationKey) ? thinkingNode : null;
3833
3943
  if (thinkingNodeInTranscript && !preservedThinkingNode) removeThinking();
3834
3944
  if (!ordered.length && !preservedPendingNodes.length && !preservedThinkingNode) {
@@ -3942,12 +4052,348 @@ export function isConversationWindowAtTail() {
3942
4052
  return !conversationHistoryState.hasMoreNewer;
3943
4053
  }
3944
4054
 
4055
+ /**
4056
+ * A live row for `conversationId` arrived (a message, a reply). True when it
4057
+ * is held back instead of put on the page: the window on the page is that
4058
+ * conversation's and ends above its end (transcript-island.mjs). The window
4059
+ * keeps loading newer pages from its own newest row, and the jump-to-bottom
4060
+ * button counts the row.
4061
+ */
4062
+ export function holdBackLiveRow(conversationId, messageId) {
4063
+ const id = String(conversationId || '').trim();
4064
+ if (!id || isConversationWindowAtTail() || getRenderedConversationId() !== id) return false;
4065
+ heldBackLiveRows.note(id, messageId);
4066
+ // A reload whose request was on its way may lack this row as it would one
4067
+ // put on the page: counted, so it is asked again (loadKeepingLiveRows)
4068
+ // rather than rendered over the rows held.
4069
+ noteTranscriptLiveRow();
4070
+ refreshJumpButton();
4071
+ return true;
4072
+ }
4073
+
4074
+ /** How many live rows wait below the window on the page. */
4075
+ export function getHeldBackLiveRowCount() {
4076
+ return heldBackLiveRows.count(getRenderedConversationId());
4077
+ }
4078
+
4079
+ /** The ids of the live rows that wait below the window on the page, oldest first. */
4080
+ export function getHeldBackLiveRowIds() {
4081
+ return heldBackLiveRows.ids(getRenderedConversationId());
4082
+ }
4083
+
4084
+ function renderedMessageIds() {
4085
+ const el = getMessagesElement();
4086
+ if (!el) return [];
4087
+ return Array.from(readingAnchorRows(el), (node) => String(node.dataset.messageId || '').trim());
4088
+ }
4089
+
4090
+ // The running turn's live bubble, drawn from what the page kept of it (its
4091
+ // thoughts, tools and text arrived while no bubble was drawn).
4092
+ function showLiveBubbleOfRunningTurn() {
4093
+ if (document.getElementById('thinking-indicator')) return;
4094
+ const turn = getActiveTurnForConversation(currentConvId);
4095
+ const messageId = String(turn?.messageId || '').trim();
4096
+ if (!messageId || String(turn?.status || '').trim().toLowerCase() !== 'processing') return;
4097
+ showThinking(messageId, false);
4098
+ renderThinkingStream();
4099
+ renderThinkingActivities();
4100
+ renderThinkingThoughts();
4101
+ renderRestoredSubagentBubbles(messageId);
4102
+ }
4103
+
4104
+ /** The conversation whose rows the transcript holds; during a switch, still the one left. */
4105
+ export function getRenderedConversationId() {
4106
+ return String(conversationHistoryState.conversationId || '').trim();
4107
+ }
4108
+
4109
+ // The message rows, without the live bubble: it carries its prompt's id.
4110
+ function readingAnchorRows(el) {
4111
+ return el.querySelectorAll(':scope > .msg[data-message-id]:not(#thinking-indicator)');
4112
+ }
4113
+
4114
+ /**
4115
+ * Where the reader is in the transcript on the page
4116
+ * (transcript-reading-anchor.mjs), for the conversation whose rows it holds;
4117
+ * null when no row is there.
4118
+ */
4119
+ export function captureConversationReadingAnchor() {
4120
+ const el = getMessagesElement();
4121
+ const conversationId = getRenderedConversationId();
4122
+ if (!el || !conversationId) return null;
4123
+ const rows = readingAnchorRows(el);
4124
+ if (!rows.length) return null;
4125
+ const paneTop = el.getBoundingClientRect().top;
4126
+ const rectAt = (index) => rows[index].getBoundingClientRect();
4127
+ const anchor = captureReadingAnchor({
4128
+ rowCount: rows.length,
4129
+ rowAt: (index) => {
4130
+ const rect = rectAt(index);
4131
+ return { messageId: rows[index].dataset.messageId, top: rect.top, bottom: rect.bottom };
4132
+ },
4133
+ paneTop,
4134
+ atTail: isMessagesNearBottom() && isConversationWindowAtTail(),
4135
+ });
4136
+ return anchor ? { conversationId, anchor } : null;
4137
+ }
4138
+
4139
+ function readingAnchorRow(el, messageId) {
4140
+ const id = String(messageId || '').trim();
4141
+ return id ? el.querySelector(`:scope > .msg[data-message-id="${CSS.escape(id)}"]:not(#thinking-indicator)`) : null;
4142
+ }
4143
+
4144
+ /**
4145
+ * Puts the anchored message at its saved offset, or the reader at the end.
4146
+ * False when the message is not on the page. The place is then held while
4147
+ * the images on the page are still loading (holdReadingAnchorWhileMediaLoads).
4148
+ */
4149
+ export function restoreConversationReadingAnchor(anchor) {
4150
+ const el = getMessagesElement();
4151
+ if (!el || !anchor) return false;
4152
+ if (anchor.atTail) {
4153
+ releaseReadingAnchorHold();
4154
+ el.scrollTop = el.scrollHeight;
4155
+ return true;
4156
+ }
4157
+ const row = readingAnchorRow(el, anchor.messageId);
4158
+ if (!row) return false;
4159
+ const delta = readingAnchorScrollDelta({
4160
+ rowTop: row.getBoundingClientRect().top,
4161
+ paneTop: el.getBoundingClientRect().top,
4162
+ offsetPx: anchor.offsetPx,
4163
+ });
4164
+ if (Math.abs(delta) > 0.5) el.scrollTop += delta;
4165
+ holdReadingAnchorWhileMediaLoads(el, anchor);
4166
+ return true;
4167
+ }
4168
+
4169
+ // Images reserve no height until their bytes are in (the attachment payload
4170
+ // carries no dimensions), so one above the place read that arrives after the
4171
+ // place was put back pushes it down by its height. Chrome's scroll anchoring
4172
+ // absorbs that; browsers without it (iOS Safari) do not. Long enough for a
4173
+ // slow link, short enough that a forgotten hold cannot surprise anyone.
4174
+ const READING_ANCHOR_HOLD_MAX_MS = 10_000;
4175
+ let readingAnchorHold = null;
4176
+
4177
+ /**
4178
+ * Lets go of the place held while images load: whoever calls this moves the
4179
+ * reader themselves (the jump to the end, an own send).
4180
+ */
4181
+ export function releaseReadingAnchorHold() {
4182
+ const hold = readingAnchorHold;
4183
+ if (!hold) return;
4184
+ readingAnchorHold = null;
4185
+ hold.cleanup();
4186
+ }
4187
+
4188
+ function pendingTranscriptImages(el) {
4189
+ return Array.from(el.querySelectorAll('img')).filter((img) => !img.complete);
4190
+ }
4191
+
4192
+ /**
4193
+ * Keeps the anchored message at its offset while images in the transcript
4194
+ * are loading: each one that is laid out at its height (a ResizeObserver
4195
+ * fires before the frame is painted; load and error events as a fallback)
4196
+ * scrolls by what it pushed. Lets go when they are all in, after
4197
+ * READING_ANCHOR_HOLD_MAX_MS, or as soon as the reader scrolls: a wheel, a
4198
+ * touch or a key, or a change of scrollTop that no growth above the row
4199
+ * explains (a drag of the scrollbar, a jump to the end, an own send). Growth
4200
+ * below the row (a reply streaming in at the end) is not the hold's to
4201
+ * correct (readingAnchorHoldStep). A reader at the end has nothing to hold
4202
+ * here: the jump-to-bottom follower keeps them at the end.
4203
+ */
4204
+ function holdReadingAnchorWhileMediaLoads(el, anchor) {
4205
+ releaseReadingAnchorHold();
4206
+ if (!el || !anchor || anchor.atTail || !anchor.messageId) return;
4207
+ const pending = pendingTranscriptImages(el);
4208
+ if (!pending.length) return;
4209
+ const startRow = readingAnchorRow(el, anchor.messageId);
4210
+ if (!startRow) return;
4211
+ // Where the row sits in the content and the scroll, as of the last look:
4212
+ // the next look corrects by what moved the row in the content only.
4213
+ const seen = {
4214
+ rowContentTop: readingAnchorContentTop({
4215
+ rowTop: startRow.getBoundingClientRect().top,
4216
+ paneTop: el.getBoundingClientRect().top,
4217
+ scrollTop: el.scrollTop,
4218
+ }),
4219
+ scrollTop: el.scrollTop,
4220
+ };
4221
+ const check = () => {
4222
+ if (readingAnchorHold !== hold) return;
4223
+ const row = readingAnchorRow(el, anchor.messageId);
4224
+ if (!row) {
4225
+ releaseReadingAnchorHold();
4226
+ return;
4227
+ }
4228
+ const step = readingAnchorHoldStep({
4229
+ rowTop: row.getBoundingClientRect().top,
4230
+ paneTop: el.getBoundingClientRect().top,
4231
+ scrollTop: el.scrollTop,
4232
+ seen,
4233
+ });
4234
+ if (step.action === 'release') {
4235
+ releaseReadingAnchorHold();
4236
+ return;
4237
+ }
4238
+ if (step.action === 'correct') el.scrollTop += step.delta;
4239
+ seen.rowContentTop = step.rowContentTop;
4240
+ // Read back: the browser can clamp the correction.
4241
+ seen.scrollTop = el.scrollTop;
4242
+ };
4243
+ const onMediaSettled = () => {
4244
+ check();
4245
+ if (readingAnchorHold === hold && !pendingTranscriptImages(el).length) releaseReadingAnchorHold();
4246
+ };
4247
+ const onReaderInput = () => releaseReadingAnchorHold();
4248
+ // A key can scroll the pane from anywhere but a field being typed in (the
4249
+ // composer, a card's reply): typing there moves nothing.
4250
+ const onKeyDown = (event) => {
4251
+ const target = event?.target;
4252
+ if (target && (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT' || target.isContentEditable === true)) return;
4253
+ releaseReadingAnchorHold();
4254
+ };
4255
+ const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(check) : null;
4256
+ for (const img of pending) observer?.observe(img);
4257
+ el.addEventListener('load', onMediaSettled, true);
4258
+ el.addEventListener('error', onMediaSettled, true);
4259
+ // A video's box changes when its metadata is in.
4260
+ el.addEventListener('loadedmetadata', check, true);
4261
+ el.addEventListener('scroll', check, { passive: true });
4262
+ el.addEventListener('wheel', onReaderInput, { passive: true });
4263
+ el.addEventListener('touchstart', onReaderInput, { passive: true });
4264
+ document.addEventListener('keydown', onKeyDown, true);
4265
+ const timer = setTimeout(releaseReadingAnchorHold, READING_ANCHOR_HOLD_MAX_MS);
4266
+ const hold = {
4267
+ cleanup() {
4268
+ clearTimeout(timer);
4269
+ observer?.disconnect();
4270
+ el.removeEventListener('load', onMediaSettled, true);
4271
+ el.removeEventListener('error', onMediaSettled, true);
4272
+ el.removeEventListener('loadedmetadata', check, true);
4273
+ el.removeEventListener('scroll', check);
4274
+ el.removeEventListener('wheel', onReaderInput);
4275
+ el.removeEventListener('touchstart', onReaderInput);
4276
+ document.removeEventListener('keydown', onKeyDown, true);
4277
+ },
4278
+ };
4279
+ readingAnchorHold = hold;
4280
+ }
4281
+
4282
+ /**
4283
+ * The window on the page no longer meets the end of the conversation (more
4284
+ * was written than one page holds): newer rows load as after a jump into
4285
+ * the history, and the jump-to-bottom button loads the end.
4286
+ */
4287
+ export function detachConversationWindowFromTail() {
4288
+ const newestMessageId = String(conversationHistoryState.newestMessageId || '').trim();
4289
+ setConversationHistoryState({ ...conversationHistoryState, hasMoreNewer: true });
4290
+ // A live bubble on the page now sits under a window that ends above the
4291
+ // end, below rows that are not loaded: it goes until the window meets the
4292
+ // end again (showThinking draws none meanwhile).
4293
+ takeThinkingBubbleOff();
4294
+ conversationFutureLoader.reset({
4295
+ hasMore: true,
4296
+ nextCursor: {
4297
+ afterMessageId: newestMessageId || null,
4298
+ afterTimestamp: String(conversationHistoryState.newestMessageTimestamp || '').trim() || null,
4299
+ },
4300
+ });
4301
+ }
4302
+
4303
+ // One message as the transcript's snapshot key sees it.
4304
+ function messageRenderKey(message) {
4305
+ return buildMessageSnapshotKey([message], { conversationId: '', hasMoreOlder: false, hasMoreNewer: false });
4306
+ }
4307
+
4308
+ /**
4309
+ * Whether the transcript on the page can take a merge of `previousMessages`'
4310
+ * window (mergeMessagesIntoTranscript) rather than a full render: not while
4311
+ * a bubble still waits for its send, the status view is up, a selection is
4312
+ * held, another conversation's rows are on the page, or a row of the window
4313
+ * is missing from it (what is merged in is placed next to those rows, and a
4314
+ * changed one is rebuilt where it stands).
4315
+ */
4316
+ export function canMergeIntoTranscript(previousMessages, conversationId) {
4317
+ const el = getMessagesElement();
4318
+ const id = String(conversationId || '').trim();
4319
+ if (!el || !id) return false;
4320
+ if (isChatInteractionHeld()
4321
+ || el.querySelector('.status-view')
4322
+ || getRenderedConversationId() !== id
4323
+ || hasPendingUserMessageForConversation(id)) {
4324
+ return false;
4325
+ }
4326
+ const rendered = new Set(Array.from(readingAnchorRows(el), (node) => String(node.dataset.messageId || '').trim()));
4327
+ for (const message of previousMessages || []) {
4328
+ const messageId = String(message?.id || '').trim();
4329
+ if (messageId && !rendered.has(messageId)) return false;
4330
+ }
4331
+ return true;
4332
+ }
4333
+
4334
+ /**
4335
+ * Puts a merged window (mergeTailPage) on the page without rebuilding it:
4336
+ * the rows the newest page changed are rebuilt where they stand, its new ones
4337
+ * are put in their place, the rows it dropped are taken off, every other row
4338
+ * stays as it is under the reader.
4339
+ * Falls back to a full render when the page holds rows the merge cannot
4340
+ * place (a bubble still waiting for its send, the status view, a held
4341
+ * selection). Returns true when the transcript changed.
4342
+ */
4343
+ export function mergeMessagesIntoTranscript(previousMessages, msgs, meta = {}) {
4344
+ const el = getMessagesElement();
4345
+ if (!el) return false;
4346
+ const conversationId = String(meta.conversationId || currentConvId || '').trim();
4347
+ if (!canMergeIntoTranscript(previousMessages, conversationId)) return renderMessages(msgs, false, meta);
4348
+ const ordered = sortConversationMessages(msgs || []);
4349
+ const snapshotKey = buildMessageSnapshotKey(ordered, meta);
4350
+ if (snapshotKey === lastRenderedMessageSnapshotKey) return false;
4351
+ const previousById = new Map((previousMessages || []).map((m) => [String(m?.id || '').trim(), m]));
4352
+ const messageById = new Map(ordered.map((m) => [String(m?.id || '').trim(), m]).filter(([id]) => !!id));
4353
+ let previousId = '';
4354
+ withSuspendedSeparatorSync(() => {
4355
+ // A window row the merge left out was deleted while the reader was away
4356
+ // (mergeTailPage drops it): it leaves the page too. Taken off first, so
4357
+ // nothing merged in is placed next to it.
4358
+ for (const id of previousById.keys()) {
4359
+ if (!id || messageById.has(id)) continue;
4360
+ el.querySelector(`:scope > .msg[data-message-id="${CSS.escape(id)}"]:not(#thinking-indicator)`)?.remove();
4361
+ seenMessageIds.delete(id);
4362
+ }
4363
+ for (const m of ordered) {
4364
+ const id = String(m?.id || '').trim();
4365
+ if (!id) continue;
4366
+ const before = previousById.get(id);
4367
+ if (!before) {
4368
+ const threadAnchor = getMessageThreadAnchor(m, messageById);
4369
+ appendMessage(m, false, id, true, threadAnchor !== id ? threadAnchor : (previousId || null), false);
4370
+ } else if (messageRenderKey(before) !== messageRenderKey(m)) {
4371
+ replaceRenderedMessage(m, id);
4372
+ }
4373
+ previousId = id;
4374
+ }
4375
+ });
4376
+ const newest = ordered[ordered.length - 1] || null;
4377
+ setConversationHistoryState({
4378
+ ...conversationHistoryState,
4379
+ conversationId,
4380
+ newestMessageId: String(newest?.id || conversationHistoryState.newestMessageId || '').trim(),
4381
+ newestMessageTimestamp: String(newest?.timestamp || conversationHistoryState.newestMessageTimestamp || '').trim(),
4382
+ loadedMessageCount: ordered.length,
4383
+ });
4384
+ syncSeparatorsNow();
4385
+ renderRelayQuestions();
4386
+ renderRelayBoards();
4387
+ lastRenderedMessageSnapshotKey = snapshotKey;
4388
+ return true;
4389
+ }
4390
+
3945
4391
  export function focusConversationMessageById(messageId, { behavior = 'smooth', block = 'center' } = {}) {
3946
4392
  const id = String(messageId || '').trim();
3947
4393
  if (!id) return false;
3948
4394
  const el = getMessagesElement();
3949
4395
  if (!el) return false;
3950
- // A message in a folded agent exchange is shown before it is scrolled to.
4396
+ // A folded agent prompt is opened before it is scrolled to.
3951
4397
  revealAgentExchangeMessage(id);
3952
4398
  const target = el.querySelector(`.msg[data-message-id="${CSS.escape(id)}"]`);
3953
4399
  if (!target) return false;
@@ -3989,7 +4435,23 @@ export function compactCurrentConversation() {
3989
4435
  // edit re-arms the warning.
3990
4436
  let unknownCommandWarned = null;
3991
4437
 
4438
+ // An own message goes to the end of the conversation. Sent from a window that
4439
+ // ends above it, its row put under the window would sit below rows that are
4440
+ // not loaded, and would move the newer pages' cursor past them; no live
4441
+ // bubble or reply would be drawn there either. So the end is loaded first,
4442
+ // as the jump-to-bottom button does, and the row, the bubble and the reply
4443
+ // follow at the end, whatever the scroll-on-send setting says: the end is
4444
+ // where the message is. Resolves once the end is on the page, or its load
4445
+ // failed (then the window still ends above, and no row is put under it).
4446
+ function loadEndForOwnSend() {
4447
+ if (isConversationWindowAtTail()) return Promise.resolve();
4448
+ return jumpTranscriptToBottom();
4449
+ }
4450
+
3992
4451
  export async function sendMessage() {
4452
+ // The reader's own send, as of now: what follows it scrolls to the end
4453
+ // only while they have not scrolled since.
4454
+ const send = beginOwnSend();
3993
4455
  const input = document.getElementById('msg-input');
3994
4456
  const originalComposerText = String(input?.value || '');
3995
4457
  const text = originalComposerText.trim();
@@ -4030,7 +4492,7 @@ export async function sendMessage() {
4030
4492
  releaseComposerFocusAfterSend(input);
4031
4493
  void flushConversationDraft(currentConvId);
4032
4494
  compactCurrentConversation();
4033
- scrollBottomAfterSend();
4495
+ scrollAfterOwnSend(send);
4034
4496
  return;
4035
4497
  }
4036
4498
 
@@ -4087,7 +4549,10 @@ export async function sendMessage() {
4087
4549
  let attachments = [];
4088
4550
  let clientMessageId = null;
4089
4551
  try {
4552
+ // Alongside the upload: the end is loaded while the files go up.
4553
+ const endLoaded = viewingSendConversation() ? loadEndForOwnSend() : null;
4090
4554
  attachments = await uploadAttachments(draftAttachments);
4555
+ await endLoaded;
4091
4556
 
4092
4557
  const isNew = !targetConversationId;
4093
4558
  const msgTimestamp = new Date().toISOString();
@@ -4107,8 +4572,14 @@ export async function sendMessage() {
4107
4572
  input.value = '';
4108
4573
  autoResize(input);
4109
4574
  releaseComposerFocusAfterSend(input);
4110
- appendMessage({ role: 'user', text, model: selectedModel, mode: selectedMode, timestamp: msgTimestamp, attachments }, true, clientMessageId, true);
4111
- scrollBottomAfterSend();
4575
+ // Asked before the bubble is on the page.
4576
+ const follow = ownSendFollows(send);
4577
+ // Not under a window that still ends above the end (loadEndForOwnSend
4578
+ // failed): the row comes with the end.
4579
+ if (isConversationWindowAtTail()) {
4580
+ appendMessage({ role: 'user', text, model: selectedModel, mode: selectedMode, timestamp: msgTimestamp, attachments }, follow, clientMessageId, true);
4581
+ }
4582
+ scrollAfterOwnSend(send);
4112
4583
  }
4113
4584
 
4114
4585
  const body = {
@@ -4196,7 +4667,8 @@ export async function sendMessage() {
4196
4667
  await window.openConversation?.(r.compactedConversationId);
4197
4668
  removeComposerAttachments(draftAttachments);
4198
4669
  if (!mobileSend) input.focus();
4199
- scrollBottomAfterSend();
4670
+ // The send goes on in the conversation the compaction opened.
4671
+ scrollAfterOwnSend({ ...send, conversationId: String(r.compactedConversationId || '').trim() });
4200
4672
  }
4201
4673
  return;
4202
4674
  }
@@ -4217,6 +4689,7 @@ export async function sendMessage() {
4217
4689
  if (adoptNewConversation) {
4218
4690
  setCurrentConv(r.conversationId);
4219
4691
  composerConversationId = r.conversationId;
4692
+ nameOwnSendConversation(send, r.conversationId);
4220
4693
  }
4221
4694
  conversations[r.conversationId] = {
4222
4695
  id: r.conversationId,
@@ -4263,7 +4736,7 @@ export async function sendMessage() {
4263
4736
  // pinned to the newest message. Re-pin now rather than after the draft
4264
4737
  // save's round trip below, or the new bubble sits under the composer
4265
4738
  // for as long as that request takes.
4266
- if (viewingSendConversation()) scrollBottom();
4739
+ if (viewingSendConversation() && ownSendFollows(send)) scrollBottom();
4267
4740
  const persistedConversationId = String(r.conversationId || targetConversationId || '').trim();
4268
4741
  if (persistedConversationId) {
4269
4742
  restorableDraftByConversation.delete(persistedConversationId);
@@ -4295,7 +4768,7 @@ export async function sendMessage() {
4295
4768
  // A turn already running here owns the live bubble; adopting the
4296
4769
  // just-queued id would re-anchor it under the newest user message.
4297
4770
  const activeTurn = getActiveTurnForConversation(composerConversationId);
4298
- if (!activeTurn?.messageId) showThinking(r.messageId || null);
4771
+ if (!activeTurn?.messageId) showThinking(r.messageId || null, ownSendFollows(send));
4299
4772
  }
4300
4773
 
4301
4774
  // Only what this message took leaves the composer: attachments added (or
@@ -4304,7 +4777,7 @@ export async function sendMessage() {
4304
4777
  if (viewingSendConversation()) {
4305
4778
  removeComposerAttachments(draftAttachments);
4306
4779
  if (!mobileSend) input.focus();
4307
- scrollBottomAfterSend();
4780
+ scrollAfterOwnSend(send);
4308
4781
  }
4309
4782
  } catch (e) {
4310
4783
  const sendLanded = hasSendEchoArrived(clientMessageId);