@oar-sh/oar 0.9.11 → 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 (63) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +17 -3
  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 +128 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -3
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -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 +9 -0
  15. package/server/grok-worker/acp-host-services.mjs +42 -3
  16. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  17. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  18. package/server/grok-worker/grok-session-worker.mjs +33 -1
  19. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  20. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  21. package/server/public/app/api-client.js +13 -0
  22. package/server/public/app/background-tasks-view.mjs +285 -16
  23. package/server/public/app/bootstrap.js +95 -5
  24. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  25. package/server/public/app/conversation-payload-cache.mjs +357 -0
  26. package/server/public/app/conversation-turn-state.mjs +1 -1
  27. package/server/public/app/conversation-view.js +433 -3
  28. package/server/public/app/conversation-window-retention.mjs +96 -0
  29. package/server/public/app/journal-view.js +252 -66
  30. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  31. package/server/public/app/settings-modal.js +110 -2
  32. package/server/public/app/socket-handlers.js +59 -7
  33. package/server/public/app/store.js +25 -0
  34. package/server/public/app/subagent-viewer.mjs +7 -1
  35. package/server/public/app/task-console-render.mjs +237 -0
  36. package/server/public/app/task-console-watch.mjs +42 -0
  37. package/server/public/app/task-console.mjs +630 -0
  38. package/server/public/app/task-output-text.mjs +234 -0
  39. package/server/public/app/transcript-island.mjs +73 -0
  40. package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
  41. package/server/public/app/transcript-jump-bottom.mjs +48 -11
  42. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  43. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  44. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  45. package/server/public/index.html +239 -6
  46. package/server/relay-tools.md +1 -0
  47. package/server/repositories/message-repository.mjs +8 -0
  48. package/server/repositories/question-repository.mjs +4 -0
  49. package/server/routes/messages-routes.mjs +249 -3
  50. package/server/routes/sessions-routes.mjs +133 -63
  51. package/server/server-runtime.mjs +77 -0
  52. package/server/services/background-task-store.mjs +240 -3
  53. package/server/services/cli-process-runner.mjs +4 -21
  54. package/server/services/conversation-history-page.mjs +199 -0
  55. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  56. package/server/services/remote-relay-dispatcher.mjs +130 -2
  57. package/server/services/session-activity-tracker.mjs +6 -2
  58. package/server/services/task-output-rooms.mjs +259 -0
  59. package/server/services/task-output-service.mjs +562 -0
  60. package/shared/claude-background-command-limit.mjs +83 -0
  61. package/shared/remote-relay-contract.mjs +32 -2
  62. package/shared/task-output-text.mjs +737 -0
  63. package/shared/worker-runtime/task-output-spool.mjs +181 -0
@@ -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';
@@ -131,6 +133,7 @@ import {
131
133
  shouldApplyIncomingDraftToComposer,
132
134
  } from './conversation-draft-sync.mjs';
133
135
  import { isChatInteractionHeld, selectionIntersectsNode } from './selection-guard.mjs';
136
+ import { captureReadingAnchor, readingAnchorContentTop, readingAnchorHoldStep, readingAnchorScrollDelta } from './transcript-reading-anchor.mjs';
134
137
  import { captureReadingBlock, restoreReadingBlock } from './transcript-viewport-anchor.mjs';
