@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
@@ -0,0 +1,234 @@
1
+ // Browser copy of the parts of shared/task-output-text.mjs the task console
2
+ // needs: the incremental line folder, the SGR colour segments (with the
3
+ // control stripping they use) and the stall minutes. The browser cannot
4
+ // import shared/ (only server/public is served), so these are hand-kept
5
+ // copies; task-output-text.test.mjs pins them to the original. Scrubbing and
6
+ // runner parsing stay on the relay: what reaches the page is already clean.
7
+
8
+ export const TASK_OUTPUT_LINE_CHARS_MAX = 4000;
9
+
10
+ function toMillis(value) {
11
+ if (value == null || value === '') return NaN;
12
+ const number = Number(value);
13
+ if (Number.isFinite(number)) return number;
14
+ return Date.parse(String(value));
15
+ }
16
+
17
+ /** Whole minutes since the last output, or null when unknown. */
18
+ export function stallMinutes(lastOutputAt, now = Date.now()) {
19
+ const last = toMillis(lastOutputAt);
20
+ const current = toMillis(now);
21
+ if (!Number.isFinite(last) || !Number.isFinite(current) || last <= 0) return null;
22
+ return Math.max(0, Math.floor((current - last) / 60_000));
23
+ }
24
+
25
+ // SGR (colours and text style) is the only escape kept. Its parameters are
26
+ // digits and semicolons; the colon form is rare and goes with the rest.
27
+ const SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
28
+ // OSC (titles, hyperlinks, clipboard): ESC ] … BEL | ST, stripped whole. An
29
+ // unterminated one at the end is stripped to the end.
30
+ const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g;
31
+ // Every CSI (cursor moves, erases, modes) that is not SGR.
32
+ const CSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
33
+ // Character-set designations (ESC ( B) and the remaining two-byte escapes.
34
+ const CHARSET_PATTERN = /\x1b[()*+\-./][0-~]/g;
35
+ // The remaining two-byte escapes (ESC 7, ESC =, ESC M, a CSI cut short) and
36
+ // a lone ESC; never the start of a kept SGR.
37
+ const OTHER_ESCAPE_PATTERN = /\x1b(?!\[[0-9;]*m)[0-~]?/g;
38
+ // C0 except \t \n \r, DEL, C1, and a lone ESC left over.
39
+ const CONTROL_PATTERN = /[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f-\x9f]/g;
40
+
41
+ /**
42
+ * Drops OSC, every CSI but SGR, the other escapes, C1 and C0 controls except
43
+ * `\n`, `\r` and `\t`; keeps `ESC [ … m`.
44
+ */
45
+ export function stripControlKeepSgr(text) {
46
+ const value = String(text ?? '');
47
+ if (!/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f-\x9f]/.test(value)) return value;
48
+ return value
49
+ .replace(OSC_PATTERN, '')
50
+ .replace(CSI_PATTERN, (match) => (/^\x1b\[[0-9;]*m$/.test(match) ? match : ''))
51
+ .replace(CHARSET_PATTERN, '')
52
+ .replace(OTHER_ESCAPE_PATTERN, '')
53
+ .replace(CONTROL_PATTERN, '');
54
+ }
55
+
56
+ // ─── Lines ───────────────────────────────────────────────────────────────────
57
+
58
+ function cutLine(text) {
59
+ return text.length > TASK_OUTPUT_LINE_CHARS_MAX ? text.slice(0, TASK_OUTPUT_LINE_CHARS_MAX) : text;
60
+ }
61
+
62
+ // A line's final state: a `\r` not followed by `\n` discards what came before
63
+ // it (a trailing one still waits for what follows).
64
+ function finalLineState(raw) {
65
+ const body = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
66
+ const at = body.lastIndexOf('\r');
67
+ return at === -1 ? body : body.slice(at + 1);
68
+ }
69
+
70
+ /**
71
+ * Lines from a stream fed in arbitrary pieces. `feed(text)` returns
72
+ * `{ lines, partial, changedFrom }`: every completed line so far as
73
+ * `{ text, count }` (adjacent identical lines folded, `count` unbounded), the
74
+ * open partial line in its current state ('' when there is none), and the
75
+ * index of the first line this feed added or changed (a folded repeat bumps
76
+ * the count of the line before). `end()` closes the partial line and returns
77
+ * the same shape. `lines` is the folder's own array: copy it to keep it.
78
+ */
79
+ export function createLineFolder() {
80
+ const lines = [];
81
+ let partial = '';
82
+ let changedFrom = 0;
83
+
84
+ function push(text) {
85
+ const line = cutLine(text);
86
+ const last = lines[lines.length - 1];
87
+ if (last && last.text === line) {
88
+ last.count += 1;
89
+ changedFrom = Math.min(changedFrom, lines.length - 1);
90
+ } else {
91
+ changedFrom = Math.min(changedFrom, lines.length);
92
+ lines.push({ text: line, count: 1 });
93
+ }
94
+ }
95
+
96
+ function result() {
97
+ const out = { lines, partial: finalLineState(partial), changedFrom };
98
+ changedFrom = lines.length;
99
+ return out;
100
+ }
101
+
102
+ return {
103
+ feed(text) {
104
+ const chunk = String(text ?? '');
105
+ if (chunk) {
106
+ const parts = (partial + chunk).split('\n');
107
+ partial = parts.pop();
108
+ for (const raw of parts) push(finalLineState(raw));
109
+ // The open line stays small: a progress bar redraws itself
110
+ // thousands of times on one line.
111
+ const keepCr = partial.endsWith('\r');
112
+ partial = cutLine(finalLineState(partial)) + (keepCr ? '\r' : '');
113
+ }
114
+ return result();
115
+ },
116
+ end() {
117
+ const last = finalLineState(partial);
118
+ partial = '';
119
+ if (last) push(last);
120
+ return result();
121
+ },
122
+ get lines() {
123
+ return lines;
124
+ },
125
+ };
126
+ }
127
+
128
+ // ─── Colours ─────────────────────────────────────────────────────────────────
129
+
130
+ // The 16-colour palette (xterm defaults), for mapping 256 and truecolor.
131
+ const PALETTE_16 = [
132
+ [0, 0, 0], [205, 0, 0], [0, 205, 0], [205, 205, 0], [0, 0, 238], [205, 0, 205], [0, 205, 205], [229, 229, 229],
133
+ [127, 127, 127], [255, 0, 0], [0, 255, 0], [255, 255, 0], [92, 92, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255],
134
+ ];
135
+
136
+ function nearest16([r, g, b]) {
137
+ let best = 0;
138
+ let bestDistance = Infinity;
139
+ PALETTE_16.forEach(([pr, pg, pb], index) => {
140
+ const distance = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2;
141
+ if (distance < bestDistance) {
142
+ best = index;
143
+ bestDistance = distance;
144
+ }
145
+ });
146
+ return best;
147
+ }
148
+
149
+ function rgbOf256(n) {
150
+ if (n < 16) return PALETTE_16[n];
151
+ if (n >= 232) {
152
+ const level = 8 + (n - 232) * 10;
153
+ return [level, level, level];
154
+ }
155
+ const index = n - 16;
156
+ const steps = [0, 95, 135, 175, 215, 255];
157
+ return [steps[Math.floor(index / 36)], steps[Math.floor(index / 6) % 6], steps[index % 6]];
158
+ }
159
+
160
+ function clampByte(value) {
161
+ const number = Number(value);
162
+ return Number.isFinite(number) ? Math.min(255, Math.max(0, number)) : 0;
163
+ }
164
+
165
+ function plainStyle() {
166
+ return { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false };
167
+ }
168
+
169
+ function applySgr(state, params) {
170
+ const codes = params === '' ? [0] : params.split(';').map((part) => (part === '' ? 0 : Number(part)));
171
+ for (let i = 0; i < codes.length; i += 1) {
172
+ const code = codes[i];
173
+ if (code === 0) Object.assign(state, plainStyle());
174
+ else if (code === 1) state.bold = true;
175
+ else if (code === 2) state.dim = true;
176
+ else if (code === 3) state.italic = true;
177
+ else if (code === 4) state.underline = true;
178
+ else if (code === 22) {
179
+ state.bold = false;
180
+ state.dim = false;
181
+ } else if (code === 23) state.italic = false;
182
+ else if (code === 24) state.underline = false;
183
+ else if (code >= 30 && code <= 37) state.fg = code - 30;
184
+ else if (code === 39) state.fg = null;
185
+ else if (code >= 40 && code <= 47) state.bg = code - 40;
186
+ else if (code === 49) state.bg = null;
187
+ else if (code >= 90 && code <= 97) state.fg = code - 90 + 8;
188
+ else if (code >= 100 && code <= 107) state.bg = code - 100 + 8;
189
+ else if (code === 38 || code === 48) {
190
+ const key = code === 38 ? 'fg' : 'bg';
191
+ if (codes[i + 1] === 5 && i + 2 < codes.length) {
192
+ state[key] = nearest16(rgbOf256(Math.min(255, Math.max(0, codes[i + 2] || 0))));
193
+ i += 2;
194
+ } else if (codes[i + 1] === 2 && i + 4 < codes.length) {
195
+ state[key] = nearest16([clampByte(codes[i + 2]), clampByte(codes[i + 3]), clampByte(codes[i + 4])]);
196
+ i += 4;
197
+ } else {
198
+ break;
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * One line as styled segments `{ text, fg, bg, bold, dim, italic, underline }`,
206
+ * colours 0–15 or null (256 and truecolor mapped to the nearest of the 16).
207
+ * Adjacent segments never share a style. Any escape that is not SGR is
208
+ * dropped from the text.
209
+ */
210
+ export function ansiToSegments(lineText) {
211
+ const text = String(lineText ?? '');
212
+ const state = plainStyle();
213
+ const segments = [];
214
+ const pushText = (value) => {
215
+ const clean = stripControlKeepSgr(value).replace(/\x1b/g, '');
216
+ if (!clean) return;
217
+ const last = segments[segments.length - 1];
218
+ if (last && last.fg === state.fg && last.bg === state.bg && last.bold === state.bold
219
+ && last.dim === state.dim && last.italic === state.italic && last.underline === state.underline) {
220
+ last.text += clean;
221
+ return;
222
+ }
223
+ segments.push({ text: clean, ...state });
224
+ };
225
+ let at = 0;
226
+ const pattern = new RegExp(SGR_PATTERN.source, 'g');
227
+ for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
228
+ pushText(text.slice(at, match.index));
229
+ applySgr(state, match[1]);
230
+ at = match.index + match[0].length;
231
+ }
232
+ pushText(text.slice(at));
233
+ return segments;
234
+ }
@@ -0,0 +1,73 @@
1
+ // Live rows for a window that ends above the conversation's end, without the
2
+ // DOM. Such a window (an "island": a jump to a pin or a search result, a
3
+ // reload around the place read, a kept window more was written below than one
4
+ // page holds) has rows between its newest row and the end that are not on the
5
+ // page. A live row put under it would hide that gap: the newer pages load
6
+ // from the window's newest row, and nothing would load what lies between. So
7
+ // the row is held back: the window keeps saying "newer rows exist", the
8
+ // jump-to-bottom button counts the row, and the newer pages bring it in their
9
+ // turn. Once the window meets the end, live rows are put on the page again.
10
+ //
11
+ // conversation-view.js keeps one of these for the window on the page and
12
+ // applies what it decides; transcript-jump-bottom.mjs draws the count.
13
+
14
+ /**
15
+ * The rows held back for the window on the page. `follow` is told of every
16
+ * change of that window: one that meets the end, or another conversation's,
17
+ * holds none.
18
+ */
19
+ export function createHeldBackLiveRows() {
20
+ let conversationId = '';
21
+ let ids = [];
22
+ let unnamed = 0;
23
+ return {
24
+ /** A live row for `conversation` that was not put on the page. */
25
+ note(conversation, messageId) {
26
+ const id = String(conversation || '').trim();
27
+ if (!id) return 0;
28
+ if (id !== conversationId) {
29
+ conversationId = id;
30
+ ids = [];
31
+ unnamed = 0;
32
+ }
33
+ const rowId = String(messageId || '').trim();
34
+ if (!rowId) unnamed += 1;
35
+ else if (!ids.includes(rowId)) ids.push(rowId);
36
+ return ids.length + unnamed;
37
+ },
38
+ /** How many rows wait for `conversation`'s window. */
39
+ count(conversation) {
40
+ return String(conversation || '').trim() === conversationId ? ids.length + unnamed : 0;
41
+ },
42
+ /** The ids of the rows that wait, oldest first. */
43
+ ids(conversation) {
44
+ return String(conversation || '').trim() === conversationId ? ids.slice() : [];
45
+ },
46
+ /** The window on the page now. True when rows were let go. */
47
+ follow({ conversationId: conversation = '', atTail = true } = {}) {
48
+ const id = String(conversation || '').trim();
49
+ if (!atTail && id === conversationId) return false;
50
+ const had = ids.length + unnamed > 0;
51
+ conversationId = atTail ? '' : id;
52
+ ids = [];
53
+ unnamed = 0;
54
+ return had;
55
+ },
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Whether a newer page that says it is the last one really brings the window
61
+ * to the end. It can have been fetched before a held-back row was written
62
+ * (a page waiting in the prefetch buffer, a request answered while the row's
63
+ * event was on its way): then a row the page lacks, and the page does not
64
+ * show either, is still to come, and one more page is asked for. An empty
65
+ * page ends it: a row that is still missing was deleted since.
66
+ */
67
+ export function newerPageReachesEnd({ pageHasMore = false, pageMessageIds = [], heldBackIds = [], renderedIds = [] } = {}) {
68
+ if (pageHasMore) return false;
69
+ const page = (pageMessageIds || []).map((id) => String(id || '').trim()).filter(Boolean);
70
+ if (!page.length) return true;
71
+ const known = new Set([...page, ...(renderedIds || []).map((id) => String(id || '').trim())]);
72
+ return (heldBackIds || []).every((id) => known.has(String(id || '').trim()));
73
+ }
@@ -15,10 +15,12 @@
15
15
  // (the background-task panel appears or opens, a banner, a taller draft) the
16
16
  // transcript shrinks from below, scrollTop stays and no scroll event fires,
17
17
  // so the reader is left that many pixels above the end and the next update
18
- // 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).
19
21
 
20
22
  import { currentConvId, isMessagesNearBottom, scrollBottom } from './store.js';
21
- import { isConversationWindowAtTail } from './conversation-view.js';
23
+ import { getHeldBackLiveRowCount, isConversationWindowAtTail, releaseReadingAnchorHold } from './conversation-view.js';
22
24
  import { disarmThinkingFollow, getThinkingFollowMode, syncThinkingFollowButtons } from './thinking-follow.js';
23
25
  import { resolveNearBottomThresholdPx } from './transcript-viewport-anchor.mjs';
24
26
  import {
@@ -28,6 +30,7 @@ import {
28
30
  } from './composer-keyboard.mjs';
29
31
  import {
30
32
  isScrollOnSendEnabled,
33
+ followsEndThroughResize,
31
34
  isNearBottom,
32
35
  jumpButtonPresentation,
33
36
  jumpScrollBehavior,
@@ -156,6 +159,8 @@ function update() {
156
159
  nearBottomPx,
157
160
  rowKeys: away ? rowKeys(el) : [],
158
161
  question: away && questionWaitingBelow(el),
162
+ windowAtTail: isConversationWindowAtTail(),
163
+ waiting: getHeldBackLiveRowCount(),
159
164
  });
160
165
  render(state);
161
166
  }
@@ -187,7 +192,18 @@ export function onTranscriptEndLeft(listener) {
187
192
  * so itself (a row is counted, anything else calls noteTranscriptNews()).
188
193
  */
189
194
  export function followsTranscriptEnd() {
190
- return isMessagesNearBottom();
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
+ });
191
207
  }
192
208
 
193
209
  /**
@@ -257,7 +273,13 @@ export function ownSendFollows(send) {
257
273
  * shrinking back): each step asks again.
258
274
  */
259
275
  export function scrollAfterOwnSend(send) {
260
- const step = () => { if (ownSendFollows(send)) scrollBottom(); };
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
+ };
261
283
  step();
262
284
  requestAnimationFrame(step);
263
285
  setTimeout(step, 120);
@@ -331,10 +353,18 @@ function prefersReducedMotion() {
331
353
  try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
332
354
  }
333
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
+ */
334
362
  export function jumpTranscriptToBottom() {
335
363
  const el = scroller();
336
- if (!el) return;
337
- // 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();
338
368
  if (getThinkingFollowMode()) {
339
369
  disarmThinkingFollow();
340
370
  syncThinkingFollowButtons(document, null);
@@ -346,11 +376,10 @@ export function jumpTranscriptToBottom() {
346
376
  if (loadConversationEnd && !isConversationWindowAtTail()) {
347
377
  endJump();
348
378
  jump = { cleanup() {} };
349
- void Promise.resolve()
379
+ return Promise.resolve()
350
380
  .then(() => loadConversationEnd())
351
381
  .catch(() => {})
352
382
  .finally(finishJump);
353
- return;
354
383
  }
355
384
  const behavior = jumpScrollBehavior({
356
385
  distance: distanceFromBottom(el),
@@ -359,6 +388,7 @@ export function jumpTranscriptToBottom() {
359
388
  });
360
389
  if (behavior === 'smooth') startSmoothJump(el);
361
390
  else finishJump();
391
+ return Promise.resolve();
362
392
  }
363
393
 
364
394
  function handleScroll() {
@@ -75,21 +75,31 @@ export function nextJumpButtonState(previous, {
75
75
  rowKeys = [],
76
76
  unseen = false,
77
77
  question = false,
78
+ windowAtTail = true,
79
+ waiting = 0,
78
80
  } = {}) {
79
- if (isNearBottom(distance, nearBottomPx)) {
80
- return { visible: false, baseline: null, count: 0, unseen: false, question: false };
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 };
81
87
  }
82
- const baseline = Array.isArray(previous?.baseline) ? previous.baseline : rowKeys.slice();
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());
83
92
  const screens = Number(clientHeight) > 0 ? Number(distance) / Number(clientHeight) : 0;
84
- const count = countNewRows(baseline, rowKeys);
93
+ const count = windowAtTail ? countNewRows(baseline, rowKeys) : 0;
85
94
  const unseenNow = previous?.unseen === true || unseen === true;
86
95
  const questionNow = question === true;
87
96
  return {
88
- visible: previous?.visible === true || screens >= JUMP_BUTTON_SHOW_SCREENS || count > 0 || unseenNow || questionNow,
97
+ visible: previous?.visible === true || screens >= JUMP_BUTTON_SHOW_SCREENS || count > 0 || waitingNow > 0 || unseenNow || questionNow,
89
98
  baseline,
90
99
  count,
91
100
  unseen: unseenNow,
92
101
  question: questionNow,
102
+ waiting: waitingNow,
93
103
  };
94
104
  }
95
105
 
@@ -97,14 +107,22 @@ export const JUMP_BUTTON_LABEL = 'Jump to the latest message';
97
107
  export const JUMP_BUTTON_QUESTION_LABEL = 'A question is waiting for your answer. Jump to it';
98
108
  export const JUMP_QUESTION_BADGE = '?';
99
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
+
100
116
  /**
101
117
  * How a state looks and reads. `state` is 'plain', 'new' or 'question'; a
102
118
  * waiting question wins over news. `badge` is the count, '?' for a question,
103
119
  * 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.
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.
106
124
  */
107
- export function jumpButtonPresentation({ visible = false, count = 0, unseen = false, question = false } = {}) {
125
+ export function jumpButtonPresentation({ visible = false, count = 0, unseen = false, question = false, waiting = 0 } = {}, { heldBackRowsBadge = HELD_BACK_ROWS_BADGE } = {}) {
108
126
  if (!visible) return { state: 'plain', label: JUMP_BUTTON_LABEL, badge: '', dot: false, announcement: '' };
109
127
  if (question) {
110
128
  return {
@@ -115,17 +133,19 @@ export function jumpButtonPresentation({ visible = false, count = 0, unseen = fa
115
133
  announcement: 'A question is waiting for your answer below.',
116
134
  };
117
135
  }
118
- const badge = jumpBadgeText(count);
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);
119
139
  if (badge) {
120
140
  return {
121
141
  state: 'new',
122
- label: `${JUMP_BUTTON_LABEL} (${Math.trunc(Number(count))} new)`,
142
+ label: `${JUMP_BUTTON_LABEL} (${counted} new)`,
123
143
  badge,
124
144
  dot: false,
125
145
  announcement: 'New messages below.',
126
146
  };
127
147
  }
128
- if (unseen) {
148
+ if (unseen || held > 0) {
129
149
  return {
130
150
  state: 'new',
131
151
  label: `${JUMP_BUTTON_LABEL} (new below)`,
@@ -167,6 +187,23 @@ export function shouldRepinOnResize({ pinned, pinnedScrollTop, scrollTop, nearBo
167
187
  return nearBottomNow === true;
168
188
  }
169
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
+
170
207
  // "Jump to the end when I send a message" (Settings › General). A setting of
171
208
  // this browser, kept in localStorage like the theme. On unless switched off:
172
209
  // with it off, the reader's own send follows the same rule as everything