@oar-sh/oar 0.9.11 → 0.9.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +332 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +201 -12
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +179 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +152 -0
  10. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  13. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  14. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/acp-host-services.mjs +48 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +248 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  22. package/server/migrations/0018-background-task-rows.mjs +101 -0
  23. package/server/public/app/action-confirmations.js +12 -15
  24. package/server/public/app/api-client.js +70 -3
  25. package/server/public/app/background-tasks-view.mjs +334 -16
  26. package/server/public/app/bootstrap.js +102 -5
  27. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  28. package/server/public/app/conversation-payload-cache.mjs +357 -0
  29. package/server/public/app/conversation-preferences.mjs +10 -4
  30. package/server/public/app/conversation-turn-state.mjs +1 -1
  31. package/server/public/app/conversation-view.js +462 -8
  32. package/server/public/app/conversation-window-retention.mjs +96 -0
  33. package/server/public/app/host-suspend-ui.js +45 -44
  34. package/server/public/app/journal-view.js +259 -67
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
  37. package/server/public/app/relay-restart-note-view.mjs +36 -0
  38. package/server/public/app/relay-restart-ui.js +568 -0
  39. package/server/public/app/settings-modal.js +162 -2
  40. package/server/public/app/socket-handlers.js +59 -7
  41. package/server/public/app/store.js +25 -0
  42. package/server/public/app/subagent-viewer.mjs +7 -1
  43. package/server/public/app/task-console-render.mjs +401 -0
  44. package/server/public/app/task-console-watch.mjs +42 -0
  45. package/server/public/app/task-console.mjs +730 -0
  46. package/server/public/app/task-output-text.mjs +243 -0
  47. package/server/public/app/transcript-island.mjs +73 -0
  48. package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
  49. package/server/public/app/transcript-jump-bottom.mjs +68 -11
  50. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  51. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  52. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  53. package/server/public/index.html +306 -6
  54. package/server/relay-tools.md +6 -0
  55. package/server/repositories/message-repository.mjs +11 -0
  56. package/server/repositories/question-repository.mjs +4 -0
  57. package/server/repositories/remote-relay-repository.mjs +4 -2
  58. package/server/routes/messages-routes.mjs +397 -23
  59. package/server/routes/relay-board-routes.mjs +19 -2
  60. package/server/routes/relay-restart-routes.mjs +73 -0
  61. package/server/routes/sessions-routes.mjs +208 -66
  62. package/server/server-runtime.mjs +269 -137
  63. package/server/services/about-service.mjs +13 -12
  64. package/server/services/background-task-persistence.mjs +133 -0
  65. package/server/services/background-task-store.mjs +470 -6
  66. package/server/services/cli-process-runner.mjs +4 -21
  67. package/server/services/conversation-history-page.mjs +199 -0
  68. package/server/services/conversation-model-fallback.mjs +57 -0
  69. package/server/services/host-suspend-command.mjs +2 -1
  70. package/server/services/preview-proxy-server.mjs +14 -1
  71. package/server/services/process-list-service.mjs +2 -1
  72. package/server/services/provider-model-selection.mjs +195 -0
  73. package/server/services/push-dispatch-service.mjs +9 -2
  74. package/server/services/relay-event-service.mjs +27 -9
  75. package/server/services/relay-prompt-sanitizer.mjs +18 -4
  76. package/server/services/relay-restart-picture.mjs +209 -0
  77. package/server/services/relay-restart-queue.mjs +380 -0
  78. package/server/services/relay-shutdown-gate.mjs +4 -1
  79. package/server/services/remote-relay-dispatcher.mjs +316 -20
  80. package/server/services/restart-resume-service.mjs +303 -0
  81. package/server/services/session-activity-tracker.mjs +6 -2
  82. package/server/services/session-worker-process-service.mjs +6 -4
  83. package/server/services/task-output-rooms.mjs +259 -0
  84. package/server/services/task-output-service.mjs +761 -0
  85. package/shared/claude-background-command-limit.mjs +83 -0
  86. package/shared/relay-events.mjs +55 -2
  87. package/shared/remote-relay-contract.mjs +47 -10
  88. package/shared/task-output-text.mjs +850 -0
  89. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  90. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -0,0 +1,243 @@
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, inverse: 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 === 7) state.inverse = true;
179
+ else if (code === 22) {
180
+ state.bold = false;
181
+ state.dim = false;
182
+ } else if (code === 23) state.italic = false;
183
+ else if (code === 24) state.underline = false;
184
+ else if (code === 27) state.inverse = false;
185
+ else if (code >= 30 && code <= 37) state.fg = code - 30;
186
+ else if (code === 39) state.fg = null;
187
+ else if (code >= 40 && code <= 47) state.bg = code - 40;
188
+ else if (code === 49) state.bg = null;
189
+ else if (code >= 90 && code <= 97) state.fg = code - 90 + 8;
190
+ else if (code >= 100 && code <= 107) state.bg = code - 100 + 8;
191
+ else if (code === 38 || code === 48) {
192
+ const key = code === 38 ? 'fg' : 'bg';
193
+ if (codes[i + 1] === 5 && i + 2 < codes.length) {
194
+ state[key] = nearest16(rgbOf256(Math.min(255, Math.max(0, codes[i + 2] || 0))));
195
+ i += 2;
196
+ } else if (codes[i + 1] === 2 && i + 4 < codes.length) {
197
+ state[key] = nearest16([clampByte(codes[i + 2]), clampByte(codes[i + 3]), clampByte(codes[i + 4])]);
198
+ i += 4;
199
+ } else {
200
+ break;
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ /**
207
+ * One line as styled segments `{ text, fg, bg, bold, dim, italic, underline }`,
208
+ * colours 0–15 or null (256 and truecolor mapped to the nearest of the 16).
209
+ * Inverse text (SGR 7, until 27 or 0) comes with its colours already swapped
210
+ * and `inverse: true`: a null `fg` then means the default background as the
211
+ * text colour, a null `bg` the default text colour as the background (the
212
+ * filled RUN / PASS / FAIL badges of vitest). Adjacent segments never share a
213
+ * style. Any escape that is not SGR is dropped from the text.
214
+ */
215
+ export function ansiToSegments(lineText) {
216
+ const text = String(lineText ?? '');
217
+ const state = plainStyle();
218
+ const segments = [];
219
+ const pushText = (value) => {
220
+ const clean = stripControlKeepSgr(value).replace(/\x1b/g, '');
221
+ if (!clean) return;
222
+ const fg = state.inverse ? state.bg : state.fg;
223
+ const bg = state.inverse ? state.fg : state.bg;
224
+ const last = segments[segments.length - 1];
225
+ if (last && last.fg === fg && last.bg === bg && last.bold === state.bold && last.dim === state.dim
226
+ && last.italic === state.italic && last.underline === state.underline && !!last.inverse === state.inverse) {
227
+ last.text += clean;
228
+ return;
229
+ }
230
+ const segment = { text: clean, fg, bg, bold: state.bold, dim: state.dim, italic: state.italic, underline: state.underline };
231
+ if (state.inverse) segment.inverse = true;
232
+ segments.push(segment);
233
+ };
234
+ let at = 0;
235
+ const pattern = new RegExp(SGR_PATTERN.source, 'g');
236
+ for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
237
+ pushText(text.slice(at, match.index));
238
+ applySgr(state, match[1]);
239
+ at = match.index + match[0].length;
240
+ }
241
+ pushText(text.slice(at));
242
+ return segments;
243
+ }
@@ -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 {
@@ -27,7 +29,9 @@ import {
27
29
  shouldHoldFocusOnJump,
28
30
  } from './composer-keyboard.mjs';
29
31
  import {
32
+ baselineBeforeUpdate,
30
33
  isScrollOnSendEnabled,
34
+ followsEndThroughResize,
31
35
  isNearBottom,
32
36
  jumpButtonPresentation,
33
37
  jumpScrollBehavior,
@@ -156,6 +160,8 @@ function update() {
156
160
  nearBottomPx,
157
161
  rowKeys: away ? rowKeys(el) : [],
158
162
  question: away && questionWaitingBelow(el),
163
+ windowAtTail: isConversationWindowAtTail(),
164
+ waiting: getHeldBackLiveRowCount(),
159
165
  });
160
166
  render(state);
161
167
  }
@@ -187,7 +193,21 @@ export function onTranscriptEndLeft(listener) {
187
193
  * so itself (a row is counted, anything else calls noteTranscriptNews()).
188
194
  */
189
195
  export function followsTranscriptEnd() {
190
- return isMessagesNearBottom();
196
+ // The bottom of a window that ends above the conversation's end is not the
197
+ // end: nothing live is drawn there, and nothing scrolls to it.
198
+ if (!isConversationWindowAtTail()) return false;
199
+ const el = scroller();
200
+ const height = jump ? null : observedClientHeight;
201
+ const follows = followsEndThroughResize({
202
+ nearBottomNow: isMessagesNearBottom(),
203
+ clientHeight: el?.clientHeight,
204
+ observedClientHeight: height,
205
+ distanceAtObservedHeight: el && height !== null ? Math.max(0, el.scrollHeight - height - el.scrollTop) : null,
206
+ nearBottomPxAtObservedHeight: height !== null ? resolveNearBottomThresholdPx(height) : 0,
207
+ });
208
+ // What the update adds is counted against the rows on the page before it.
209
+ if (!follows && el && !jump && syncStateConversation()) state = baselineBeforeUpdate(state, rowKeys(el));
210
+ return follows;
191
211
  }
192
212
 
193
213
  /**
@@ -257,7 +277,13 @@ export function ownSendFollows(send) {
257
277
  * shrinking back): each step asks again.
258
278
  */
259
279
  export function scrollAfterOwnSend(send) {
260
- const step = () => { if (ownSendFollows(send)) scrollBottom(); };
280
+ const step = () => {
281
+ if (!ownSendFollows(send)) return;
282
+ // A place held while images load would take this scroll for the reader's
283
+ // and let go only then; the send moves the reader, so it lets go first.
284
+ releaseReadingAnchorHold();
285
+ scrollBottom();
286
+ };
261
287
  step();
262
288
  requestAnimationFrame(step);
263
289
  setTimeout(step, 120);
@@ -331,10 +357,18 @@ function prefersReducedMotion() {
331
357
  try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
332
358
  }
333
359
 
360
+ /**
361
+ * Takes the reader to the end. From a window that ends above the
362
+ * conversation's end, the end is loaded first: the promise settles once it
363
+ * is on the page (or the load failed), which an own send from such a window
364
+ * waits for.
365
+ */
334
366
  export function jumpTranscriptToBottom() {
335
367
  const el = scroller();
336
- if (!el) return;
337
- // The end wins over a followed section of the live bubble.
368
+ if (!el) return Promise.resolve();
369
+ // The end wins over a place held while images load, as over a followed
370
+ // section of the live bubble.
371
+ releaseReadingAnchorHold();
338
372
  if (getThinkingFollowMode()) {
339
373
  disarmThinkingFollow();
340
374
  syncThinkingFollowButtons(document, null);
@@ -346,11 +380,10 @@ export function jumpTranscriptToBottom() {
346
380
  if (loadConversationEnd && !isConversationWindowAtTail()) {
347
381
  endJump();
348
382
  jump = { cleanup() {} };
349
- void Promise.resolve()
383
+ return Promise.resolve()
350
384
  .then(() => loadConversationEnd())
351
385
  .catch(() => {})
352
386
  .finally(finishJump);
353
- return;
354
387
  }
355
388
  const behavior = jumpScrollBehavior({
356
389
  distance: distanceFromBottom(el),
@@ -359,6 +392,7 @@ export function jumpTranscriptToBottom() {
359
392
  });
360
393
  if (behavior === 'smooth') startSmoothJump(el);
361
394
  else finishJump();
395
+ return Promise.resolve();
362
396
  }
363
397
 
364
398
  function handleScroll() {
@@ -75,21 +75,51 @@ 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,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * The state before a live update that does not follow the end changes the
108
+ * transcript: the baseline is taken now, from the rows before it, if there
109
+ * is none yet. Otherwise it is taken by the next frame's nextJumpButtonState,
110
+ * by then with the update's row in it, and a row that lands between the
111
+ * reader's scroll away and that frame (one frame, or a tenth of a second on
112
+ * a busy page) is not counted.
113
+ */
114
+ export function baselineBeforeUpdate(previous, rowKeys = []) {
115
+ if (Array.isArray(previous?.baseline)) return previous;
116
+ return {
117
+ visible: previous?.visible === true,
118
+ baseline: rowKeys.slice(),
119
+ count: 0,
120
+ unseen: previous?.unseen === true,
121
+ question: previous?.question === true,
122
+ waiting: 0,
93
123
  };
94
124
  }
95
125
 
@@ -97,14 +127,22 @@ export const JUMP_BUTTON_LABEL = 'Jump to the latest message';
97
127
  export const JUMP_BUTTON_QUESTION_LABEL = 'A question is waiting for your answer. Jump to it';
98
128
  export const JUMP_QUESTION_BADGE = '?';
99
129
 
130
+ /**
131
+ * How the rows held back for a window above the end show on the badge:
132
+ * 'count' adds them to the count, 'dot' draws the dot of news without a row.
133
+ */
134
+ export const HELD_BACK_ROWS_BADGE = 'count';
135
+
100
136
  /**
101
137
  * How a state looks and reads. `state` is 'plain', 'new' or 'question'; a
102
138
  * waiting question wins over news. `badge` is the count, '?' for a question,
103
139
  * 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.
140
+ * to count). `waiting` rows are held back below the page
141
+ * (HELD_BACK_ROWS_BADGE). `announcement` is what a screen reader is told
142
+ * when the state is entered; the label alone is only read when the button is
143
+ * focused.
106
144
  */
107
- export function jumpButtonPresentation({ visible = false, count = 0, unseen = false, question = false } = {}) {
145
+ export function jumpButtonPresentation({ visible = false, count = 0, unseen = false, question = false, waiting = 0 } = {}, { heldBackRowsBadge = HELD_BACK_ROWS_BADGE } = {}) {
108
146
  if (!visible) return { state: 'plain', label: JUMP_BUTTON_LABEL, badge: '', dot: false, announcement: '' };
109
147
  if (question) {
110
148
  return {
@@ -115,17 +153,19 @@ export function jumpButtonPresentation({ visible = false, count = 0, unseen = fa
115
153
  announcement: 'A question is waiting for your answer below.',
116
154
  };
117
155
  }
118
- const badge = jumpBadgeText(count);
156
+ const held = Math.max(0, Math.trunc(Number(waiting) || 0));
157
+ const counted = Math.max(0, Math.trunc(Number(count) || 0)) + (heldBackRowsBadge === 'count' ? held : 0);
158
+ const badge = jumpBadgeText(counted);
119
159
  if (badge) {
120
160
  return {
121
161
  state: 'new',
122
- label: `${JUMP_BUTTON_LABEL} (${Math.trunc(Number(count))} new)`,
162
+ label: `${JUMP_BUTTON_LABEL} (${counted} new)`,
123
163
  badge,
124
164
  dot: false,
125
165
  announcement: 'New messages below.',
126
166
  };
127
167
  }
128
- if (unseen) {
168
+ if (unseen || held > 0) {
129
169
  return {
130
170
  state: 'new',
131
171
  label: `${JUMP_BUTTON_LABEL} (new below)`,
@@ -167,6 +207,23 @@ export function shouldRepinOnResize({ pinned, pinnedScrollTop, scrollTop, nearBo
167
207
  return nearBottomNow === true;
168
208
  }
169
209
 
210
+ /**
211
+ * Whether a live update follows the end. A reader at the end now is
212
+ * followed. So is one the re-pin above is on its way to: the transcript
213
+ * shrank from below (`clientHeight` under the height the last
214
+ * ResizeObserver round saw) and at that height the reader is at the end.
215
+ * An update that lands between the shrink and that round would otherwise
216
+ * find them the panel's height above the end, stay put, and leave them
217
+ * there. The geometry decides, not the last note of the position: a scroll
218
+ * to the end (the update before) is noted only by its scroll event a frame
219
+ * later, and the shrink and this update can both land before it.
220
+ */
221
+ export function followsEndThroughResize({ nearBottomNow, clientHeight, observedClientHeight, distanceAtObservedHeight, nearBottomPxAtObservedHeight }) {
222
+ if (nearBottomNow === true) return true;
223
+ if (!(Number(clientHeight) < Number(observedClientHeight))) return false;
224
+ return isNearBottom(distanceAtObservedHeight, nearBottomPxAtObservedHeight);
225
+ }
226
+
170
227
  // "Jump to the end when I send a message" (Settings › General). A setting of
171
228
  // this browser, kept in localStorage like the theme. On unless switched off:
172
229
  // with it off, the reader's own send follows the same rule as everything