135
138
  import {
136
139
  applyThinkingFollow,
@@ -142,10 +145,12 @@ import {
142
145
  toggleThinkingFollowMode,
143
146
  unobserveThinkingBubble,
144
147
  } from './thinking-follow.js';
145
- import { beginOwnSend, followsTranscriptEnd, nameOwnSendConversation, ownSendFollows, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
148
+ import { beginOwnSend, followsTranscriptEnd, jumpTranscriptToBottom, nameOwnSendConversation, ownSendFollows, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
149
+ import { createHeldBackLiveRows, newerPageReachesEnd } from './transcript-island.mjs';
146
150
  import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
147
151
  import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
148
152
  import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
153
+ import { conversationPayloadCache } from './conversation-payload-cache.mjs';
149
154
 
150
155
  const CONVERSATION_HISTORY_PAGE_SIZE = 20;
151
156
  const HISTORY_LOAD_MORE_ID = 'history-load-more';
@@ -239,6 +244,20 @@ let conversationHistoryState = {
239
244
  loadingOlder: false,
240
245
  loadingNewer: false,
241
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
+ }
242
261
 
243
262
  function renderImageEditTarget() {
244
263
  const chip = document.getElementById('image-edit-target');
@@ -354,6 +373,8 @@ const conversationHistoryLoader = createInfiniteLoader({
354
373
  const previousScrollTop = el.scrollTop;
355
374
  const previousScrollHeight = el.scrollHeight;
356
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 });
357
378
  setConversationHistoryState({
358
379
  conversationId: currentId,
359
380
  hasMoreOlder: page.hasMore,
@@ -381,6 +402,10 @@ const conversationHistoryLoader = createInfiniteLoader({
381
402
  if (!el || String(currentConvId || '').trim() !== currentId) return;
382
403
  const nextScrollHeight = el.scrollHeight;
383
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);
384
409
  void conversationHistoryLoader.handleBoundaryDistance(el.scrollTop);
385
410
  });
386
411
  },
@@ -427,6 +452,24 @@ const conversationFutureLoader = createInfiniteLoader({
427
452
  const currentId = String(currentConvId || '').trim();
428
453
  if (!currentId || conversationPaginationCursorIsStale(currentId)) return;
429
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 });
430
473
  let inserted = 0;
431
474
  withSuspendedSeparatorSync(() => {
432
475
  for (const m of ordered) {
@@ -448,6 +491,9 @@ const conversationFutureLoader = createInfiniteLoader({
448
491
  loadingOlder: conversationHistoryState.loadingOlder,
449
492
  loadingNewer: false,
450
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();
451
497
  },
452
498
  onError: (error, { mode }) => {
453
499
  if (mode === 'load') {
@@ -460,6 +506,7 @@ const conversationFutureLoader = createInfiniteLoader({
460
506
  hasMoreNewer: state.hasMore,
461
507
  loadingNewer: state.isLoading,
462
508
  };
509
+ syncHeldBackLiveRows();
463
510
  },
464
511
  });
465
512
 
@@ -1177,6 +1224,7 @@ function resetConversationHistoryState() {
1177
1224
  conversationFutureLoader.reset({ hasMore: false, nextCursor: null });
1178
1225
  saveConversationLoadedMessageCount(conversationId, 0);
1179
1226
  syncHistoryLoadMoreControl();
1227
+ syncHeldBackLiveRows();
1180
1228
  }
1181
1229
 
1182
1230
  function setConversationHistoryState(next = {}) {
@@ -1197,6 +1245,7 @@ function setConversationHistoryState(next = {}) {
1197
1245
  conversationHistoryState.loadedMessageCount,
1198
1246
  );
1199
1247
  syncHistoryLoadMoreControl();
1248
+ syncHeldBackLiveRows();
1200
1249
  }
1201
1250
 
1202
1251
  function getConversationHistoryCursor() {
@@ -1873,6 +1922,16 @@ export function showThinking(messageId = null, autoScroll = true) {
1873
1922
  const nextMessageId = String(messageId || '').trim();
1874
1923
  if (nextMessageId) thinkingMessageId = nextMessageId;
1875
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
+ }
1876
1935
  if (existing
1877
1936
  && existing.dataset.conversationId === thinkingConversationKey()
1878
1937
  && (!nextMessageId || String(existing.dataset.messageId || '') === nextMessageId)) {
@@ -1933,6 +1992,14 @@ export function showThinking(messageId = null, autoScroll = true) {
1933
1992
  export function removeThinking() {
1934
1993
  thinkingMessageId = null;
1935
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() {
1936
2003
  const hadBubble = !!document.getElementById('thinking-indicator');
1937
2004
  const wasCompacting = !!document.getElementById('thinking-indicator')?.dataset?.compactBoundary;
1938
2005
  unobserveThinkingBubble();
@@ -3121,9 +3188,12 @@ async function resendStoppedSteer(conversationId, markerId) {
3121
3188
  showTransientRelayNotice('Select a reasoning effort after refreshing model metadata.');
3122
3189
  return;
3123
3190
  }
3191
+ // As a send from the composer (loadEndForOwnSend): the Resend goes to
3192
+ // the end.
3193
+ if (isCurrentConversation(conversationKey)) await loadEndForOwnSend();
3124
3194
  trackPendingUserMessage(clientMessageId, conversationKey, text, { attachments });
3125
3195
  pendingUserMessageIds.add(clientMessageId);
3126
- if (isCurrentConversation(conversationKey)) {
3196
+ if (isCurrentConversation(conversationKey) && isConversationWindowAtTail()) {
3127
3197
  appendMessage({
3128
3198
  role: 'user',
3129
3199
  text,
@@ -3807,6 +3877,8 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
3807
3877
  return false;
3808
3878
  }
3809
3879
  deferredMessageRender = null;
3880
+ // Other rows: whoever renders them puts the reader in place.
3881
+ releaseReadingAnchorHold();
3810
3882
  const messageById = new Map(
3811
3883
  ordered
3812
3884
  .map((item) => [String(item?.id || '').trim(), item])
@@ -3864,7 +3936,9 @@ export function renderMessages(msgs, scroll = true, meta = {}) {
3864
3936
  // in as an extra row, and another conversation's bubble must not leak into
3865
3937
  // this one (it is dropped instead, so the next restore builds afresh).
3866
3938
  const thinkingNodeInTranscript = !!thinkingNode && thinkingNode.parentNode === el;
3939
+ // Nor under a window that ends above the conversation's end (showThinking).
3867
3940
  const preservedThinkingNode = (thinkingNodeInTranscript
3941
+ && !hasMoreNewer
3868
3942
  && thinkingNode.dataset.conversationId === renderConversationKey) ? thinkingNode : null;
3869
3943
  if (thinkingNodeInTranscript && !preservedThinkingNode) removeThinking();
3870
3944
  if (!ordered.length && !preservedPendingNodes.length && !preservedThinkingNode) {
@@ -3978,6 +4052,342 @@ export function isConversationWindowAtTail() {
3978
4052
  return !conversationHistoryState.hasMoreNewer;
3979
4053
  }
3980
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
+
3981
4391
  export function focusConversationMessageById(messageId, { behavior = 'smooth', block = 'center' } = {}) {
3982
4392
  const id = String(messageId || '').trim();
3983
4393
  if (!id) return false;
@@ -4025,6 +4435,19 @@ export function compactCurrentConversation() {
4025
4435
  // edit re-arms the warning.
4026
4436
  let unknownCommandWarned = null;
4027
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
+
4028
4451
  export async function sendMessage() {
4029
4452
  // The reader's own send, as of now: what follows it scrolls to the end
4030
4453
  // only while they have not scrolled since.
@@ -4126,7 +4549,10 @@ export async function sendMessage() {
4126
4549
  let attachments = [];
4127
4550
  let clientMessageId = null;
4128
4551
  try {
4552
+ // Alongside the upload: the end is loaded while the files go up.
4553
+ const endLoaded = viewingSendConversation() ? loadEndForOwnSend() : null;
4129
4554
  attachments = await uploadAttachments(draftAttachments);
4555
+ await endLoaded;
4130
4556
 
4131
4557
  const isNew = !targetConversationId;
4132
4558
  const msgTimestamp = new Date().toISOString();
@@ -4148,7 +4574,11 @@ export async function sendMessage() {
4148
4574
  releaseComposerFocusAfterSend(input);
4149
4575
  // Asked before the bubble is on the page.
4150
4576
  const follow = ownSendFollows(send);
4151
- appendMessage({ role: 'user', text, model: selectedModel, mode: selectedMode, timestamp: msgTimestamp, attachments }, follow, clientMessageId, true);
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
+ }
4152
4582
  scrollAfterOwnSend(send);
4153
4583
  }
4154
4584
 
@@ -0,0 +1,96 @@
1
+ // "Messages kept per conversation for switching back" (Settings › General):
2
+ // how many loaded messages the page keeps of a conversation it leaves, so a
3
+ // switch back shows the same rows. A setting of this browser, kept in
4
+ // localStorage like the scroll-on-send one. The slider runs from 100 to 1,000
5
+ // by 100 and has one more step at the far end, no limit. The document and
6
+ // the storage are passed in by the tests.
7
+
8
+ export const WINDOW_RETENTION_STORAGE_KEY = 'copilot_conversation_window_retention';
9
+ export const WINDOW_RETENTION_SLIDER_ID = 'conversation-window-retention-slider';
10
+ export const WINDOW_RETENTION_VALUE_ID = 'conversation-window-retention-value';
11
+ export const WINDOW_RETENTION_DEFAULT = 200;
12
+ export const WINDOW_RETENTION_MIN = 100;
13
+ export const WINDOW_RETENTION_MAX = 1000;
14
+ export const WINDOW_RETENTION_STEP = 100;
15
+ /** The slider's last step: no limit. */
16
+ export const WINDOW_RETENTION_UNLIMITED_POSITION = WINDOW_RETENTION_MAX + WINDOW_RETENTION_STEP;
17
+ const UNLIMITED_TOKEN = 'none';
18
+
19
+ function clampToStep(value) {
20
+ const stepped = Math.round(value / WINDOW_RETENTION_STEP) * WINDOW_RETENTION_STEP;
21
+ return Math.min(WINDOW_RETENTION_MAX, Math.max(WINDOW_RETENTION_MIN, stepped));
22
+ }
23
+
24
+ /** The cap a stored value stands for: a count, or Infinity for no limit. */
25
+ export function parseWindowRetention(stored) {
26
+ const text = String(stored ?? '').trim().toLowerCase();
27
+ if (!text) return WINDOW_RETENTION_DEFAULT;
28
+ if (text === UNLIMITED_TOKEN) return Infinity;
29
+ const value = Number(text);
30
+ if (!Number.isFinite(value) || value <= 0) return WINDOW_RETENTION_DEFAULT;
31
+ return clampToStep(value);
32
+ }
33
+
34
+ export function serializeWindowRetention(cap) {
35
+ return Number.isFinite(Number(cap)) ? String(clampToStep(Number(cap))) : UNLIMITED_TOKEN;
36
+ }
37
+
38
+ /** The cap at a slider position; the far end is no limit. */
39
+ export function windowRetentionFromSlider(position) {
40
+ const value = Number(position);
41
+ if (!Number.isFinite(value)) return WINDOW_RETENTION_DEFAULT;
42
+ if (value >= WINDOW_RETENTION_UNLIMITED_POSITION) return Infinity;
43
+ return clampToStep(value);
44
+ }
45
+
46
+ export function sliderPositionForWindowRetention(cap) {
47
+ return Number.isFinite(Number(cap)) ? clampToStep(Number(cap)) : WINDOW_RETENTION_UNLIMITED_POSITION;
48
+ }
49
+
50
+ export function formatWindowRetention(cap) {
51
+ if (!Number.isFinite(Number(cap))) return 'No limit';
52
+ return `${clampToStep(Number(cap)).toLocaleString('en-US')} messages`;
53
+ }
54
+
55
+ function storage() {
56
+ try { return globalThis.localStorage || null; } catch { return null; }
57
+ }
58
+
59
+ export function readWindowRetention(store = storage()) {
60
+ try {
61
+ return parseWindowRetention(store?.getItem(WINDOW_RETENTION_STORAGE_KEY));
62
+ } catch {
63
+ return WINDOW_RETENTION_DEFAULT;
64
+ }
65
+ }
66
+
67
+ function showValue(doc, cap) {
68
+ const text = formatWindowRetention(cap);
69
+ const label = doc?.getElementById?.(WINDOW_RETENTION_VALUE_ID);
70
+ if (label) label.textContent = text;
71
+ doc?.getElementById?.(WINDOW_RETENTION_SLIDER_ID)?.setAttribute?.('aria-valuetext', text);
72
+ }
73
+
74
+ export function syncWindowRetentionSlider(doc = globalThis.document, store = storage()) {
75
+ const cap = readWindowRetention(store);
76
+ const slider = doc?.getElementById?.(WINDOW_RETENTION_SLIDER_ID);
77
+ if (slider) slider.value = String(sliderPositionForWindowRetention(cap));
78
+ showValue(doc, cap);
79
+ }
80
+
81
+ /** While the thumb moves: the label follows, nothing is saved. */
82
+ export function previewWindowRetention(position, { doc = globalThis.document } = {}) {
83
+ showValue(doc, windowRetentionFromSlider(position));
84
+ }
85
+
86
+ /**
87
+ * Saves the slider's value and hands the new cap to `onChange` (the kept
88
+ * conversations are cut down to it). Returns the cap.
89
+ */
90
+ export function updateWindowRetention(position, { doc = globalThis.document, store = storage(), onChange = null } = {}) {
91
+ const cap = windowRetentionFromSlider(position);
92
+ try { store?.setItem(WINDOW_RETENTION_STORAGE_KEY, serializeWindowRetention(cap)); } catch {}
93
+ syncWindowRetentionSlider(doc, store);
94
+ if (typeof onChange === 'function') onChange(cap);
95
+ return cap;
96
+ }