@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
@@ -3,15 +3,24 @@
3
3
  // Decisions live in transcript-jump-bottom.mjs; this module reads the
4
4
  // scroller and the rows and applies them.
5
5
  //
6
+ // The transcript moves by itself in two cases only: the reader is at the
7
+ // end, or the reader has just sent something. Both are asked here, before
8
+ // the row is put on the page: followsTranscriptEnd() by every live update,
9
+ // ownSendFollows() by the reader's own sends. "At the end" is one margin
10
+ // everywhere (resolveNearBottomThresholdPx). An update that is not followed
11
+ // leaves the transcript where it is; what it added marks the button.
12
+ //
6
13
  // Following the end has no state of its own: every live update asks
7
- // isMessagesAtBottom() before it appends and scrolls. When the composer grows
14
+ // followsTranscriptEnd() before it appends and scrolls. When the composer grows
8
15
  // (the background-task panel appears or opens, a banner, a taller draft) the
9
16
  // transcript shrinks from below, scrollTop stays and no scroll event fires,
10
17
  // so the reader is left that many pixels above the end and the next update
11
- // stops following. The ResizeObserver here puts them back before paint.
18
+ // stops following. The ResizeObserver here puts them back before paint, and
19
+ // an update that lands before its round still follows (a reply streaming in
20
+ // right after the panel grew).
12
21
 
13
22
  import { currentConvId, isMessagesNearBottom, scrollBottom } from './store.js';
14
- import { isConversationWindowAtTail } from './conversation-view.js';
23
+ import { getHeldBackLiveRowCount, isConversationWindowAtTail, releaseReadingAnchorHold } from './conversation-view.js';
15
24
  import { disarmThinkingFollow, getThinkingFollowMode, syncThinkingFollowButtons } from './thinking-follow.js';
16
25
  import { resolveNearBottomThresholdPx } from './transcript-viewport-anchor.mjs';
