@oar-sh/oar 0.9.11 → 0.9.13

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 (90) hide show
  1. package/CHANGELOG.md +332 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +201 -12
  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 +179 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +152 -0
  10. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  13. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  14. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/acp-host-services.mjs +48 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +248 -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/0017-conversation-history-indexes.mjs +89 -0
  22. package/server/migrations/0018-background-task-rows.mjs +101 -0
  23. package/server/public/app/action-confirmations.js +12 -15
  24. package/server/public/app/api-client.js +70 -3
  25. package/server/public/app/background-tasks-view.mjs +334 -16
  26. package/server/public/app/bootstrap.js +102 -5
  27. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  28. package/server/public/app/conversation-payload-cache.mjs +357 -0
  29. package/server/public/app/conversation-preferences.mjs +10 -4
  30. package/server/public/app/conversation-turn-state.mjs +1 -1
  31. package/server/public/app/conversation-view.js +462 -8
  32. package/server/public/app/conversation-window-retention.mjs +96 -0
  33. package/server/public/app/host-suspend-ui.js +45 -44
  34. package/server/public/app/journal-view.js +259 -67
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
  37. package/server/public/app/relay-restart-note-view.mjs +36 -0
  38. package/server/public/app/relay-restart-ui.js +568 -0
  39. package/server/public/app/settings-modal.js +162 -2
  40. package/server/public/app/socket-handlers.js +59 -7
  41. package/server/public/app/store.js +25 -0
  42. package/server/public/app/subagent-viewer.mjs +7 -1
  43. package/server/public/app/task-console-render.mjs +401 -0
  44. package/server/public/app/task-console-watch.mjs +42 -0
  45. package/server/public/app/task-console.mjs +730 -0
  46. package/server/public/app/task-output-text.mjs +243 -0
  47. package/server/public/app/transcript-island.mjs +73 -0
  48. package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
  49. package/server/public/app/transcript-jump-bottom.mjs +68 -11
  50. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  51. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  52. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  53. package/server/public/index.html +306 -6
  54. package/server/relay-tools.md +6 -0
  55. package/server/repositories/message-repository.mjs +11 -0
  56. package/server/repositories/question-repository.mjs +4 -0
  57. package/server/repositories/remote-relay-repository.mjs +4 -2
  58. package/server/routes/messages-routes.mjs +397 -23
  59. package/server/routes/relay-board-routes.mjs +19 -2
  60. package/server/routes/relay-restart-routes.mjs +73 -0
  61. package/server/routes/sessions-routes.mjs +208 -66
  62. package/server/server-runtime.mjs +269 -137
  63. package/server/services/about-service.mjs +13 -12
  64. package/server/services/background-task-persistence.mjs +133 -0
  65. package/server/services/background-task-store.mjs +470 -6
  66. package/server/services/cli-process-runner.mjs +4 -21
  67. package/server/services/conversation-history-page.mjs +199 -0
  68. package/server/services/conversation-model-fallback.mjs +57 -0
  69. package/server/services/host-suspend-command.mjs +2 -1
  70. package/server/services/preview-proxy-server.mjs +14 -1
  71. package/server/services/process-list-service.mjs +2 -1
  72. package/server/services/provider-model-selection.mjs +195 -0
  73. package/server/services/push-dispatch-service.mjs +9 -2
  74. package/server/services/relay-event-service.mjs +27 -9
  75. package/server/services/relay-prompt-sanitizer.mjs +18 -4
  76. package/server/services/relay-restart-picture.mjs +209 -0
  77. package/server/services/relay-restart-queue.mjs +380 -0
  78. package/server/services/relay-shutdown-gate.mjs +4 -1
  79. package/server/services/remote-relay-dispatcher.mjs +316 -20
  80. package/server/services/restart-resume-service.mjs +303 -0
  81. package/server/services/session-activity-tracker.mjs +6 -2
  82. package/server/services/session-worker-process-service.mjs +6 -4
  83. package/server/services/task-output-rooms.mjs +259 -0
  84. package/server/services/task-output-service.mjs +761 -0
  85. package/shared/claude-background-command-limit.mjs +83 -0
  86. package/shared/relay-events.mjs +55 -2
  87. package/shared/remote-relay-contract.mjs +47 -10
  88. package/shared/task-output-text.mjs +850 -0
  89. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  90. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -47,6 +47,8 @@ import {
47
47
  openSummaryModal,
48
48
  closeSummaryModal,
49
49
  toggleSidebar,
50
+ hasPendingUserMessageForConversation,
51
+ isMessagesNearBottom,
50
52
  } from './store.js';
51
53
  import { getPendingQuestionCountsByConversation } from './ask-user-view.js';
52
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';
@@ -65,6 +67,7 @@ import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomp
65
67
  import { closeMentionAutocomplete, handleMentionAutocompleteKey, updateMentionAutocomplete } from './mention-autocomplete.mjs';
66
68
  import { renderRemoteOriginBadgeHtml } from './remote-relay-origin-view.mjs';
67
69
  import { isRelayEventMessage, renderRelayEventRowHtml } from './relay-event-view.mjs';
