@oar-sh/oar 0.9.10 → 0.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +314 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +95 -22
  5. package/server/claude-worker/claude-session-process.mjs +89 -11
  6. package/server/claude-worker/claude-session-worker.mjs +9 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
  8. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  9. package/server/db-schema.mjs +59 -2
  10. package/server/grok-worker/acp-client.mjs +2 -1
  11. package/server/grok-worker/acp-host-services.mjs +4 -3
  12. package/server/migrations/0013-previews.mjs +130 -0
  13. package/server/migrations/0014-conversation-groups.mjs +89 -0
  14. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  15. package/server/migrations/0016-share-access-log.mjs +110 -0
  16. package/server/public/app/about-ui.js +73 -0
  17. package/server/public/app/about-view.mjs +234 -0
  18. package/server/public/app/action-confirmations.js +31 -14
  19. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  20. package/server/public/app/api-client.js +251 -16
  21. package/server/public/app/ask-user-view.js +17 -15
  22. package/server/public/app/bootstrap.js +158 -32
  23. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  24. package/server/public/app/context-menu.mjs +90 -6
  25. package/server/public/app/conversation-group-drag.mjs +191 -0
  26. package/server/public/app/conversation-group-order.mjs +265 -0
  27. package/server/public/app/conversation-group-state.mjs +100 -0
  28. package/server/public/app/conversation-list-drag.mjs +449 -0
  29. package/server/public/app/conversation-turn-state.mjs +81 -0
  30. package/server/public/app/conversation-view.js +136 -93
  31. package/server/public/app/cwd-picker.js +1 -1
  32. package/server/public/app/hidden-messages-view.mjs +312 -0
  33. package/server/public/app/host-suspend-ui.js +7 -1
  34. package/server/public/app/journal-view.js +787 -22
  35. package/server/public/app/preview-cards.mjs +117 -31
  36. package/server/public/app/preview-command.mjs +10 -1
  37. package/server/public/app/relay-board-view.js +19 -6
  38. package/server/public/app/relay-restart-error.mjs +16 -0
  39. package/server/public/app/settings-modal.js +40 -8
  40. package/server/public/app/settings-tabs.js +1 -1
  41. package/server/public/app/share-format.mjs +49 -0
  42. package/server/public/app/share-preview-marks.mjs +167 -0
  43. package/server/public/app/share-state.js +109 -0
  44. package/server/public/app/shared-conversations-settings.js +480 -0
  45. package/server/public/app/socket-handlers.js +80 -14
  46. package/server/public/app/store.js +96 -30
  47. package/server/public/app/subagent-row.mjs +3 -3
  48. package/server/public/app/subagent-viewer.mjs +30 -6
  49. package/server/public/app/thinking-follow.js +0 -4
  50. package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
  51. package/server/public/app/transcript-jump-bottom.mjs +132 -21
  52. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  53. package/server/public/index.html +386 -15
  54. package/server/relay-tools.md +2 -1
  55. package/server/relay.mjs +2 -1
  56. package/server/repositories/conversation-group-repository.mjs +444 -0
  57. package/server/repositories/message-repository.mjs +1 -0
  58. package/server/repositories/session-repository.mjs +12 -37
  59. package/server/repositories/share-repository.mjs +322 -0
  60. package/server/repositories/share-visibility-statements.mjs +10 -0
  61. package/server/routes/about-routes.mjs +15 -0
  62. package/server/routes/cache-routes.mjs +4 -0
  63. package/server/routes/conversation-group-routes.mjs +171 -0
  64. package/server/routes/messages-routes.mjs +184 -65
  65. package/server/routes/preview-routes.mjs +9 -1
  66. package/server/routes/sessions-routes.mjs +771 -193
  67. package/server/server-runtime.mjs +316 -100
  68. package/server/services/about-service.mjs +461 -0
  69. package/server/services/background-task-store.mjs +128 -0
  70. package/server/services/background-work-view.mjs +228 -0
  71. package/server/services/cache-rebuild-service.mjs +30 -0
  72. package/server/services/delete-archive-service.mjs +4 -1
  73. package/server/services/detached-worker-watch-service.mjs +150 -0
  74. package/server/services/hidden-message-list.mjs +21 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/message-pin-list.mjs +1 -1
  77. package/server/services/preview-health-probe.mjs +8 -1
  78. package/server/services/preview-proxy-server.mjs +8 -4
  79. package/server/services/preview-registry-service.mjs +676 -26
  80. package/server/services/preview-static-handler.mjs +40 -7
  81. package/server/services/preview-store.mjs +274 -0
  82. package/server/services/rate-limit-service.mjs +12 -1
  83. package/server/services/relay-auth.mjs +183 -0
  84. package/server/services/relay-cli-launcher-service.mjs +3 -1
  85. package/server/services/relay-event-service.mjs +17 -0
  86. package/server/services/remote-relay-dispatcher.mjs +57 -5
  87. package/server/services/request-boundary.mjs +183 -0
  88. package/server/services/session-activity-tracker.mjs +227 -19
  89. package/server/services/session-worker-launch-service.mjs +190 -6
  90. package/server/services/session-worker-process-service.mjs +10 -6
  91. package/server/services/share-access-log-service.mjs +123 -0
  92. package/server/services/share-path-redaction.mjs +424 -0
  93. package/server/services/status-event-service.mjs +7 -3
  94. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  95. package/server/services/windows-autostart-service.mjs +6 -1
  96. package/server/services/windows-boot-autostart-service.mjs +207 -23
  97. package/shared/preview-tool-core.mjs +15 -5
  98. package/shared/remote-relay-contract.mjs +3 -1
  99. package/shared/share-path-privacy.mjs +30 -0
  100. package/shared/windows-system-tools.mjs +106 -0
  101. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  102. package/shared/worker-runtime/heartbeat.mjs +24 -0
  103. package/shared/worker-runtime/kept-posts.mjs +83 -0
  104. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -3,8 +3,15 @@
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
@@ -20,8 +27,9 @@ import {
20
27
  shouldHoldFocusOnJump,
21
28
  } from './composer-keyboard.mjs';