17
26
  import {
@@ -20,8 +29,10 @@ import {
20
29
  shouldHoldFocusOnJump,
21
30
  } from './composer-keyboard.mjs';
22
31
  import {
32
+ isScrollOnSendEnabled,
33
+ followsEndThroughResize,
23
34
  isNearBottom,
24
- jumpBadgeText,
35
+ jumpButtonPresentation,
25
36
  jumpScrollBehavior,
26
37
  nextJumpButtonState,
27
38
  shouldRepinOnResize,
@@ -30,7 +41,10 @@ import {
30
41
 
31
42
  // The final snap of a smooth jump where the browser has no scrollend event.
32
43
  const SMOOTH_JUMP_FALLBACK_MS = 900;
33
- const BUTTON_LABEL = 'Jump to the latest message';
44
+ // A card that waits for the reader: a question or an approval, or a board
45
+ // whose actions are still open.
46
+ const WAITING_CARD_SELECTOR = '.relay-question-container, .relay-board-inline .relay-board-actions';
47
+ const STATUS_ID = 'jump-to-bottom-status';
34
48
 
35
49
  let initialized = false;
36
50
  let state = null;
@@ -43,6 +57,15 @@ let observedClientHeight = null;
43
57
  let jump = null;
44
58
  let updateScheduled = false;
45
59
  let loadConversationEnd = null;
60
+ // Counts the times the reader left the end: a scroll that took the position
61
+ // up and away from it, whatever caused it (a wheel, a drag, a key with the
62
+ // focus anywhere, the scrollbar). An input that moves nothing is none: a
63
+ // wheel notch at the end, a key typed into a card. What scrolls to the end on
64
+ // the reader's behalf after their own send checks that this has not happened
65
+ // since.
66
+ let readerMoveEpoch = 0;
67
+ let lastScrollTop = 0;
68
+ const endLeftListeners = new Set();
46
69
 
47
70
  function scroller() {
48
71
  return document.getElementById('messages');
@@ -58,7 +81,6 @@ function rowKeys(el) {
58
81
  if (!node.classList?.contains('msg')) continue;
59
82
  const key = transcriptRowKey({
60
83
  messageId: node.dataset.messageId,
61
- exchangePromptId: node.dataset.agentExchangePromptId,
62
84
  live: node.id === 'thinking-indicator',
63
85
  });
64
86
  if (key) keys.push(key);
@@ -66,42 +88,81 @@ function rowKeys(el) {
66
88
  return keys;
67
89
  }
68
90
 
69
- function render(visible, count) {
91
+ // Whether a waiting card lies wholly below what the reader sees.
92
+ function questionWaitingBelow(el) {
93
+ const bottom = el.getBoundingClientRect().bottom;
94
+ for (const card of el.querySelectorAll(WAITING_CARD_SELECTOR)) {
95
+ if (card.getBoundingClientRect().top >= bottom) return true;
96
+ }
97
+ return false;
98
+ }
99
+
100
+ // What a screen reader hears when the button changes its state: the button's
101
+ // own label is only read once it has the focus.
102
+ function statusRegion(button) {
103
+ let region = document.getElementById(STATUS_ID);
104
+ if (region || !button?.parentNode) return region;
105
+ region = document.createElement('div');
106
+ region.id = STATUS_ID;
107
+ region.setAttribute('role', 'status');
108
+ region.setAttribute('aria-live', 'polite');
109
+ region.style.cssText = 'position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0;';
110
+ button.parentNode.insertBefore(region, button.nextSibling);
111
+ return region;
112
+ }
113
+
114
+ function render(next) {
70
115
  const button = document.getElementById('jump-to-bottom-btn');
71
116
  if (!button) return;
72
- button.classList.toggle('visible', visible);
117
+ const shown = jumpButtonPresentation(next || {});
118
+ button.classList.toggle('visible', next?.visible === true);
119
+ if (button.dataset.state !== shown.state) button.dataset.state = shown.state;
73
120
  const badge = button.querySelector('.jump-to-bottom-badge');
74
- const text = visible ? jumpBadgeText(count) : '';
75
121
  if (badge) {
76
- badge.textContent = text;
77
- badge.hidden = !text;
122
+ if (badge.textContent !== shown.badge) badge.textContent = shown.badge;
123
+ badge.classList.toggle('jump-to-bottom-badge-dot', shown.dot);
124
+ badge.hidden = !shown.badge && !shown.dot;
125
+ }
126
+ if (button.getAttribute('aria-label') !== shown.label) {
127
+ button.setAttribute('aria-label', shown.label);
128
+ button.title = shown.label;
78
129
  }
79
- const label = text ? `${BUTTON_LABEL} (${count} new)` : BUTTON_LABEL;
80
- if (button.getAttribute('aria-label') !== label) button.setAttribute('aria-label', label);
130
+ const region = statusRegion(button);
131
+ if (region && region.textContent !== shown.announcement) region.textContent = shown.announcement;
81
132
  }
82
133
 
83
- function update() {
84
- updateScheduled = false;
85
- const el = scroller();
86
- if (!el) return;
134
+ function syncStateConversation() {
87
135
  const conversationId = String(currentConvId || '').trim();
88
136
  if (conversationId !== stateConversationId) {
89
137
  state = null;
90
138
  stateConversationId = conversationId;
91
139
  }
140
+ return conversationId;
141
+ }
142
+
143
+ function update() {
144
+ updateScheduled = false;
145
+ const el = scroller();
146
+ if (!el) return;
147
+ const conversationId = syncStateConversation();
92
148
  if (!conversationId || jump) {
93
- render(false, 0);
149
+ render(null);
94
150
  return;
95
151
  }
152
+ notePositionAfterContentChange(el);
96
153
  const distance = distanceFromBottom(el);
97
154
  const nearBottomPx = resolveNearBottomThresholdPx(el.clientHeight);
155
+ const away = !isNearBottom(distance, nearBottomPx);
98
156
  state = nextJumpButtonState(state, {
99
157
  distance,
100
158
  clientHeight: el.clientHeight,
101
159
  nearBottomPx,
102
- rowKeys: isNearBottom(distance, nearBottomPx) ? [] : rowKeys(el),
160
+ rowKeys: away ? rowKeys(el) : [],
161
+ question: away && questionWaitingBelow(el),
162
+ windowAtTail: isConversationWindowAtTail(),
163
+ waiting: getHeldBackLiveRowCount(),
103
164
  });
104
- render(state.visible, state.count);
165
+ render(state);
105
166
  }
106
167
 
107
168
  function scheduleUpdate() {
@@ -110,6 +171,120 @@ function scheduleUpdate() {
110
171
  requestAnimationFrame(update);
111
172
  }
112
173
 
174
+ /** A card or a board was put on the page, or taken off it. */
175
+ export function refreshJumpButton() {
176
+ scheduleUpdate();
177
+ }
178
+
179
+ /**
180
+ * `listener` runs when a reader who followed the end is found away from it
181
+ * without having scrolled: content landed that was not followed. Whoever
182
+ * keeps "at the end" from the last scroll event lets go of it then.
183
+ */
184
+ export function onTranscriptEndLeft(listener) {
185
+ if (typeof listener === 'function') endLeftListeners.add(listener);
186
+ }
187
+
188
+ /**
189
+ * Asked by every live update before it changes the transcript: true when
190
+ * the reader is at the end and the update scrolls with it. Otherwise the
191
+ * transcript stays where it is. Only a question: what was really added says
192
+ * so itself (a row is counted, anything else calls noteTranscriptNews()).
193
+ */
194
+ export function followsTranscriptEnd() {
195
+ // The bottom of a window that ends above the conversation's end is not the
196
+ // end: nothing live is drawn there, and nothing scrolls to it.
197
+ if (!isConversationWindowAtTail()) return false;
198
+ const el = scroller();
199
+ const height = jump ? null : observedClientHeight;
200
+ return followsEndThroughResize({
201
+ nearBottomNow: isMessagesNearBottom(),
202
+ clientHeight: el?.clientHeight,
203
+ observedClientHeight: height,
204
+ distanceAtObservedHeight: el && height !== null ? Math.max(0, el.scrollHeight - height - el.scrollTop) : null,
205
+ nearBottomPxAtObservedHeight: height !== null ? resolveNearBottomThresholdPx(height) : 0,
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Something landed below a reader who is not at the end and added no row to
211
+ * count (a reply growing in its bubble, a thought, a tool line): the button
212
+ * says that there is news until the reader is at the end again.
213
+ */
214
+ export function noteTranscriptNews() {
215
+ const el = scroller();
216
+ if (!el || !syncStateConversation()) return;
217
+ state = {
218
+ visible: state?.visible === true,
219
+ baseline: Array.isArray(state?.baseline) ? state.baseline : rowKeys(el),
220
+ count: state?.count || 0,
221
+ unseen: true,
222
+ question: state?.question === true,
223
+ };
224
+ scheduleUpdate();
225
+ }
226
+
227
+ /**
228
+ * The reader sends something themselves (the composer, a steer, Resend, an
229
+ * answer on a card, a board's action): taken at that moment, before anything
230
+ * is awaited. Nothing a relay or an agent puts into the conversation comes
231
+ * here.
232
+ */
233
+ export function beginOwnSend() {
234
+ return { conversationId: String(currentConvId || '').trim(), epoch: readerMoveEpoch, follows: false };
235
+ }
236
+
237
+ /**
238
+ * A send from the new-chat screen has no conversation; the relay's answer
239
+ * creates it. Named here once it is on screen, so the send goes on in it and
240
+ * in no other.
241
+ */
242
+ export function nameOwnSendConversation(send, conversationId) {
243
+ if (send && !send.conversationId) send.conversationId = String(conversationId || '').trim();
244
+ }
245
+
246
+ // The send is still the last thing the reader did to this conversation's
247
+ // position: it is on screen, and they have not scrolled away since.
248
+ function ownSendHolds(send) {
249
+ return !!send
250
+ && send.conversationId === String(currentConvId || '').trim()
251
+ && send.epoch === readerMoveEpoch;
252
+ }
253
+
254
+ /**
255
+ * Whether what the send puts on the page is followed to the end; asked
256
+ * before each row and each step. A reader who is at the end is followed like
257
+ * with anything else, whatever became of the send. The send adds one right
258
+ * to that: while it holds, it takes the reader to the end from wherever they
259
+ * are (unless that is switched off in the settings) and keeps them there
260
+ * through the layout that follows it. Once the reader has scrolled away, or
261
+ * another conversation is on screen, that right is gone.
262
+ */
263
+ export function ownSendFollows(send) {
264
+ const atEnd = isMessagesNearBottom();
265
+ if (!ownSendHolds(send)) return atEnd;
266
+ send.follows = send.follows || atEnd || isScrollOnSendEnabled();
267
+ return send.follows;
268
+ }
269
+
270
+ /**
271
+ * To the end after the reader's own send, and again through the layout that
272
+ * follows it (the bubble's separator, a picture that loads, the composer
273
+ * shrinking back): each step asks again.
274
+ */
275
+ export function scrollAfterOwnSend(send) {
276
+ const step = () => {
277
+ if (!ownSendFollows(send)) return;
278
+ // A place held while images load would take this scroll for the reader's
279
+ // and let go only then; the send moves the reader, so it lets go first.
280
+ releaseReadingAnchorHold();
281
+ scrollBottom();
282
+ };
283
+ step();
284
+ requestAnimationFrame(step);
285
+ setTimeout(step, 120);
286
+ }
287
+
113
288
  // A scroll event is dispatched a frame after the scroll, before that frame's
114
289
  // ResizeObserver round: when the composer grew in between, the transcript
115
290
  // has already shrunk, and the reader would read as having left the end. So
@@ -122,6 +297,18 @@ function notePosition(el) {
122
297
  pinnedScrollTop = el.scrollTop;
123
298
  }
124
299
 
300
+ // The content changed without a scroll event. A reader who followed the end
301
+ // and is away from it now was not followed (rows landed while they held a
302
+ // selection, a card grew): a later resize must not take them to the end.
303
+ function notePositionAfterContentChange(el) {
304
+ const followed = pinned;
305
+ notePosition(el);
306
+ if (!followed || pinned) return;
307
+ for (const listener of endLeftListeners) {
308
+ try { listener(); } catch {}
309
+ }
310
+ }
311
+
125
312
  function endJump() {
126
313
  if (!jump) return;
127
314
  jump.cleanup();
@@ -166,26 +353,33 @@ function prefersReducedMotion() {
166
353
  try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
167
354
  }
168
355
 
356
+ /**
357
+ * Takes the reader to the end. From a window that ends above the
358
+ * conversation's end, the end is loaded first: the promise settles once it
359
+ * is on the page (or the load failed), which an own send from such a window
360
+ * waits for.
361
+ */
169
362
  export function jumpTranscriptToBottom() {
170
363
  const el = scroller();
171
- if (!el) return;
172
- // The end wins over a followed section of the live bubble.
364
+ if (!el) return Promise.resolve();
365
+ // The end wins over a place held while images load, as over a followed
366
+ // section of the live bubble.
367
+ releaseReadingAnchorHold();
173
368
  if (getThinkingFollowMode()) {
174
369
  disarmThinkingFollow();
175
370
  syncThinkingFollowButtons(document, null);
176
371
  }
177
372
  state = null;
178
- render(false, 0);
373
+ render(null);
179
374
  // A window in the middle of the history (a search result, a pin) ends
180
375
  // above the conversation's end: the end is loaded instead.
181
376
  if (loadConversationEnd && !isConversationWindowAtTail()) {
182
377
  endJump();
183
378
  jump = { cleanup() {} };
184
- void Promise.resolve()
379
+ return Promise.resolve()
185
380
  .then(() => loadConversationEnd())
186
381
  .catch(() => {})
187
382
  .finally(finishJump);
188
- return;
189
383
  }
190
384
  const behavior = jumpScrollBehavior({
191
385
  distance: distanceFromBottom(el),
@@ -194,16 +388,22 @@ export function jumpTranscriptToBottom() {
194
388
  });
195
389
  if (behavior === 'smooth') startSmoothJump(el);
196
390
  else finishJump();
391
+ return Promise.resolve();
197
392
  }
198
393
 
199
394
  function handleScroll() {
200
395
  const el = scroller();
201
396
  if (!el) return;
397
+ const movedUp = el.scrollTop < lastScrollTop - 1;
398
+ lastScrollTop = el.scrollTop;
202
399
  if (jump) {
203
400
  if (distanceFromBottom(el) <= 1) finishJump();
204
401
  return;
205
402
  }
206
403
  notePosition(el);
404
+ // `pinned` is "at the end" as of this scroll. A clamp (the page got
405
+ // shorter) also takes scrollTop down, and ends at the end.
406
+ if (movedUp && !pinned) readerMoveEpoch += 1;
207
407
  scheduleUpdate();
208
408
  }
209
409
 
@@ -267,6 +467,7 @@ export function initTranscriptJumpToBottom({ loadEnd } = {}) {
267
467
  const button = document.getElementById('jump-to-bottom-btn');
268
468
  if (!el || !button) return;
269
469
  initialized = true;
470
+ lastScrollTop = el.scrollTop;
270
471
  pinnedScrollTop = el.scrollTop;
271
472
  observedClientHeight = el.clientHeight;
272
473
  button.addEventListener('click', (event) => {
@@ -280,8 +481,11 @@ export function initTranscriptJumpToBottom({ loadEnd } = {}) {
280
481
  window.visualViewport?.addEventListener?.('resize', noteViewport, { passive: true });
281
482
  window.addEventListener('resize', noteViewport, { passive: true });
282
483
  el.addEventListener('scroll', handleScroll, { passive: true });
484
+ // The whole subtree: the button shows at any distance once something new
485
+ // is below, so it also has to go when the end comes back to the reader
486
+ // without a scroll (the live bubble folding its thoughts, a row shrinking).
283
487
  if (typeof MutationObserver === 'function') {
284
- new MutationObserver(scheduleUpdate).observe(el, { childList: true });
488
+ new MutationObserver(scheduleUpdate).observe(el, { childList: true, subtree: true, characterData: true });
285
489
  }
286
490
  if (typeof ResizeObserver === 'function') {
287
491
  new ResizeObserver(handleResize).observe(el);
@@ -1,11 +1,16 @@
1
1
  // The transcript's jump-to-bottom button, without the DOM: when it shows,
2
- // what its badge counts, how far a click scrolls smoothly, and whether a
3
- // resize of the transcript re-pins a reader who was following the end.
2
+ // which of its three states it is in (plain, something new below, a question
3
+ // waits below), what its badge counts, how far a click scrolls smoothly, and
4
+ // whether a resize of the transcript re-pins a reader who was following the
5
+ // end.
4
6
  // transcript-jump-bottom-view.mjs reads the scroller and the rows and applies
5
7
  // what this module decides.
6
8
  //
7
9
  // "At the bottom" is the composer's near-bottom rule (isMessagesNearBottom,
8
10
  // resolveNearBottomThresholdPx); the caller passes its threshold in.
11
+ //
12
+ // Also here: the setting that decides whether the reader's own send jumps to
13
+ // the end.
9
14
 
10
15
  /** The button fades in once the reader is this many screens above the end. */
11
16
  export const JUMP_BUTTON_SHOW_SCREENS = 1;
@@ -14,15 +19,11 @@ export const JUMP_SMOOTH_MAX_SCREENS = 3;
14
19
  export const JUMP_BADGE_MAX = 99;
15
20
 
16
21
  /**
17
- * One key per row the reader sees. Every row of a folded agent exchange
18
- * (its prompt, its replies, its live bubble) carries the prompt's id, so the
19
- * fold counts once however many of its rows are on the page. The live
20
- * bubble of a turn outside a fold has the prompt's id as its message id, so
21
- * it gets a key of its own.
22
+ * One key per row the reader sees. The live bubble of a turn has the
23
+ * prompt's id as its message id, so it gets a key of its own. A folded agent
24
+ * prompt is a row like any other: its reply shows below it and counts too.
22
25
  */
23
- export function transcriptRowKey({ messageId = '', exchangePromptId = '', live = false } = {}) {
24
- const fold = String(exchangePromptId || '').trim();
25
- if (fold) return `fold:${fold}`;
26
+ export function transcriptRowKey({ messageId = '', live = false } = {}) {
26
27
  const id = String(messageId || '').trim();
27
28
  if (!id) return '';
28
29
  return live ? `live:${id}` : `msg:${id}`;
@@ -57,20 +58,105 @@ export function isNearBottom(distance, nearBottomPx) {
57
58
  /**
58
59
  * The button's next state from the scroller's geometry. At the bottom it
59
60
  * hides and forgets the baseline. Away from it, the rows on the page when the
60
- * reader left are the baseline the badge counts from; the button shows from
61
- * one screen above the end and, once shown, stays until the bottom.
61
+ * reader left are the baseline the badge counts from; the plain button shows
62
+ * from one screen above the end and, once shown, stays until the bottom.
63
+ *
64
+ * The transcript does not move under a reader who is away from the end, so
65
+ * the button is how they learn of what landed there, at any distance:
66
+ * `unseen` is a live update that was not followed (it holds until the
67
+ * bottom: a reply growing in its bubble adds no row to count), `question` a
68
+ * card waiting for an answer below the viewport (as it is now: answered
69
+ * elsewhere, it is gone).
62
70
  */
63
- export function nextJumpButtonState(previous, { distance, clientHeight, nearBottomPx, rowKeys = [] } = {}) {
64
- if (isNearBottom(distance, nearBottomPx)) return { visible: false, baseline: null, count: 0 };
65
- const baseline = Array.isArray(previous?.baseline) ? previous.baseline : rowKeys.slice();
71
+ export function nextJumpButtonState(previous, {
72
+ distance,
73
+ clientHeight,
74
+ nearBottomPx,
75
+ rowKeys = [],
76
+ unseen = false,
77
+ question = false,
78
+ windowAtTail = true,
79
+ waiting = 0,
80
+ } = {}) {
81
+ // Live rows held back for a window that ends above the conversation's end
82
+ // (transcript-island.mjs): the bottom of the page is not the end, so the
83
+ // button stays while they wait.
84
+ const waitingNow = windowAtTail ? 0 : Math.max(0, Math.trunc(Number(waiting) || 0));
85
+ if (isNearBottom(distance, nearBottomPx) && !waitingNow) {
86
+ return { visible: false, baseline: null, count: 0, unseen: false, question: false, waiting: 0 };
87
+ }
88
+ // Below such a window the rows put on the page are the newer pages, older
89
+ // than anything live: they are not counted, and the baseline is taken once
90
+ // the window meets the end.
91
+ const baseline = !windowAtTail ? null : (Array.isArray(previous?.baseline) ? previous.baseline : rowKeys.slice());
66
92
  const screens = Number(clientHeight) > 0 ? Number(distance) / Number(clientHeight) : 0;
93
+ const count = windowAtTail ? countNewRows(baseline, rowKeys) : 0;
94
+ const unseenNow = previous?.unseen === true || unseen === true;
95
+ const questionNow = question === true;
67
96
  return {
68
- visible: previous?.visible === true || screens >= JUMP_BUTTON_SHOW_SCREENS,
97
+ visible: previous?.visible === true || screens >= JUMP_BUTTON_SHOW_SCREENS || count > 0 || waitingNow > 0 || unseenNow || questionNow,
69
98
  baseline,
70
- count: countNewRows(baseline, rowKeys),
99
+ count,
100
+ unseen: unseenNow,
101
+ question: questionNow,
102
+ waiting: waitingNow,
71
103
  };
72
104
  }
73
105
 
106
+ export const JUMP_BUTTON_LABEL = 'Jump to the latest message';
107
+ export const JUMP_BUTTON_QUESTION_LABEL = 'A question is waiting for your answer. Jump to it';
108
+ export const JUMP_QUESTION_BADGE = '?';
109
+
110
+ /**
111
+ * How the rows held back for a window above the end show on the badge:
112
+ * 'count' adds them to the count, 'dot' draws the dot of news without a row.
113
+ */
114
+ export const HELD_BACK_ROWS_BADGE = 'count';
115
+
116
+ /**
117
+ * How a state looks and reads. `state` is 'plain', 'new' or 'question'; a
118
+ * waiting question wins over news. `badge` is the count, '?' for a question,
119
+ * or empty: with `dot`, an empty badge is drawn as a dot (news without a row
120
+ * to count). `waiting` rows are held back below the page
121
+ * (HELD_BACK_ROWS_BADGE). `announcement` is what a screen reader is told
122
+ * when the state is entered; the label alone is only read when the button is
123
+ * focused.
124
+ */
125
+ export function jumpButtonPresentation({ visible = false, count = 0, unseen = false, question = false, waiting = 0 } = {}, { heldBackRowsBadge = HELD_BACK_ROWS_BADGE } = {}) {
126
+ if (!visible) return { state: 'plain', label: JUMP_BUTTON_LABEL, badge: '', dot: false, announcement: '' };
127
+ if (question) {
128
+ return {
129
+ state: 'question',
130
+ label: JUMP_BUTTON_QUESTION_LABEL,
131
+ badge: JUMP_QUESTION_BADGE,
132
+ dot: false,
133
+ announcement: 'A question is waiting for your answer below.',
134
+ };
135
+ }
136
+ const held = Math.max(0, Math.trunc(Number(waiting) || 0));
137
+ const counted = Math.max(0, Math.trunc(Number(count) || 0)) + (heldBackRowsBadge === 'count' ? held : 0);
138
+ const badge = jumpBadgeText(counted);
139
+ if (badge) {
140
+ return {
141
+ state: 'new',
142
+ label: `${JUMP_BUTTON_LABEL} (${counted} new)`,
143
+ badge,
144
+ dot: false,
145
+ announcement: 'New messages below.',
146
+ };
147
+ }
148
+ if (unseen || held > 0) {
149
+ return {
150
+ state: 'new',
151
+ label: `${JUMP_BUTTON_LABEL} (new below)`,
152
+ badge: '',
153
+ dot: true,
154
+ announcement: 'New messages below.',
155
+ };
156
+ }
157
+ return { state: 'plain', label: JUMP_BUTTON_LABEL, badge: '', dot: false, announcement: '' };
158
+ }
159
+
74
160
  /** 'smooth' up to JUMP_SMOOTH_MAX_SCREENS above the end, else 'instant'. */
75
161
  export function jumpScrollBehavior({ distance, clientHeight, reducedMotion = false } = {}) {
76
162
  if (reducedMotion) return 'instant';
@@ -88,12 +174,74 @@ export function jumpBadgeText(count) {
88
174
  /**
89
175
  * Whether a resize of the transcript (the composer or the background-task
90
176
  * panel growing under it) re-pins it to the end. The scroller shrinking from
91
- * below keeps scrollTop and fires no scroll event, so a reader who was
92
- * following is still described by the last scroll: `pinned` and the
93
- * scrollTop it was taken at. Once scrollTop has moved since (a restore, a
94
- * clamp, the browser's anchoring), the geometry as it is now decides.
177
+ * below keeps scrollTop and fires no scroll event, so the reader is still
178
+ * described by the last note of their position: `pinned` (they followed the
179
+ * end, by a scroll event or when the content last changed) and the scrollTop
180
+ * it was taken at. While scrollTop is that one, the note alone decides: a
181
+ * reader who was not at the end is never taken there by a resize. Once
182
+ * scrollTop has moved since (a restore, a clamp, the browser's anchoring),
183
+ * the note is stale and the geometry as it is now decides.
95
184
  */
96
185
  export function shouldRepinOnResize({ pinned, pinnedScrollTop, scrollTop, nearBottomNow }) {
97
- if (pinned && Number(scrollTop) === Number(pinnedScrollTop)) return true;
186
+ if (Number(scrollTop) === Number(pinnedScrollTop)) return pinned === true;
98
187
  return nearBottomNow === true;
99
188
  }
189
+
190
+ /**
191
+ * Whether a live update follows the end. A reader at the end now is
192
+ * followed. So is one the re-pin above is on its way to: the transcript
193
+ * shrank from below (`clientHeight` under the height the last
194
+ * ResizeObserver round saw) and at that height the reader is at the end.
195
+ * An update that lands between the shrink and that round would otherwise
196
+ * find them the panel's height above the end, stay put, and leave them
197
+ * there. The geometry decides, not the last note of the position: a scroll
198
+ * to the end (the update before) is noted only by its scroll event a frame
199
+ * later, and the shrink and this update can both land before it.
200
+ */
201
+ export function followsEndThroughResize({ nearBottomNow, clientHeight, observedClientHeight, distanceAtObservedHeight, nearBottomPxAtObservedHeight }) {
202
+ if (nearBottomNow === true) return true;
203
+ if (!(Number(clientHeight) < Number(observedClientHeight))) return false;
204
+ return isNearBottom(distanceAtObservedHeight, nearBottomPxAtObservedHeight);
205
+ }
206
+
207
+ // "Jump to the end when I send a message" (Settings › General). A setting of
208
+ // this browser, kept in localStorage like the theme. On unless switched off:
209
+ // with it off, the reader's own send follows the same rule as everything
210
+ // else and only scrolls a transcript that is at its end already. The
211
+ // document and the storage are passed in by the tests.
212
+
213
+ export const SCROLL_ON_SEND_STORAGE_KEY = 'copilot_scroll_on_send';
214
+ export const SCROLL_ON_SEND_TOGGLE_ID = 'scroll-on-send-toggle';
215
+
216
+ /** Anything but an explicit off is on: a missing or damaged value included. */
217
+ export function parseScrollOnSendSetting(stored) {
218
+ const value = String(stored ?? '').trim().toLowerCase();
219
+ if (!value) return true;
220
+ return value !== '0' && value !== 'false';
221
+ }
222
+
223
+ export function serializeScrollOnSendSetting(enabled) {
224
+ return enabled ? '1' : '0';
225
+ }
226
+
227
+ function storage() {
228
+ try { return globalThis.localStorage || null; } catch { return null; }
229
+ }
230
+
231
+ export function isScrollOnSendEnabled(store = storage()) {
232
+ try {
233
+ return parseScrollOnSendSetting(store?.getItem(SCROLL_ON_SEND_STORAGE_KEY));
234
+ } catch {
235
+ return true;
236
+ }
237
+ }
238
+
239
+ export function syncScrollOnSendToggle(doc = globalThis.document, store = storage()) {
240
+ const checkbox = doc?.getElementById?.(SCROLL_ON_SEND_TOGGLE_ID);
241
+ if (checkbox) checkbox.checked = isScrollOnSendEnabled(store);
242
+ }
243
+
244
+ export function updateScrollOnSendSetting(enabled, { doc = globalThis.document, store = storage() } = {}) {
245
+ try { store?.setItem(SCROLL_ON_SEND_STORAGE_KEY, serializeScrollOnSendSetting(!!enabled)); } catch {}
246
+ syncScrollOnSendToggle(doc, store);
247
+ }