70
+ import { isRestartResumeNote, renderRestartResumeNoteHtml } from './relay-restart-note-view.mjs';
68
71
  import {
69
72
  AGENT_EXCHANGE_CLOSED_CLASS,
70
73
  AGENT_EXCHANGE_HEAD_CLASS,
@@ -131,6 +134,7 @@ import {
131
134
  shouldApplyIncomingDraftToComposer,
132
135
  } from './conversation-draft-sync.mjs';
133
136
  import { isChatInteractionHeld, selectionIntersectsNode } from './selection-guard.mjs';
137
+ import { captureReadingAnchor, readingAnchorContentTop, readingAnchorHoldStep, readingAnchorScrollDelta } from './transcript-reading-anchor.mjs';
134
138
  import { captureReadingBlock, restoreReadingBlock } from './transcript-viewport-anchor.mjs';
135
139
  import {
136
140
  applyThinkingFollow,
@@ -142,10 +146,12 @@ import {
142
146
  toggleThinkingFollowMode,
143
147
  unobserveThinkingBubble,
144
148
  } from './thinking-follow.js';
145
- import { beginOwnSend, followsTranscriptEnd, nameOwnSendConversation, ownSendFollows, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
149
+ import { beginOwnSend, followsTranscriptEnd, jumpTranscriptToBottom, nameOwnSendConversation, ownSendFollows, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
150
+ import { createHeldBackLiveRows, newerPageReachesEnd } from './transcript-island.mjs';
146
151
  import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
147
152
  import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
148
153
  import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
154
+ import { conversationPayloadCache } from './conversation-payload-cache.mjs';
149
155
 
150
156
  const CONVERSATION_HISTORY_PAGE_SIZE = 20;
151
157
  const HISTORY_LOAD_MORE_ID = 'history-load-more';
@@ -239,6 +245,20 @@ let conversationHistoryState = {
239
245
  loadingOlder: false,
240
246
  loadingNewer: false,
241
247
  };
248
+ // Live rows for a window that ends above the conversation's end: held back
249
+ // until the newer pages bring them (transcript-island.mjs).
250
+ const heldBackLiveRows = createHeldBackLiveRows();
251
+
252
+ // After every change of conversationHistoryState: a window that meets the
253
+ // end, or another conversation's, holds no rows back. The button's count
254
+ // changes without the page changing, so it is told.
255
+ function syncHeldBackLiveRows() {
256
+ const released = heldBackLiveRows.follow({
257
+ conversationId: conversationHistoryState.conversationId,
258
+ atTail: !conversationHistoryState.hasMoreNewer,
259
+ });
260
+ if (released) refreshJumpButton();
261
+ }
242
262
 
243
263
  function renderImageEditTarget() {
244
264
  const chip = document.getElementById('image-edit-target');
@@ -354,6 +374,8 @@ const conversationHistoryLoader = createInfiniteLoader({
354
374
  const previousScrollTop = el.scrollTop;
355
375
  const previousScrollHeight = el.scrollHeight;
356
376
  const inserted = prependMessageNodes(page.items || []);
377
+ // The kept window grows with the page: a switch back shows these rows too.
378
+ if (!IS_SHARED_VIEW) conversationPayloadCache.addOlder(currentId, page.items || [], { hasMoreOlder: page.hasMore });
357
379
  setConversationHistoryState({
358
380
  conversationId: currentId,
359
381
  hasMoreOlder: page.hasMore,
@@ -377,10 +399,18 @@ const conversationHistoryLoader = createInfiniteLoader({
377
399
  syncSteeredTurnMerge(el);
378
400
  // The page's agent exchanges fold before the height delta is measured.
379
401
  syncAgentExchangeFolds(el);
402
+ // Here, not a frame later: the position taken above is the reader's only
403
+ // until they scroll again, and a scroll in that frame (a jump to the end
404
+ // and up again) was undone by it.
405
+ el.scrollTop = previousScrollTop + (el.scrollHeight - previousScrollHeight);
406
+ // The page's images have no height yet: the place read is held while
407
+ // they come in, as after a restore.
408
+ const place = captureConversationReadingAnchor();
409
+ if (place?.conversationId === currentId) holdReadingAnchorWhileMediaLoads(el, place.anchor);
410
+ // Once the loader has taken this page's cursor: still near the top, the
411
+ // next page loads.
380
412
  requestAnimationFrame(() => {
381
- if (!el || String(currentConvId || '').trim() !== currentId) return;
382
- const nextScrollHeight = el.scrollHeight;
383
- el.scrollTop = previousScrollTop + (nextScrollHeight - previousScrollHeight);
413
+ if (String(currentConvId || '').trim() !== currentId) return;
384
414
  void conversationHistoryLoader.handleBoundaryDistance(el.scrollTop);
385
415
  });
386
416
  },
@@ -427,6 +457,24 @@ const conversationFutureLoader = createInfiniteLoader({
427
457
  const currentId = String(currentConvId || '').trim();
428
458
  if (!currentId || conversationPaginationCursorIsStale(currentId)) return;
429
459
  const ordered = sortConversationMessages(page.items || []);
460
+ // The last page may predate a row held back meanwhile: one more is asked
461
+ // for, from this page's newest row. The loader reads hasMore and the
462
+ // cursor off this page once it is applied.
463
+ const heldBackIds = heldBackLiveRows.ids(currentId);
464
+ if (!page.hasMore && heldBackIds.length && !newerPageReachesEnd({
465
+ pageHasMore: page.hasMore,
466
+ pageMessageIds: ordered.map((m) => m?.id),
467
+ heldBackIds,
468
+ renderedIds: renderedMessageIds(),
469
+ })) {
470
+ const newest = ordered[ordered.length - 1];
471
+ page.hasMore = true;
472
+ page.nextCursor = {
473
+ afterMessageId: String(newest?.id || '').trim() || null,
474
+ afterTimestamp: String(newest?.timestamp || '').trim() || null,
475
+ };
476
+ }
477
+ if (!IS_SHARED_VIEW) conversationPayloadCache.addNewer(currentId, ordered, { hasMoreNewer: page.hasMore });
430
478
  let inserted = 0;
431
479
  withSuspendedSeparatorSync(() => {
432
480
  for (const m of ordered) {
@@ -448,6 +496,9 @@ const conversationFutureLoader = createInfiniteLoader({
448
496
  loadingOlder: conversationHistoryState.loadingOlder,
449
497
  loadingNewer: false,
450
498
  });
499
+ // The window meets the end: a turn still running gets its live bubble,
500
+ // which was not drawn under the window while it ended above.
501
+ if (!page.hasMore) showLiveBubbleOfRunningTurn();
451
502
  },
452
503
  onError: (error, { mode }) => {
453
504
  if (mode === 'load') {
@@ -460,6 +511,7 @@ const conversationFutureLoader = createInfiniteLoader({
460
511
  hasMoreNewer: state.hasMore,
461
512
  loadingNewer: state.isLoading,
462
513
  };
514
+ syncHeldBackLiveRows();
463
515
  },
464
516
  });
465
517
 
@@ -1177,6 +1229,7 @@ function resetConversationHistoryState() {
1177
1229
  conversationFutureLoader.reset({ hasMore: false, nextCursor: null });
1178
1230
  saveConversationLoadedMessageCount(conversationId, 0);
1179
1231
  syncHistoryLoadMoreControl();
1232
+ syncHeldBackLiveRows();
1180
1233
  }
1181
1234
 
1182
1235
  function setConversationHistoryState(next = {}) {
@@ -1197,6 +1250,7 @@ function setConversationHistoryState(next = {}) {
1197
1250
  conversationHistoryState.loadedMessageCount,
1198
1251
  );
1199
1252
  syncHistoryLoadMoreControl();
1253
+ syncHeldBackLiveRows();
1200
1254
  }
1201
1255
 
1202
1256
  function getConversationHistoryCursor() {
@@ -1287,6 +1341,21 @@ function createRelayEventNode(msg, msgId) {
1287
1341
  return div;
1288
1342
  }
1289
1343
 
1344
+ // The relay's note to carry on after a restart cut a turn off: a folded row
1345
+ // like an event row (relay-restart-note-view.mjs).
1346
+ function createRestartResumeNoteNode(msg, msgId) {
1347
+ const div = document.createElement('div');
1348
+ div.className = 'msg relay-event relay-restart-resume';
1349
+ if (msgId) div.dataset.messageId = msgId;
1350
+ const fingerprint = buildLiveMessageFingerprint({ ...msg, id: msgId || msg?.id || '' });
1351
+ div.dataset.messageRole = fingerprint.role || '';
1352
+ div.dataset.messageTextFingerprint = fingerprint.text || '';
1353
+ div.dataset.messageTimestamp = String(msg?.timestamp || '').trim();
1354
+ div.innerHTML = renderRestartResumeNoteHtml(msg, { escHtml, timeText: msg?.timestamp ? fmtDate(msg.timestamp) : '' });
1355
+ setAgentExchangeRow(div, msg, msgId);
1356
+ return div;
1357
+ }
1358
+
1290
1359
  // Agent exchanges (agent-exchange-fold-view.mjs): an agent's prompt folds
1291
1360
  // into one row; this session's reply to it always shows. What the fold pass
1292
1361
  // needs of a message is kept on its node when the row is built, so the pass
@@ -1420,6 +1489,7 @@ function createMessageNode(msg, msgId = null, force = false) {
1420
1489
  // An event message the relay wrote into a watcher's conversation: a
1421
1490
  // compact, foldable row, never a bubble of the user's.
1422
1491
  if (isRelayEventMessage(msg)) return createRelayEventNode(msg, msgId);
1492
+ if (isRestartResumeNote(msg)) return createRestartResumeNoteNode(msg, msgId);
1423
1493
 
1424
1494
  // A prompt another relay's agent sent carries `origin`: its header line is
1425
1495
  // hidden (the badge below says the same), and a stray remote-relay mention
@@ -1873,6 +1943,16 @@ export function showThinking(messageId = null, autoScroll = true) {
1873
1943
  const nextMessageId = String(messageId || '').trim();
1874
1944
  if (nextMessageId) thinkingMessageId = nextMessageId;
1875
1945
  const existing = document.getElementById('thinking-indicator');
1946
+ // A window that ends above the conversation's end shows no live bubble:
1947
+ // under it, it would sit below rows that are not loaded. The turn's
1948
+ // thoughts, tools and text are kept all the same; the bubble is drawn once
1949
+ // the window meets the end.
1950
+ if (!isConversationWindowAtTail()) {
1951
+ // Through the same path as removeThinking, so the separators and the
1952
+ // agent folds no longer show the bubble either; the turn's id stays.
1953
+ if (existing) takeThinkingBubbleOff();
1954
+ return;
1955
+ }
1876
1956
  if (existing
1877
1957
  && existing.dataset.conversationId === thinkingConversationKey()
1878
1958
  && (!nextMessageId || String(existing.dataset.messageId || '') === nextMessageId)) {
@@ -1933,6 +2013,14 @@ export function showThinking(messageId = null, autoScroll = true) {
1933
2013
  export function removeThinking() {
1934
2014
  thinkingMessageId = null;
1935
2015
  lastInFlightSnapshotKey = '';
2016
+ takeThinkingBubbleOff();
2017
+ }
2018
+
2019
+ // The live bubble leaves the page, with what hangs on it (its compaction
2020
+ // line, the agent folds' "working"). The turn it shows is not forgotten:
2021
+ // removeThinking does that; a window that stops reaching the end only hides
2022
+ // the bubble until it meets the end again (showThinking).
2023
+ function takeThinkingBubbleOff() {
1936
2024
  const hadBubble = !!document.getElementById('thinking-indicator');
1937
2025
  const wasCompacting = !!document.getElementById('thinking-indicator')?.dataset?.compactBoundary;
1938
2026
  unobserveThinkingBubble();
@@ -3121,9 +3209,12 @@ async function resendStoppedSteer(conversationId, markerId) {
3121
3209
  showTransientRelayNotice('Select a reasoning effort after refreshing model metadata.');
3122
3210
  return;
3123
3211
  }
3212
+ // As a send from the composer (loadEndForOwnSend): the Resend goes to
3213
+ // the end.
3214
+ if (isCurrentConversation(conversationKey)) await loadEndForOwnSend();
3124
3215
  trackPendingUserMessage(clientMessageId, conversationKey, text, { attachments });
3125
3216
  pendingUserMessageIds.add(clientMessageId);
3126
- if (isCurrentConversation(conversationKey)) {
3217
+ if (isCurrentConversation(conversationKey) && isConversationWindowAtTail()) {
3127
3218
  appendMessage({
3128
3219
  role: 'user',
3129
3220
  text,
@@ -3807,6 +3898,8 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
3807
3898
  return false;
3808
3899
  }
3809
3900
  deferredMessageRender = null;
3901
+ // Other rows: whoever renders them puts the reader in place.
3902
+ releaseReadingAnchorHold();
3810
3903
  const messageById = new Map(
3811
3904
  ordered
3812
3905
  .map((item) => [String(item?.id || '').trim(), item])
@@ -3864,7 +3957,9 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
3864
3957
  // in as an extra row, and another conversation's bubble must not leak into
3865
3958
  // this one (it is dropped instead, so the next restore builds afresh).
3866
3959
  const thinkingNodeInTranscript = !!thinkingNode && thinkingNode.parentNode === el;
3960
+ // Nor under a window that ends above the conversation's end (showThinking).
3867
3961
  const preservedThinkingNode = (thinkingNodeInTranscript
3962
+ && !hasMoreNewer
3868
3963
  && thinkingNode.dataset.conversationId === renderConversationKey) ? thinkingNode : null;
3869
3964
  if (thinkingNodeInTranscript && !preservedThinkingNode) removeThinking();
3870
3965
  if (!ordered.length && !preservedPendingNodes.length && !preservedThinkingNode) {
@@ -3978,6 +4073,342 @@ export function isConversationWindowAtTail() {
3978
4073
  return !conversationHistoryState.hasMoreNewer;
3979
4074
  }
3980
4075
 
4076
+ /**
4077
+ * A live row for `conversationId` arrived (a message, a reply). True when it
4078
+ * is held back instead of put on the page: the window on the page is that
4079
+ * conversation's and ends above its end (transcript-island.mjs). The window
4080
+ * keeps loading newer pages from its own newest row, and the jump-to-bottom
4081
+ * button counts the row.
4082
+ */
4083
+ export function holdBackLiveRow(conversationId, messageId) {
4084
+ const id = String(conversationId || '').trim();
4085
+ if (!id || isConversationWindowAtTail() || getRenderedConversationId() !== id) return false;
4086
+ heldBackLiveRows.note(id, messageId);
4087
+ // A reload whose request was on its way may lack this row as it would one
4088
+ // put on the page: counted, so it is asked again (loadKeepingLiveRows)
4089
+ // rather than rendered over the rows held.
4090
+ noteTranscriptLiveRow();
4091
+ refreshJumpButton();
4092
+ return true;
4093
+ }
4094
+
4095
+ /** How many live rows wait below the window on the page. */
4096
+ export function getHeldBackLiveRowCount() {
4097
+ return heldBackLiveRows.count(getRenderedConversationId());
4098
+ }
4099
+
4100
+ /** The ids of the live rows that wait below the window on the page, oldest first. */
4101
+ export function getHeldBackLiveRowIds() {
4102
+ return heldBackLiveRows.ids(getRenderedConversationId());
4103
+ }
4104
+
4105
+ function renderedMessageIds() {
4106
+ const el = getMessagesElement();
4107
+ if (!el) return [];
4108
+ return Array.from(readingAnchorRows(el), (node) => String(node.dataset.messageId || '').trim());
4109
+ }
4110
+
4111
+ // The running turn's live bubble, drawn from what the page kept of it (its
4112
+ // thoughts, tools and text arrived while no bubble was drawn).
4113
+ function showLiveBubbleOfRunningTurn() {
4114
+ if (document.getElementById('thinking-indicator')) return;
4115
+ const turn = getActiveTurnForConversation(currentConvId);
4116
+ const messageId = String(turn?.messageId || '').trim();
4117
+ if (!messageId || String(turn?.status || '').trim().toLowerCase() !== 'processing') return;
4118
+ showThinking(messageId, false);
4119
+ renderThinkingStream();
4120
+ renderThinkingActivities();
4121
+ renderThinkingThoughts();
4122
+ renderRestoredSubagentBubbles(messageId);
4123
+ }
4124
+
4125
+ /** The conversation whose rows the transcript holds; during a switch, still the one left. */
4126
+ export function getRenderedConversationId() {
4127
+ return String(conversationHistoryState.conversationId || '').trim();
4128
+ }
4129
+
4130
+ // The message rows, without the live bubble: it carries its prompt's id.
4131
+ function readingAnchorRows(el) {
4132
+ return el.querySelectorAll(':scope > .msg[data-message-id]:not(#thinking-indicator)');
4133
+ }
4134
+
4135
+ /**
4136
+ * Where the reader is in the transcript on the page
4137
+ * (transcript-reading-anchor.mjs), for the conversation whose rows it holds;
4138
+ * null when no row is there.
4139
+ */
4140
+ export function captureConversationReadingAnchor() {
4141
+ const el = getMessagesElement();
4142
+ const conversationId = getRenderedConversationId();
4143
+ if (!el || !conversationId) return null;
4144
+ const rows = readingAnchorRows(el);
4145
+ if (!rows.length) return null;
4146
+ const paneTop = el.getBoundingClientRect().top;
4147
+ const rectAt = (index) => rows[index].getBoundingClientRect();
4148
+ const anchor = captureReadingAnchor({
4149
+ rowCount: rows.length,
4150
+ rowAt: (index) => {
4151
+ const rect = rectAt(index);
4152
+ return { messageId: rows[index].dataset.messageId, top: rect.top, bottom: rect.bottom };
4153
+ },
4154
+ paneTop,
4155
+ atTail: isMessagesNearBottom() && isConversationWindowAtTail(),
4156
+ });
4157
+ return anchor ? { conversationId, anchor } : null;
4158
+ }
4159
+
4160
+ function readingAnchorRow(el, messageId) {
4161
+ const id = String(messageId || '').trim();
4162
+ return id ? el.querySelector(`:scope > .msg[data-message-id="${CSS.escape(id)}"]:not(#thinking-indicator)`) : null;
4163
+ }
4164
+
4165
+ /**
4166
+ * Puts the anchored message at its saved offset, or the reader at the end.
4167
+ * False when the message is not on the page. The place is then held while
4168
+ * the images on the page are still loading (holdReadingAnchorWhileMediaLoads).
4169
+ */
4170
+ export function restoreConversationReadingAnchor(anchor) {
4171
+ const el = getMessagesElement();
4172
+ if (!el || !anchor) return false;
4173
+ if (anchor.atTail) {
4174
+ releaseReadingAnchorHold();
4175
+ el.scrollTop = el.scrollHeight;
4176
+ return true;
4177
+ }
4178
+ const row = readingAnchorRow(el, anchor.messageId);
4179
+ if (!row) return false;
4180
+ const delta = readingAnchorScrollDelta({
4181
+ rowTop: row.getBoundingClientRect().top,
4182
+ paneTop: el.getBoundingClientRect().top,
4183
+ offsetPx: anchor.offsetPx,
4184
+ });
4185
+ if (Math.abs(delta) > 0.5) el.scrollTop += delta;
4186
+ holdReadingAnchorWhileMediaLoads(el, anchor);
4187
+ return true;
4188
+ }
4189
+
4190
+ // Images reserve no height until their bytes are in (the attachment payload
4191
+ // carries no dimensions), so one above the place read that arrives after the
4192
+ // place was put back pushes it down by its height. Chrome's scroll anchoring
4193
+ // absorbs that; browsers without it (iOS Safari) do not. Long enough for a
4194
+ // slow link, short enough that a forgotten hold cannot surprise anyone.
4195
+ const READING_ANCHOR_HOLD_MAX_MS = 10_000;
4196
+ let readingAnchorHold = null;
4197
+
4198
+ /**
4199
+ * Lets go of the place held while images load: whoever calls this moves the
4200
+ * reader themselves (the jump to the end, an own send).
4201
+ */
4202
+ export function releaseReadingAnchorHold() {
4203
+ const hold = readingAnchorHold;
4204
+ if (!hold) return;
4205
+ readingAnchorHold = null;
4206
+ hold.cleanup();
4207
+ }
4208
+
4209
+ function pendingTranscriptImages(el) {
4210
+ return Array.from(el.querySelectorAll('img')).filter((img) => !img.complete);
4211
+ }
4212
+
4213
+ /**
4214
+ * Keeps the anchored message at its offset while images in the transcript
4215
+ * are loading: each one that is laid out at its height (a ResizeObserver
4216
+ * fires before the frame is painted; load and error events as a fallback)
4217
+ * scrolls by what it pushed. Lets go when they are all in, after
4218
+ * READING_ANCHOR_HOLD_MAX_MS, or as soon as the reader scrolls: a wheel, a
4219
+ * touch or a key, or a change of scrollTop that no growth above the row
4220
+ * explains (a drag of the scrollbar, a jump to the end, an own send). Growth
4221
+ * below the row (a reply streaming in at the end) is not the hold's to
4222
+ * correct (readingAnchorHoldStep). A reader at the end has nothing to hold
4223
+ * here: the jump-to-bottom follower keeps them at the end.
4224
+ */
4225
+ function holdReadingAnchorWhileMediaLoads(el, anchor) {
4226
+ releaseReadingAnchorHold();
4227
+ if (!el || !anchor || anchor.atTail || !anchor.messageId) return;
4228
+ const pending = pendingTranscriptImages(el);
4229
+ if (!pending.length) return;
4230
+ const startRow = readingAnchorRow(el, anchor.messageId);
4231
+ if (!startRow) return;
4232
+ // Where the row sits in the content and the scroll, as of the last look:
4233
+ // the next look corrects by what moved the row in the content only.
4234
+ const seen = {
4235
+ rowContentTop: readingAnchorContentTop({
4236
+ rowTop: startRow.getBoundingClientRect().top,
4237
+ paneTop: el.getBoundingClientRect().top,
4238
+ scrollTop: el.scrollTop,
4239
+ }),
4240
+ scrollTop: el.scrollTop,
4241
+ };
4242
+ const check = () => {
4243
+ if (readingAnchorHold !== hold) return;
4244
+ const row = readingAnchorRow(el, anchor.messageId);
4245
+ if (!row) {
4246
+ releaseReadingAnchorHold();
4247
+ return;
4248
+ }
4249
+ const step = readingAnchorHoldStep({
4250
+ rowTop: row.getBoundingClientRect().top,
4251
+ paneTop: el.getBoundingClientRect().top,
4252
+ scrollTop: el.scrollTop,
4253
+ seen,
4254
+ });
4255
+ if (step.action === 'release') {
4256
+ releaseReadingAnchorHold();
4257
+ return;
4258
+ }
4259
+ if (step.action === 'correct') el.scrollTop += step.delta;
4260
+ seen.rowContentTop = step.rowContentTop;
4261
+ // Read back: the browser can clamp the correction.
4262
+ seen.scrollTop = el.scrollTop;
4263
+ };
4264
+ const onMediaSettled = () => {
4265
+ check();
4266
+ if (readingAnchorHold === hold && !pendingTranscriptImages(el).length) releaseReadingAnchorHold();
4267
+ };
4268
+ const onReaderInput = () => releaseReadingAnchorHold();
4269
+ // A key can scroll the pane from anywhere but a field being typed in (the
4270
+ // composer, a card's reply): typing there moves nothing.
4271
+ const onKeyDown = (event) => {
4272
+ const target = event?.target;
4273
+ if (target && (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT' || target.isContentEditable === true)) return;
4274
+ releaseReadingAnchorHold();
4275
+ };
4276
+ const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(check) : null;
4277
+ for (const img of pending) observer?.observe(img);
4278
+ el.addEventListener('load', onMediaSettled, true);
4279
+ el.addEventListener('error', onMediaSettled, true);
4280
+ // A video's box changes when its metadata is in.
4281
+ el.addEventListener('loadedmetadata', check, true);
4282
+ el.addEventListener('scroll', check, { passive: true });
4283
+ el.addEventListener('wheel', onReaderInput, { passive: true });
4284
+ el.addEventListener('touchstart', onReaderInput, { passive: true });
4285
+ document.addEventListener('keydown', onKeyDown, true);
4286
+ const timer = setTimeout(releaseReadingAnchorHold, READING_ANCHOR_HOLD_MAX_MS);
4287
+ const hold = {
4288
+ cleanup() {
4289
+ clearTimeout(timer);
4290
+ observer?.disconnect();
4291
+ el.removeEventListener('load', onMediaSettled, true);
4292
+ el.removeEventListener('error', onMediaSettled, true);
4293
+ el.removeEventListener('loadedmetadata', check, true);
4294
+ el.removeEventListener('scroll', check);
4295
+ el.removeEventListener('wheel', onReaderInput);
4296
+ el.removeEventListener('touchstart', onReaderInput);
4297
+ document.removeEventListener('keydown', onKeyDown, true);
4298
+ },
4299
+ };
4300
+ readingAnchorHold = hold;
4301
+ }
4302
+
4303
+ /**
4304
+ * The window on the page no longer meets the end of the conversation (more
4305
+ * was written than one page holds): newer rows load as after a jump into
4306
+ * the history, and the jump-to-bottom button loads the end.
4307
+ */
4308
+ export function detachConversationWindowFromTail() {
4309
+ const newestMessageId = String(conversationHistoryState.newestMessageId || '').trim();
4310
+ setConversationHistoryState({ ...conversationHistoryState, hasMoreNewer: true });
4311
+ // A live bubble on the page now sits under a window that ends above the
4312
+ // end, below rows that are not loaded: it goes until the window meets the
4313
+ // end again (showThinking draws none meanwhile).
4314
+ takeThinkingBubbleOff();
4315
+ conversationFutureLoader.reset({
4316
+ hasMore: true,
4317
+ nextCursor: {
4318
+ afterMessageId: newestMessageId || null,
4319
+ afterTimestamp: String(conversationHistoryState.newestMessageTimestamp || '').trim() || null,
4320
+ },
4321
+ });
4322
+ }
4323
+
4324
+ // One message as the transcript's snapshot key sees it.
4325
+ function messageRenderKey(message) {
4326
+ return buildMessageSnapshotKey([message], { conversationId: '', hasMoreOlder: false, hasMoreNewer: false });
4327
+ }
4328
+
4329
+ /**
4330
+ * Whether the transcript on the page can take a merge of `previousMessages`'
4331
+ * window (mergeMessagesIntoTranscript) rather than a full render: not while
4332
+ * a bubble still waits for its send, the status view is up, a selection is
4333
+ * held, another conversation's rows are on the page, or a row of the window
4334
+ * is missing from it (what is merged in is placed next to those rows, and a
4335
+ * changed one is rebuilt where it stands).
4336
+ */
4337
+ export function canMergeIntoTranscript(previousMessages, conversationId) {
4338
+ const el = getMessagesElement();
4339
+ const id = String(conversationId || '').trim();
4340
+ if (!el || !id) return false;
4341
+ if (isChatInteractionHeld()
4342
+ || el.querySelector('.status-view')
4343
+ || getRenderedConversationId() !== id
4344
+ || hasPendingUserMessageForConversation(id)) {
4345
+ return false;
4346
+ }
4347
+ const rendered = new Set(Array.from(readingAnchorRows(el), (node) => String(node.dataset.messageId || '').trim()));
4348
+ for (const message of previousMessages || []) {
4349
+ const messageId = String(message?.id || '').trim();
4350
+ if (messageId && !rendered.has(messageId)) return false;
4351
+ }
4352
+ return true;
4353
+ }
4354
+
4355
+ /**
4356
+ * Puts a merged window (mergeTailPage) on the page without rebuilding it:
4357
+ * the rows the newest page changed are rebuilt where they stand, its new ones
4358
+ * are put in their place, the rows it dropped are taken off, every other row
4359
+ * stays as it is under the reader.
4360
+ * Falls back to a full render when the page holds rows the merge cannot
4361
+ * place (a bubble still waiting for its send, the status view, a held
4362
+ * selection). Returns true when the transcript changed.
4363
+ */
4364
+ export function mergeMessagesIntoTranscript(previousMessages, msgs, meta = {}) {
4365
+ const el = getMessagesElement();
4366
+ if (!el) return false;
4367
+ const conversationId = String(meta.conversationId || currentConvId || '').trim();
4368
+ if (!canMergeIntoTranscript(previousMessages, conversationId)) return renderMessages(msgs, false, meta);
4369
+ const ordered = sortConversationMessages(msgs || []);
4370
+ const snapshotKey = buildMessageSnapshotKey(ordered, meta);
4371
+ if (snapshotKey === lastRenderedMessageSnapshotKey) return false;
4372
+ const previousById = new Map((previousMessages || []).map((m) => [String(m?.id || '').trim(), m]));
4373
+ const messageById = new Map(ordered.map((m) => [String(m?.id || '').trim(), m]).filter(([id]) => !!id));
4374
+ let previousId = '';
4375
+ withSuspendedSeparatorSync(() => {
4376
+ // A window row the merge left out was deleted while the reader was away
4377
+ // (mergeTailPage drops it): it leaves the page too. Taken off first, so
4378
+ // nothing merged in is placed next to it.
4379
+ for (const id of previousById.keys()) {
4380
+ if (!id || messageById.has(id)) continue;
4381
+ el.querySelector(`:scope > .msg[data-message-id="${CSS.escape(id)}"]:not(#thinking-indicator)`)?.remove();
4382
+ seenMessageIds.delete(id);
4383
+ }
4384
+ for (const m of ordered) {
4385
+ const id = String(m?.id || '').trim();
4386
+ if (!id) continue;
4387
+ const before = previousById.get(id);
4388
+ if (!before) {
4389
+ const threadAnchor = getMessageThreadAnchor(m, messageById);
4390
+ appendMessage(m, false, id, true, threadAnchor !== id ? threadAnchor : (previousId || null), false);
4391
+ } else if (messageRenderKey(before) !== messageRenderKey(m)) {
4392
+ replaceRenderedMessage(m, id);
4393
+ }
4394
+ previousId = id;
4395
+ }
4396
+ });
4397
+ const newest = ordered[ordered.length - 1] || null;
4398
+ setConversationHistoryState({
4399
+ ...conversationHistoryState,
4400
+ conversationId,
4401
+ newestMessageId: String(newest?.id || conversationHistoryState.newestMessageId || '').trim(),
4402
+ newestMessageTimestamp: String(newest?.timestamp || conversationHistoryState.newestMessageTimestamp || '').trim(),
4403
+ loadedMessageCount: ordered.length,
4404
+ });
4405
+ syncSeparatorsNow();
4406
+ renderRelayQuestions();
4407
+ renderRelayBoards();
4408
+ lastRenderedMessageSnapshotKey = snapshotKey;
4409
+ return true;
4410
+ }
4411
+
3981
4412
  export function focusConversationMessageById(messageId, { behavior = 'smooth', block = 'center' } = {}) {
3982
4413
  const id = String(messageId || '').trim();
3983
4414
  if (!id) return false;
@@ -4025,6 +4456,19 @@ export function compactCurrentConversation() {
4025
4456
  // edit re-arms the warning.
4026
4457
  let unknownCommandWarned = null;
4027
4458
 
4459
+ // An own message goes to the end of the conversation. Sent from a window that
4460
+ // ends above it, its row put under the window would sit below rows that are
4461
+ // not loaded, and would move the newer pages' cursor past them; no live
4462
+ // bubble or reply would be drawn there either. So the end is loaded first,
4463
+ // as the jump-to-bottom button does, and the row, the bubble and the reply
4464
+ // follow at the end, whatever the scroll-on-send setting says: the end is
4465
+ // where the message is. Resolves once the end is on the page, or its load
4466
+ // failed (then the window still ends above, and no row is put under it).
4467
+ function loadEndForOwnSend() {
4468
+ if (isConversationWindowAtTail()) return Promise.resolve();
4469
+ return jumpTranscriptToBottom();
4470
+ }
4471
+
4028
4472
  export async function sendMessage() {
4029
4473
  // The reader's own send, as of now: what follows it scrolls to the end
4030
4474
  // only while they have not scrolled since.
@@ -4126,7 +4570,10 @@ export async function sendMessage() {
4126
4570
  let attachments = [];
4127
4571
  let clientMessageId = null;
4128
4572
  try {
4573
+ // Alongside the upload: the end is loaded while the files go up.
4574
+ const endLoaded = viewingSendConversation() ? loadEndForOwnSend() : null;
4129
4575
  attachments = await uploadAttachments(draftAttachments);
4576
+ await endLoaded;
4130
4577
 
4131
4578
  const isNew = !targetConversationId;
4132
4579
  const msgTimestamp = new Date().toISOString();
@@ -4148,7 +4595,11 @@ export async function sendMessage() {
4148
4595
  releaseComposerFocusAfterSend(input);
4149
4596
  // Asked before the bubble is on the page.
4150
4597
  const follow = ownSendFollows(send);
4151
- appendMessage({ role: 'user', text, model: selectedModel, mode: selectedMode, timestamp: msgTimestamp, attachments }, follow, clientMessageId, true);
4598
+ // Not under a window that still ends above the end (loadEndForOwnSend
4599
+ // failed): the row comes with the end.
4600
+ if (isConversationWindowAtTail()) {
4601
+ appendMessage({ role: 'user', text, model: selectedModel, mode: selectedMode, timestamp: msgTimestamp, attachments }, follow, clientMessageId, true);
4602
+ }
4152
4603
  scrollAfterOwnSend(send);
4153
4604
  }
4154
4605
 
@@ -4172,7 +4623,10 @@ export async function sendMessage() {
4172
4623
  : undefined,
4173
4624
  };
4174
4625
 
4175
- const r = await sendMessageApi(body);
4626
+ // A refusal the user can act on (the conversation's model left the
4627
+ // catalog) is shown as the relay words it.
4628
+ let refusal = '';
4629
+ const r = await sendMessageApi(body, { onRefused: (reason) => { refusal = reason; } });
4176
4630
  if (!r) {
4177
4631
  // Offline: park the send in the durable outbox instead of bouncing it
4178
4632
  // back into the composer. The client-generated messageId makes a replay
@@ -4206,7 +4660,7 @@ export async function sendMessage() {
4206
4660
  sendLanded,
4207
4661
  });
4208
4662
  if (!mobileSend && viewingSendConversation()) input.focus();
4209
- setModelBanner('⚠️ Message could not be sent. Please try again.');
4663
+ setModelBanner(refusal ? `⚠️ ${refusal}` : '⚠️ Message could not be sent. Please try again.');
4210
4664
  return;
4211
4665
  }
4212
4666