22
29
  import {
30
+ isScrollOnSendEnabled,
23
31
  isNearBottom,
24
- jumpBadgeText,
32
+ jumpButtonPresentation,
25
33
  jumpScrollBehavior,
26
34
  nextJumpButtonState,
27
35
  shouldRepinOnResize,
@@ -30,7 +38,10 @@ import {
30
38
 
31
39
  // The final snap of a smooth jump where the browser has no scrollend event.
32
40
  const SMOOTH_JUMP_FALLBACK_MS = 900;
33
- const BUTTON_LABEL = 'Jump to the latest message';
41
+ // A card that waits for the reader: a question or an approval, or a board
42
+ // whose actions are still open.
43
+ const WAITING_CARD_SELECTOR = '.relay-question-container, .relay-board-inline .relay-board-actions';
44
+ const STATUS_ID = 'jump-to-bottom-status';
34
45
 
35
46
  let initialized = false;
36
47
  let state = null;
@@ -43,6 +54,15 @@ let observedClientHeight = null;
43
54
  let jump = null;
44
55
  let updateScheduled = false;
45
56
  let loadConversationEnd = null;
57
+ // Counts the times the reader left the end: a scroll that took the position
58
+ // up and away from it, whatever caused it (a wheel, a drag, a key with the
59
+ // focus anywhere, the scrollbar). An input that moves nothing is none: a
60
+ // wheel notch at the end, a key typed into a card. What scrolls to the end on
61
+ // the reader's behalf after their own send checks that this has not happened
62
+ // since.
63
+ let readerMoveEpoch = 0;
64
+ let lastScrollTop = 0;
65
+ const endLeftListeners = new Set();
46
66
 
47
67
  function scroller() {
48
68
  return document.getElementById('messages');
@@ -58,7 +78,6 @@ function rowKeys(el) {
58
78
  if (!node.classList?.contains('msg')) continue;
59
79
  const key = transcriptRowKey({
60
80
  messageId: node.dataset.messageId,
61
- exchangePromptId: node.dataset.agentExchangePromptId,
62
81
  live: node.id === 'thinking-indicator',
63
82
  });
64
83
  if (key) keys.push(key);
@@ -66,42 +85,79 @@ function rowKeys(el) {
66
85
  return keys;
67
86
  }
68
87
 
69
- function render(visible, count) {
88
+ // Whether a waiting card lies wholly below what the reader sees.
89
+ function questionWaitingBelow(el) {
90
+ const bottom = el.getBoundingClientRect().bottom;
91
+ for (const card of el.querySelectorAll(WAITING_CARD_SELECTOR)) {
92
+ if (card.getBoundingClientRect().top >= bottom) return true;
93
+ }
94
+ return false;
95
+ }
96
+
97
+ // What a screen reader hears when the button changes its state: the button's
98
+ // own label is only read once it has the focus.
99
+ function statusRegion(button) {
100
+ let region = document.getElementById(STATUS_ID);
101
+ if (region || !button?.parentNode) return region;
102
+ region = document.createElement('div');
103
+ region.id = STATUS_ID;
104
+ region.setAttribute('role', 'status');
105
+ region.setAttribute('aria-live', 'polite');
106
+ region.style.cssText = 'position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0;';
107
+ button.parentNode.insertBefore(region, button.nextSibling);
108
+ return region;
109
+ }
110
+
111
+ function render(next) {
70
112
  const button = document.getElementById('jump-to-bottom-btn');
71
113
  if (!button) return;
72
- button.classList.toggle('visible', visible);
114
+ const shown = jumpButtonPresentation(next || {});
115
+ button.classList.toggle('visible', next?.visible === true);
116
+ if (button.dataset.state !== shown.state) button.dataset.state = shown.state;
73
117
  const badge = button.querySelector('.jump-to-bottom-badge');
74
- const text = visible ? jumpBadgeText(count) : '';
75
118
  if (badge) {
76
- badge.textContent = text;
77
- badge.hidden = !text;
119
+ if (badge.textContent !== shown.badge) badge.textContent = shown.badge;
120
+ badge.classList.toggle('jump-to-bottom-badge-dot', shown.dot);
121
+ badge.hidden = !shown.badge && !shown.dot;
78
122
  }
79
- const label = text ? `${BUTTON_LABEL} (${count} new)` : BUTTON_LABEL;
80
- if (button.getAttribute('aria-label') !== label) button.setAttribute('aria-label', label);
123
+ if (button.getAttribute('aria-label') !== shown.label) {
124
+ button.setAttribute('aria-label', shown.label);
125
+ button.title = shown.label;
126
+ }
127
+ const region = statusRegion(button);
128
+ if (region && region.textContent !== shown.announcement) region.textContent = shown.announcement;
81
129
  }
82
130
 
83
- function update() {
84
- updateScheduled = false;
85
- const el = scroller();
86
- if (!el) return;
131
+ function syncStateConversation() {
87
132
  const conversationId = String(currentConvId || '').trim();
88
133
  if (conversationId !== stateConversationId) {
89
134
  state = null;
90
135
  stateConversationId = conversationId;
91
136
  }
137
+ return conversationId;
138
+ }
139
+
140
+ function update() {
141
+ updateScheduled = false;
142
+ const el = scroller();
143
+ if (!el) return;
144
+ const conversationId = syncStateConversation();
92
145
  if (!conversationId || jump) {
93
- render(false, 0);
146
+ render(null);
94
147
  return;
95
148
  }
149
+ notePositionAfterContentChange(el);
96
150
  const distance = distanceFromBottom(el);
97
151
  const nearBottomPx = resolveNearBottomThresholdPx(el.clientHeight);
152
+ const away = !isNearBottom(distance, nearBottomPx);
98
153
  state = nextJumpButtonState(state, {
99
154
  distance,
100
155
  clientHeight: el.clientHeight,
101
156
  nearBottomPx,
102
- rowKeys: isNearBottom(distance, nearBottomPx) ? [] : rowKeys(el),
157
+ rowKeys: away ? rowKeys(el) : [],
158
+ question: away && questionWaitingBelow(el),
103
159
  });
104
- render(state.visible, state.count);
160
+ render(state);
105
161
  }
106
162
 
107
163
  function scheduleUpdate() {
@@ -110,6 +166,103 @@ function scheduleUpdate() {
110
166
  requestAnimationFrame(update);
111
167
  }
112
168
 
169
+ /** A card or a board was put on the page, or taken off it. */
170
+ export function refreshJumpButton() {
171
+ scheduleUpdate();
172
+ }
173
+
174
+ /**
175
+ * `listener` runs when a reader who followed the end is found away from it
176
+ * without having scrolled: content landed that was not followed. Whoever
177
+ * keeps "at the end" from the last scroll event lets go of it then.
178
+ */
179
+ export function onTranscriptEndLeft(listener) {
180
+ if (typeof listener === 'function') endLeftListeners.add(listener);
181
+ }
182
+
183
+ /**
184
+ * Asked by every live update before it changes the transcript: true when
185
+ * the reader is at the end and the update scrolls with it. Otherwise the
186
+ * transcript stays where it is. Only a question: what was really added says
187
+ * so itself (a row is counted, anything else calls noteTranscriptNews()).
188
+ */
189
+ export function followsTranscriptEnd() {
190
+ return isMessagesNearBottom();
191
+ }
192
+
193
+ /**
194
+ * Something landed below a reader who is not at the end and added no row to
195
+ * count (a reply growing in its bubble, a thought, a tool line): the button
196
+ * says that there is news until the reader is at the end again.
197
+ */
198
+ export function noteTranscriptNews() {
199
+ const el = scroller();
200
+ if (!el || !syncStateConversation()) return;
201
+ state = {
202
+ visible: state?.visible === true,
203
+ baseline: Array.isArray(state?.baseline) ? state.baseline : rowKeys(el),
204
+ count: state?.count || 0,
205
+ unseen: true,
206
+ question: state?.question === true,
207
+ };
208
+ scheduleUpdate();
209
+ }
210
+
211
+ /**
212
+ * The reader sends something themselves (the composer, a steer, Resend, an
213
+ * answer on a card, a board's action): taken at that moment, before anything
214
+ * is awaited. Nothing a relay or an agent puts into the conversation comes
215
+ * here.
216
+ */
217
+ export function beginOwnSend() {
218
+ return { conversationId: String(currentConvId || '').trim(), epoch: readerMoveEpoch, follows: false };
219
+ }
220
+
221
+ /**
222
+ * A send from the new-chat screen has no conversation; the relay's answer
223
+ * creates it. Named here once it is on screen, so the send goes on in it and
224
+ * in no other.
225
+ */
226
+ export function nameOwnSendConversation(send, conversationId) {
227
+ if (send && !send.conversationId) send.conversationId = String(conversationId || '').trim();
228
+ }
229
+
230
+ // The send is still the last thing the reader did to this conversation's
231
+ // position: it is on screen, and they have not scrolled away since.
232
+ function ownSendHolds(send) {
233
+ return !!send
234
+ && send.conversationId === String(currentConvId || '').trim()
235
+ && send.epoch === readerMoveEpoch;
236
+ }
237
+
238
+ /**
239
+ * Whether what the send puts on the page is followed to the end; asked
240
+ * before each row and each step. A reader who is at the end is followed like
241
+ * with anything else, whatever became of the send. The send adds one right
242
+ * to that: while it holds, it takes the reader to the end from wherever they
243
+ * are (unless that is switched off in the settings) and keeps them there
244
+ * through the layout that follows it. Once the reader has scrolled away, or
245
+ * another conversation is on screen, that right is gone.
246
+ */
247
+ export function ownSendFollows(send) {
248
+ const atEnd = isMessagesNearBottom();
249
+ if (!ownSendHolds(send)) return atEnd;
250
+ send.follows = send.follows || atEnd || isScrollOnSendEnabled();
251
+ return send.follows;
252
+ }
253
+
254
+ /**
255
+ * To the end after the reader's own send, and again through the layout that
256
+ * follows it (the bubble's separator, a picture that loads, the composer
257
+ * shrinking back): each step asks again.
258
+ */
259
+ export function scrollAfterOwnSend(send) {
260
+ const step = () => { if (ownSendFollows(send)) scrollBottom(); };
261
+ step();
262
+ requestAnimationFrame(step);
263
+ setTimeout(step, 120);
264
+ }
265
+
113
266
  // A scroll event is dispatched a frame after the scroll, before that frame's
114
267
  // ResizeObserver round: when the composer grew in between, the transcript
115
268
  // has already shrunk, and the reader would read as having left the end. So
@@ -122,6 +275,18 @@ function notePosition(el) {
122
275
  pinnedScrollTop = el.scrollTop;
123
276
  }
124
277
 
278
+ // The content changed without a scroll event. A reader who followed the end
279
+ // and is away from it now was not followed (rows landed while they held a
280
+ // selection, a card grew): a later resize must not take them to the end.
281
+ function notePositionAfterContentChange(el) {
282
+ const followed = pinned;
283
+ notePosition(el);
284
+ if (!followed || pinned) return;
285
+ for (const listener of endLeftListeners) {
286
+ try { listener(); } catch {}
287
+ }
288
+ }
289
+
125
290
  function endJump() {
126
291
  if (!jump) return;
127
292
  jump.cleanup();
@@ -175,7 +340,7 @@ export function jumpTranscriptToBottom() {
175
340
  syncThinkingFollowButtons(document, null);
176
341
  }
177
342
  state = null;
178
- render(false, 0);
343
+ render(null);
179
344
  // A window in the middle of the history (a search result, a pin) ends
180
345
  // above the conversation's end: the end is loaded instead.
181
346
  if (loadConversationEnd && !isConversationWindowAtTail()) {
@@ -199,11 +364,16 @@ export function jumpTranscriptToBottom() {
199
364
  function handleScroll() {
200
365
  const el = scroller();
201
366
  if (!el) return;
367
+ const movedUp = el.scrollTop < lastScrollTop - 1;
368
+ lastScrollTop = el.scrollTop;
202
369
  if (jump) {
203
370
  if (distanceFromBottom(el) <= 1) finishJump();
204
371
  return;
205
372
  }
206
373
  notePosition(el);
374
+ // `pinned` is "at the end" as of this scroll. A clamp (the page got
375
+ // shorter) also takes scrollTop down, and ends at the end.
376
+ if (movedUp && !pinned) readerMoveEpoch += 1;
207
377
  scheduleUpdate();
208
378
  }
209
379
 
@@ -267,6 +437,7 @@ export function initTranscriptJumpToBottom({ loadEnd } = {}) {
267
437
  const button = document.getElementById('jump-to-bottom-btn');
268
438
  if (!el || !button) return;
269
439
  initialized = true;
440
+ lastScrollTop = el.scrollTop;
270
441
  pinnedScrollTop = el.scrollTop;
271
442
  observedClientHeight = el.clientHeight;
272
443
  button.addEventListener('click', (event) => {
@@ -280,8 +451,11 @@ export function initTranscriptJumpToBottom({ loadEnd } = {}) {
280
451
  window.visualViewport?.addEventListener?.('resize', noteViewport, { passive: true });
281
452
  window.addEventListener('resize', noteViewport, { passive: true });
282
453
  el.addEventListener('scroll', handleScroll, { passive: true });
454
+ // The whole subtree: the button shows at any distance once something new
455
+ // is below, so it also has to go when the end comes back to the reader
456
+ // without a scroll (the live bubble folding its thoughts, a row shrinking).
283
457
  if (typeof MutationObserver === 'function') {
284
- new MutationObserver(scheduleUpdate).observe(el, { childList: true });
458
+ new MutationObserver(scheduleUpdate).observe(el, { childList: true, subtree: true, characterData: true });
285
459
  }
286
460
  if (typeof ResizeObserver === 'function') {
287
461
  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,85 @@ 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 };
71
+ export function nextJumpButtonState(previous, {
72
+ distance,
73
+ clientHeight,
74
+ nearBottomPx,
75
+ rowKeys = [],
76
+ unseen = false,
77
+ question = false,
78
+ } = {}) {
79
+ if (isNearBottom(distance, nearBottomPx)) {
80
+ return { visible: false, baseline: null, count: 0, unseen: false, question: false };
81
+ }
65
82
  const baseline = Array.isArray(previous?.baseline) ? previous.baseline : rowKeys.slice();
66
83
  const screens = Number(clientHeight) > 0 ? Number(distance) / Number(clientHeight) : 0;
84
+ const count = countNewRows(baseline, rowKeys);
85
+ const unseenNow = previous?.unseen === true || unseen === true;
86
+ const questionNow = question === true;
67
87
  return {
68
- visible: previous?.visible === true || screens >= JUMP_BUTTON_SHOW_SCREENS,
88
+ visible: previous?.visible === true || screens >= JUMP_BUTTON_SHOW_SCREENS || count > 0 || unseenNow || questionNow,
69
89
  baseline,
70
- count: countNewRows(baseline, rowKeys),
90
+ count,
91
+ unseen: unseenNow,
92
+ question: questionNow,
71
93
  };
72
94
  }
73
95
 
96
+ export const JUMP_BUTTON_LABEL = 'Jump to the latest message';
97
+ export const JUMP_BUTTON_QUESTION_LABEL = 'A question is waiting for your answer. Jump to it';
98
+ export const JUMP_QUESTION_BADGE = '?';
99
+
100
+ /**
101
+ * How a state looks and reads. `state` is 'plain', 'new' or 'question'; a
102
+ * waiting question wins over news. `badge` is the count, '?' for a question,
103
+ * or empty: with `dot`, an empty badge is drawn as a dot (news without a row
104
+ * to count). `announcement` is what a screen reader is told when the state is
105
+ * entered; the label alone is only read when the button is focused.
106
+ */
107
+ export function jumpButtonPresentation({ visible = false, count = 0, unseen = false, question = false } = {}) {
108
+ if (!visible) return { state: 'plain', label: JUMP_BUTTON_LABEL, badge: '', dot: false, announcement: '' };
109
+ if (question) {
110
+ return {
111
+ state: 'question',
112
+ label: JUMP_BUTTON_QUESTION_LABEL,
113
+ badge: JUMP_QUESTION_BADGE,
114
+ dot: false,
115
+ announcement: 'A question is waiting for your answer below.',
116
+ };
117
+ }
118
+ const badge = jumpBadgeText(count);
119
+ if (badge) {
120
+ return {
121
+ state: 'new',
122
+ label: `${JUMP_BUTTON_LABEL} (${Math.trunc(Number(count))} new)`,
123
+ badge,
124
+ dot: false,
125
+ announcement: 'New messages below.',
126
+ };
127
+ }
128
+ if (unseen) {
129
+ return {
130
+ state: 'new',
131
+ label: `${JUMP_BUTTON_LABEL} (new below)`,
132
+ badge: '',
133
+ dot: true,
134
+ announcement: 'New messages below.',
135
+ };
136
+ }
137
+ return { state: 'plain', label: JUMP_BUTTON_LABEL, badge: '', dot: false, announcement: '' };
138
+ }
139
+
74
140
  /** 'smooth' up to JUMP_SMOOTH_MAX_SCREENS above the end, else 'instant'. */
75
141
  export function jumpScrollBehavior({ distance, clientHeight, reducedMotion = false } = {}) {
76
142
  if (reducedMotion) return 'instant';
@@ -88,12 +154,57 @@ export function jumpBadgeText(count) {
88
154
  /**
89
155
  * Whether a resize of the transcript (the composer or the background-task
90
156
  * 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.
157
+ * below keeps scrollTop and fires no scroll event, so the reader is still
158
+ * described by the last note of their position: `pinned` (they followed the
159
+ * end, by a scroll event or when the content last changed) and the scrollTop
160
+ * it was taken at. While scrollTop is that one, the note alone decides: a
161
+ * reader who was not at the end is never taken there by a resize. Once
162
+ * scrollTop has moved since (a restore, a clamp, the browser's anchoring),
163
+ * the note is stale and the geometry as it is now decides.
95
164
  */
96
165
  export function shouldRepinOnResize({ pinned, pinnedScrollTop, scrollTop, nearBottomNow }) {
97
- if (pinned && Number(scrollTop) === Number(pinnedScrollTop)) return true;
166
+ if (Number(scrollTop) === Number(pinnedScrollTop)) return pinned === true;
98
167
  return nearBottomNow === true;
99
168
  }
169
+
170
+ // "Jump to the end when I send a message" (Settings › General). A setting of
171
+ // this browser, kept in localStorage like the theme. On unless switched off:
172
+ // with it off, the reader's own send follows the same rule as everything
173
+ // else and only scrolls a transcript that is at its end already. The
174
+ // document and the storage are passed in by the tests.
175
+
176
+ export const SCROLL_ON_SEND_STORAGE_KEY = 'copilot_scroll_on_send';
177
+ export const SCROLL_ON_SEND_TOGGLE_ID = 'scroll-on-send-toggle';
178
+
179
+ /** Anything but an explicit off is on: a missing or damaged value included. */
180
+ export function parseScrollOnSendSetting(stored) {
181
+ const value = String(stored ?? '').trim().toLowerCase();
182
+ if (!value) return true;
183
+ return value !== '0' && value !== 'false';
184
+ }
185
+
186
+ export function serializeScrollOnSendSetting(enabled) {
187
+ return enabled ? '1' : '0';
188
+ }
189
+
190
+ function storage() {
191
+ try { return globalThis.localStorage || null; } catch { return null; }
192
+ }
193
+
194
+ export function isScrollOnSendEnabled(store = storage()) {
195
+ try {
196
+ return parseScrollOnSendSetting(store?.getItem(SCROLL_ON_SEND_STORAGE_KEY));
197
+ } catch {
198
+ return true;
199
+ }
200
+ }
201
+
202
+ export function syncScrollOnSendToggle(doc = globalThis.document, store = storage()) {
203
+ const checkbox = doc?.getElementById?.(SCROLL_ON_SEND_TOGGLE_ID);
204
+ if (checkbox) checkbox.checked = isScrollOnSendEnabled(store);
205
+ }
206
+
207
+ export function updateScrollOnSendSetting(enabled, { doc = globalThis.document, store = storage() } = {}) {
208
+ try { store?.setItem(SCROLL_ON_SEND_STORAGE_KEY, serializeScrollOnSendSetting(!!enabled)); } catch {}
209
+ syncScrollOnSendToggle(doc, store);
210
+ }
@@ -94,6 +94,135 @@ export function restoreTranscriptAnchor(el, anchor) {
94
94
  return Number(el.scrollTop || 0) !== before;
95
95
  }
96
96
 
97
+ /** The blocks of a rendered message a reader reads one at a time. */
98
+ export const READING_BLOCK_SELECTOR = 'p, li, pre, h1, h2, h3, h4, h5, h6, tr';
99
+
100
+ // The live bubble of a running turn: a row under its prompt, with the
101
+ // prompt's message id.
102
+ const LIVE_ROW_ID = 'thinking-indicator';
103
+ // How many rows after the prompt the reply is looked for in.
104
+ const REPLY_SEARCH_ROWS = 3;
105
+
106
+ // A block's text without its controls: a finished reply's code block holds
107
+ // its Copy button, the same block of the streamed reply does not.
108
+ function blockText(block) {
109
+ if (typeof block.querySelector !== 'function' || !block.querySelector('button')) {
110
+ return String(block.textContent || '');
111
+ }
112
+ const copy = block.cloneNode(true);
113
+ copy.querySelectorAll('button').forEach((node) => node.remove());
114
+ return String(copy.textContent || '');
115
+ }
116
+
117
+ function sameBlocks(scope, tag, text) {
118
+ return Array.from(scope.querySelectorAll(tag)).filter((node) => blockText(node) === text);
119
+ }
120
+
121
+ // A block the reader can see. Inside a closed fold (a reply's thoughts, an
122
+ // event row) it cannot be read, yet the browser still gives it a box there:
123
+ // the fold's content is skipped, not removed from the layout.
124
+ function isReadable(block, rect) {
125
+ if (!(rect.bottom - rect.top > 0)) return false;
126
+ if (typeof block.checkVisibility === 'function') return block.checkVisibility();
127
+ return typeof block.closest !== 'function' || !block.closest('details:not([open]) > :not(summary)');
128
+ }
129
+
130
+ /**
131
+ * The block of text the reader is at: the first one that starts inside the
132
+ * viewport, else the one that reaches into it from above (a code block
133
+ * taller than the screen). Finer than the row anchor above, for the times
134
+ * the rows themselves are rebuilt: a reload of the conversation, or the reply
135
+ * that takes the live bubble's place. There the same text comes back in a
136
+ * row whose other parts (folded thoughts, tool lines, the usage line) have
137
+ * another height, and a row or a pixel anchor moves the text by that much.
138
+ *
139
+ * `scope` limits the search to one element (the live bubble's stream);
140
+ * without it the rows that cross the viewport are searched. Returns null
141
+ * when no block is there (the reader looks at an image, or at a card).
142
+ */
143
+ export function captureReadingBlock(el, { scope = null } = {}) {
144
+ if (!el) return null;
145
+ const view = el.getBoundingClientRect();
146
+ const scopes = scope ? [scope] : transcriptRows(el).filter((row) => {
147
+ const rect = row.getBoundingClientRect();
148
+ return rect.bottom > view.top && rect.top < view.bottom;
149
+ });
150
+ let found = null;
151
+ let foundScope = null;
152
+ for (const candidate of scopes) {
153
+ for (const block of candidate.querySelectorAll(READING_BLOCK_SELECTOR)) {
154
+ const rect = block.getBoundingClientRect();
155
+ if (!isReadable(block, rect) || rect.bottom <= view.top) continue;
156
+ if (rect.top >= view.bottom) break;
157
+ found = { block, rect };
158
+ foundScope = candidate;
159
+ // The first block that starts in view ends the search; one that only
160
+ // reaches into it is kept until a later one does.
161
+ if (rect.top >= view.top) break;
162
+ }
163
+ if (found && found.rect.top >= view.top) break;
164
+ }
165
+ if (!found) return null;
166
+ const { block, rect } = found;
167
+ const tag = String(block.tagName || '').toLowerCase();
168
+ const text = blockText(block);
169
+ const height = Math.max(0, rect.bottom - rect.top);
170
+ const offset = rect.top - view.top;
171
+ return {
172
+ messageId: String(foundScope?.dataset?.messageId || '').trim() || null,
173
+ // Read in the live bubble: the reply that replaces it is another row.
174
+ live: foundScope?.id === LIVE_ROW_ID,
175
+ tag,
176
+ text,
177
+ // Which of the row's blocks with this very text (a repeated line).
178
+ occurrence: Math.max(0, sameBlocks(foundScope, tag, text).indexOf(block)),
179
+ offset,
180
+ // Scaled to the block's height when its top is scrolled past, so the
181
+ // same share of it stays hidden if it comes back with another height.
182
+ offsetFraction: offset < 0 && height > 0 ? offset / height : null,
183
+ };
184
+ }
185
+
186
+ // The rows the captured block can be in now. A block read in the live bubble
187
+ // is in that bubble while the turn runs, and in the reply once it has ended:
188
+ // one of the rows that follow the prompt.
189
+ function readingBlockHosts(el, anchor) {
190
+ const messageId = String(anchor.messageId || '').trim();
191
+ if (!messageId) return [];
192
+ const rows = transcriptRows(el);
193
+ const matches = (row) => String(row.dataset?.messageId || '').trim() === messageId;
194
+ if (!anchor.live) return rows.filter((row) => matches(row) && row.id !== LIVE_ROW_ID).slice(0, 1);
195
+ const live = rows.find((row) => matches(row) && row.id === LIVE_ROW_ID);
196
+ if (live) return [live];
197
+ const prompt = rows.findIndex(matches);
198
+ return prompt < 0 ? [] : rows.slice(prompt + 1, prompt + 1 + REPLY_SEARCH_ROWS);
199
+ }
200
+
201
+ /**
202
+ * Scrolls so the captured block sits where it was. It is looked for in
203
+ * `scope` when given (the reply that replaced the live bubble), else in the
204
+ * row it was captured in. Returns false when it is not there; the caller
205
+ * falls back to what it did before.
206
+ */
207
+ export function restoreReadingBlock(el, anchor, { scope = null } = {}) {
208
+ if (!el || !anchor?.tag) return false;
209
+ let block = null;
210
+ for (const host of (scope ? [scope] : readingBlockHosts(el, anchor))) {
211
+ const matches = sameBlocks(host, anchor.tag, anchor.text).filter((node) => isReadable(node, node.getBoundingClientRect()));
212
+ block = matches[anchor.occurrence] || matches[0] || null;
213
+ if (block) break;
214
+ }
215
+ if (!block) return false;
216
+ const view = el.getBoundingClientRect();
217
+ const rect = block.getBoundingClientRect();
218
+ const target = anchor.offsetFraction === null || anchor.offsetFraction === undefined
219
+ ? Number(anchor.offset || 0)
220
+ : Number(anchor.offsetFraction) * Math.max(0, rect.bottom - rect.top);
221
+ const delta = rect.top - view.top - target;
222
+ if (Math.abs(delta) > 0.5) el.scrollTop = Number(el.scrollTop || 0) + delta;
223
+ return true;
224
+ }
225
+
97
226
  const KEYBOARD_INPUT_TYPES = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'number', 'password']);
98
227
 
99
228
  /**
@@ -201,8 +330,19 @@ export function createTranscriptResizeKeeper({
201
330
  return apply(el);
202
331
  }
203
332
 
333
+ // Content landed under a reader who followed the end and was not followed
334
+ // (no scroll event tells): the position is what it is now, not "the end".
335
+ function recordEndLeft() {
336
+ if (settleTimer !== null) return false;
337
+ const el = element();
338
+ const next = el ? capture(el) : null;
339
+ if (next) committed = next;
340
+ return Boolean(next);
341
+ }
342
+
204
343
  return {
205
344
  recordScroll,
345
+ recordEndLeft,
206
346
  handleResize,
207
347
  isSettling: () => settleTimer !== null,
208
348
  committedAnchor: () => committed,