@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.
- package/CHANGELOG.md +332 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +201 -12
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +179 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +152 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/acp-host-services.mjs +48 -6
- package/server/grok-worker/grok-background-tasks.mjs +248 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +70 -3
- package/server/public/app/background-tasks-view.mjs +334 -16
- package/server/public/app/bootstrap.js +102 -5
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +462 -8
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +259 -67
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +162 -2
- package/server/public/app/socket-handlers.js +59 -7
- package/server/public/app/store.js +25 -0
- package/server/public/app/subagent-viewer.mjs +7 -1
- package/server/public/app/task-console-render.mjs +401 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +730 -0
- package/server/public/app/task-output-text.mjs +243 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
- package/server/public/app/transcript-jump-bottom.mjs +68 -11
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +20 -1
- package/server/public/index.html +306 -6
- package/server/relay-tools.md +6 -0
- package/server/repositories/message-repository.mjs +11 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +397 -23
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +208 -66
- package/server/server-runtime.mjs +269 -137
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +470 -6
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +9 -2
- package/server/services/relay-event-service.mjs +27 -9
- package/server/services/relay-prompt-sanitizer.mjs +18 -4
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +380 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +316 -20
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +761 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +47 -10
- package/shared/task-output-text.mjs +850 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Where a reader is in a conversation, kept across a switch to another one
|
|
2
|
+
// and across a page reload, and the window of loaded messages kept with it.
|
|
3
|
+
// No DOM here: the caller measures the rows and applies what is decided.
|
|
4
|
+
//
|
|
5
|
+
// A pixel scrollTop does not survive either trip. The rows on the page after
|
|
6
|
+
// a switch back are another set (the oldest dropped by the page-size cap,
|
|
7
|
+
// new ones written meanwhile at the end, a window centred on the place read
|
|
8
|
+
// after a reload), so the same pixel count lands on another message. The
|
|
9
|
+
// position is therefore a message and how far its top sits from the pane's
|
|
10
|
+
// top, or "at the end".
|
|
11
|
+
//
|
|
12
|
+
// Finer than the reading block of transcript-viewport-anchor.mjs, coarser
|
|
13
|
+
// than it too: that one is for rows rebuilt under the reader with other
|
|
14
|
+
// heights, and holds the text read. This one goes to localStorage, so it
|
|
15
|
+
// holds an id and an offset only.
|
|
16
|
+
|
|
17
|
+
import { sortConversationMessages } from './thread-order.mjs';
|
|
18
|
+
|
|
19
|
+
function messageIdOf(message) {
|
|
20
|
+
return String(message?.id || '').trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The index of the first row whose bottom edge is below the pane's top (the
|
|
25
|
+
* row being read), or -1. `bottomAt(i)` is the row's bottom in the pane's
|
|
26
|
+
* coordinates; rows are in page order, so their bottoms only grow and a
|
|
27
|
+
* binary search reads a handful of them on a long transcript.
|
|
28
|
+
*/
|
|
29
|
+
export function findReadingRowIndex(rowCount, bottomAt, paneTop) {
|
|
30
|
+
const count = Math.max(0, Math.trunc(Number(rowCount) || 0));
|
|
31
|
+
let low = 0;
|
|
32
|
+
let high = count;
|
|
33
|
+
const edge = Number(paneTop) + 1;
|
|
34
|
+
while (low < high) {
|
|
35
|
+
const mid = (low + high) >> 1;
|
|
36
|
+
if (Number(bottomAt(mid)) > edge) high = mid;
|
|
37
|
+
else low = mid + 1;
|
|
38
|
+
}
|
|
39
|
+
return low < count ? low : -1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The reader's position from the pane and its rows. `rowAt(i)` gives
|
|
44
|
+
* `{ messageId, top, bottom }`; `atTail` is true when the reader is at the
|
|
45
|
+
* end of a window that is the end of the conversation (they follow live
|
|
46
|
+
* rows). Null when no row is on the page.
|
|
47
|
+
*/
|
|
48
|
+
export function captureReadingAnchor({ rowCount, rowAt, paneTop, atTail = false }) {
|
|
49
|
+
const index = findReadingRowIndex(rowCount, (i) => rowAt(i)?.bottom, paneTop);
|
|
50
|
+
if (index < 0) return null;
|
|
51
|
+
const row = rowAt(index);
|
|
52
|
+
const messageId = String(row?.messageId || '').trim();
|
|
53
|
+
if (!messageId) return null;
|
|
54
|
+
return {
|
|
55
|
+
messageId,
|
|
56
|
+
offsetPx: Math.round(Number(row.top) - Number(paneTop)),
|
|
57
|
+
atTail: atTail === true,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** How far to scroll so the anchored row's top is at its saved offset again. */
|
|
62
|
+
export function readingAnchorScrollDelta({ rowTop, paneTop, offsetPx }) {
|
|
63
|
+
return Number(rowTop) - Number(paneTop) - Number(offsetPx || 0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Where a row's top sits in the content, whatever the scroll: its distance
|
|
68
|
+
* from the pane's top plus how far the pane is scrolled. It changes only when
|
|
69
|
+
* something above the row changes height.
|
|
70
|
+
*/
|
|
71
|
+
export function readingAnchorContentTop({ rowTop, paneTop, scrollTop }) {
|
|
72
|
+
return Number(rowTop) - Number(paneTop) + Number(scrollTop);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One look at the anchored row while the images above it are still loading
|
|
77
|
+
* (they grow from nothing to their height after the restore, and a browser
|
|
78
|
+
* without scroll anchoring lets that push the row down). `seen` is the row's
|
|
79
|
+
* content position and the pane's scrollTop as of the last look.
|
|
80
|
+
*
|
|
81
|
+
* The total height is no guide: growth below the row (a reply streaming in
|
|
82
|
+
* at the end) changes it too, and a scroll in the same frame (a jump to the
|
|
83
|
+
* end, a key, a drag of the scrollbar) would be taken back. So only the
|
|
84
|
+
* row's own content position counts, and a scroll is told apart from it:
|
|
85
|
+
* - 'stay': nothing above the row changed, or the browser's own scroll
|
|
86
|
+
* anchoring already scrolled by exactly what grew above it.
|
|
87
|
+
* - 'correct': something above the row grew by `delta` and nothing else
|
|
88
|
+
* scrolled: scroll by `delta`.
|
|
89
|
+
* - 'release': scrollTop changed for any other reason. That is the reader
|
|
90
|
+
* (or a jump, a send) and the place is theirs from now on, even when
|
|
91
|
+
* something above grew in the same frame.
|
|
92
|
+
* `rowContentTop` is the position to remember for the next look.
|
|
93
|
+
*/
|
|
94
|
+
export function readingAnchorHoldStep({ rowTop, paneTop, scrollTop, seen, tolerancePx = 1 }) {
|
|
95
|
+
const rowContentTop = readingAnchorContentTop({ rowTop, paneTop, scrollTop });
|
|
96
|
+
const grewAbove = rowContentTop - Number(seen?.rowContentTop);
|
|
97
|
+
const scrolled = Number(scrollTop) - Number(seen?.scrollTop);
|
|
98
|
+
if (!Number.isFinite(grewAbove) || !Number.isFinite(scrolled)) return { action: 'release', delta: 0, rowContentTop };
|
|
99
|
+
const grew = Math.abs(grewAbove) > tolerancePx;
|
|
100
|
+
if (Math.abs(scrolled) > tolerancePx) {
|
|
101
|
+
if (grew && Math.abs(scrolled - grewAbove) <= tolerancePx) return { action: 'stay', delta: 0, rowContentTop };
|
|
102
|
+
return { action: 'release', delta: 0, rowContentTop };
|
|
103
|
+
}
|
|
104
|
+
if (grew) return { action: 'correct', delta: grewAbove, rowContentTop };
|
|
105
|
+
return { action: 'stay', delta: 0, rowContentTop };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** An anchor read back from storage, or null when it is not one. */
|
|
109
|
+
export function normalizeReadingAnchor(value) {
|
|
110
|
+
if (!value || typeof value !== 'object') return null;
|
|
111
|
+
const messageId = String(value.messageId || '').trim();
|
|
112
|
+
const atTail = value.atTail === true;
|
|
113
|
+
if (!messageId && !atTail) return null;
|
|
114
|
+
const offset = Number(value.offsetPx);
|
|
115
|
+
return {
|
|
116
|
+
messageId: messageId || null,
|
|
117
|
+
offsetPx: Number.isFinite(offset) ? Math.round(offset) : 0,
|
|
118
|
+
atTail,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The part of a loaded window kept for a switch back: at most `cap` messages
|
|
124
|
+
* (no cap when it is not a positive finite number). A reader at the end keeps
|
|
125
|
+
* the newest; anyone else keeps the messages around the one they read, half
|
|
126
|
+
* above and half below, so either direction can be read on without a fetch.
|
|
127
|
+
* Rows cut off on a side mean there is more on that side to load.
|
|
128
|
+
*/
|
|
129
|
+
export function trimMessageWindow(messages, {
|
|
130
|
+
cap = Infinity,
|
|
131
|
+
anchorMessageId = '',
|
|
132
|
+
atTail = false,
|
|
133
|
+
hasMoreOlder = false,
|
|
134
|
+
hasMoreNewer = false,
|
|
135
|
+
} = {}) {
|
|
136
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
137
|
+
const limit = Number(cap);
|
|
138
|
+
if (!(limit > 0) || !Number.isFinite(limit) || list.length <= limit) {
|
|
139
|
+
return { messages: list, hasMoreOlder: !!hasMoreOlder, hasMoreNewer: !!hasMoreNewer, trimmed: false };
|
|
140
|
+
}
|
|
141
|
+
const keep = Math.trunc(limit);
|
|
142
|
+
const ordered = sortConversationMessages(list);
|
|
143
|
+
const anchorId = String(anchorMessageId || '').trim();
|
|
144
|
+
const anchorIndex = !atTail && anchorId ? ordered.findIndex((message) => messageIdOf(message) === anchorId) : -1;
|
|
145
|
+
let start;
|
|
146
|
+
if (anchorIndex < 0) {
|
|
147
|
+
start = ordered.length - keep;
|
|
148
|
+
} else {
|
|
149
|
+
start = Math.max(0, anchorIndex - Math.floor(keep / 2));
|
|
150
|
+
start = Math.min(start, ordered.length - keep);
|
|
151
|
+
}
|
|
152
|
+
const end = start + keep;
|
|
153
|
+
return {
|
|
154
|
+
messages: ordered.slice(start, end),
|
|
155
|
+
hasMoreOlder: !!hasMoreOlder || start > 0,
|
|
156
|
+
hasMoreNewer: !!hasMoreNewer || end < ordered.length,
|
|
157
|
+
trimmed: true,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function timestampMsOf(message) {
|
|
162
|
+
const ms = Date.parse(message?.timestamp || 0);
|
|
163
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The relay's history order: timestamp in ms, then id by localeCompare (as
|
|
168
|
+
* conversation-history-page.mjs pages it). The page's range is measured in
|
|
169
|
+
* this order, not in the thread order the transcript is drawn in: the relay
|
|
170
|
+
* cuts its pages by it, so a row inside the page's range in this order is a
|
|
171
|
+
* row the relay would have put on the page.
|
|
172
|
+
*/
|
|
173
|
+
export function compareHistoryOrder(a, b) {
|
|
174
|
+
const aMs = timestampMsOf(a);
|
|
175
|
+
const bMs = timestampMsOf(b);
|
|
176
|
+
if (aMs !== bMs) return aMs - bMs;
|
|
177
|
+
return messageIdOf(a).localeCompare(messageIdOf(b));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The newest page of a conversation merged into a kept window that reaches
|
|
182
|
+
* the end. A message of the page replaces the one with its id (its text may
|
|
183
|
+
* have changed since: a reply that was still streaming), the others join in
|
|
184
|
+
* conversation order. Null when the page does not meet the window: more rows
|
|
185
|
+
* were written than the page holds, and the rows between are unknown.
|
|
186
|
+
* `changed` and `added` are the ids the page brought that differ from, or
|
|
187
|
+
* are missing in, the window (`sameMessage` decides what differs).
|
|
188
|
+
* The page is the truth for its own range, from its oldest row to its
|
|
189
|
+
* newest in the relay's order: a window row inside it that the page lacks
|
|
190
|
+
* was deleted while the reader was away and is dropped (`removed`). Rows
|
|
191
|
+
* older than the page are kept, the page says nothing about them; so are
|
|
192
|
+
* rows newer than it, which the socket may have brought after the page was
|
|
193
|
+
* read. With `windowPredatesPage` every window row was there before the page
|
|
194
|
+
* was asked for (the caller merged a window read before the request, or no
|
|
195
|
+
* live row arrived while it was on its way): the page, which ends at the
|
|
196
|
+
* conversation's end, then is the truth up to that end, and a newer window
|
|
197
|
+
* row it lacks was deleted too (the relay's own follow-up dropped by a
|
|
198
|
+
* Cancel, say: the newest row of the conversation).
|
|
199
|
+
*/
|
|
200
|
+
export function mergeTailPage(windowMessages, pageMessages, {
|
|
201
|
+
pageHasMoreOlder = false,
|
|
202
|
+
windowPredatesPage = false,
|
|
203
|
+
sameMessage = (a, b) => JSON.stringify(a) === JSON.stringify(b),
|
|
204
|
+
} = {}) {
|
|
205
|
+
const kept = Array.isArray(windowMessages) ? windowMessages : [];
|
|
206
|
+
const page = Array.isArray(pageMessages) ? pageMessages : [];
|
|
207
|
+
const keptById = new Map();
|
|
208
|
+
for (const message of kept) {
|
|
209
|
+
const id = messageIdOf(message);
|
|
210
|
+
if (id) keptById.set(id, message);
|
|
211
|
+
}
|
|
212
|
+
const meets = !pageHasMoreOlder || page.some((message) => keptById.has(messageIdOf(message)));
|
|
213
|
+
if (!meets) return null;
|
|
214
|
+
const changed = [];
|
|
215
|
+
const added = [];
|
|
216
|
+
const pageById = new Map();
|
|
217
|
+
for (const message of page) {
|
|
218
|
+
const id = messageIdOf(message);
|
|
219
|
+
if (!id) continue;
|
|
220
|
+
pageById.set(id, message);
|
|
221
|
+
if (!keptById.has(id)) added.push(id);
|
|
222
|
+
else if (!sameMessage(keptById.get(id), message)) changed.push(id);
|
|
223
|
+
}
|
|
224
|
+
let oldest = null;
|
|
225
|
+
let newest = null;
|
|
226
|
+
for (const message of pageById.values()) {
|
|
227
|
+
if (!oldest || compareHistoryOrder(message, oldest) < 0) oldest = message;
|
|
228
|
+
if (!newest || compareHistoryOrder(message, newest) > 0) newest = message;
|
|
229
|
+
}
|
|
230
|
+
// An empty page that is the whole conversation (meets without a shared
|
|
231
|
+
// row) holds nothing: every window row is gone, when the window predates it.
|
|
232
|
+
const insidePage = (message) => {
|
|
233
|
+
if (!oldest) return windowPredatesPage && !pageHasMoreOlder;
|
|
234
|
+
if (compareHistoryOrder(message, oldest) < 0) return false;
|
|
235
|
+
return windowPredatesPage || compareHistoryOrder(message, newest) <= 0;
|
|
236
|
+
};
|
|
237
|
+
const removed = [];
|
|
238
|
+
const merged = [];
|
|
239
|
+
for (const message of kept) {
|
|
240
|
+
const id = messageIdOf(message);
|
|
241
|
+
if (pageById.has(id)) {
|
|
242
|
+
merged.push(pageById.get(id));
|
|
243
|
+
} else if (id && insidePage(message)) {
|
|
244
|
+
removed.push(id);
|
|
245
|
+
} else {
|
|
246
|
+
merged.push(message);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const id of added) merged.push(pageById.get(id));
|
|
250
|
+
return {
|
|
251
|
+
messages: added.length ? sortConversationMessages(merged) : merged,
|
|
252
|
+
changed,
|
|
253
|
+
added,
|
|
254
|
+
removed,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -12,7 +12,8 @@ export const LIVE_ROW_RELOAD_ATTEMPTS = 3;
|
|
|
12
12
|
/**
|
|
13
13
|
* `stale` is true when rows still arrived during the last attempt: the page
|
|
14
14
|
* holds rows the answer lacks, so the caller leaves the transcript alone and
|
|
15
|
-
* the next reload asks again
|
|
15
|
+
* the next reload asks again (unless the page holds an older copy, see
|
|
16
|
+
* shouldKeepTranscriptOnReload).
|
|
16
17
|
*/
|
|
17
18
|
export async function loadKeepingLiveRows(load, getLiveRowEpoch, { attempts = LIVE_ROW_RELOAD_ATTEMPTS } = {}) {
|
|
18
19
|
let response = null;
|
|
@@ -23,3 +24,18 @@ export async function loadKeepingLiveRows(load, getLiveRowEpoch, { attempts = LI
|
|
|
23
24
|
}
|
|
24
25
|
return { response, stale: true };
|
|
25
26
|
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether a reload leaves the transcript as it is. A jump into the history
|
|
30
|
+
* (`jumped`) always wins. A `stale` answer is kept off the page only when
|
|
31
|
+
* the page shows the live transcript (the live poll): it then holds every
|
|
32
|
+
* row the answer has and the socket's newer ones. After a kept copy was
|
|
33
|
+
* drawn on a switch (`showsLiveTranscript` false) the page holds an old
|
|
34
|
+
* copy instead, and the rows stored while the reader was away exist only
|
|
35
|
+
* in the answer: the answer is the closer one, and the next reload brings
|
|
36
|
+
* whatever socket row it still lacks.
|
|
37
|
+
*/
|
|
38
|
+
export function shouldKeepTranscriptOnReload({ stale = false, jumped = false, showsLiveTranscript = true } = {}) {
|
|
39
|
+
if (jumped) return true;
|
|
40
|
+
return !!stale && !!showsLiveTranscript;
|
|
41
|
+
}
|
|
@@ -127,6 +127,24 @@ function isReadable(block, rect) {
|
|
|
127
127
|
return typeof block.closest !== 'function' || !block.closest('details:not([open]) > :not(summary)');
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
// Where restoreReadingBlock last put a block, per scroller. scrollTop takes
|
|
131
|
+
// whole pixels, so the block lands up to half a pixel off the offset it was
|
|
132
|
+
// asked for. Rows are rebuilt twice in a row when a turn ends (the reply
|
|
133
|
+
// takes the live bubble's place, then the reload after the turn), and each
|
|
134
|
+
// capture would take the last landing as the truth: the halves added up to
|
|
135
|
+
// more than a pixel.
|
|
136
|
+
const lastRestored = new WeakMap();
|
|
137
|
+
|
|
138
|
+
// The offset the block was put at, while the reader is still there: the
|
|
139
|
+
// same block, scrollTop as it was left, and the block no further off than
|
|
140
|
+
// the rounding.
|
|
141
|
+
function intendedOffset(el, tag, text, measured) {
|
|
142
|
+
const restored = lastRestored.get(el);
|
|
143
|
+
if (!restored || restored.tag !== tag || restored.text !== text) return measured;
|
|
144
|
+
if (Number(el.scrollTop || 0) !== restored.scrollTop) return measured;
|
|
145
|
+
return Math.abs(measured - restored.offset) <= 0.5 ? restored.offset : measured;
|
|
146
|
+
}
|
|
147
|
+
|
|
130
148
|
/**
|
|
131
149
|
* The block of text the reader is at: the first one that starts inside the
|
|
132
150
|
* viewport, else the one that reaches into it from above (a code block
|
|
@@ -167,7 +185,7 @@ export function captureReadingBlock(el, { scope = null } = {}) {
|
|
|
167
185
|
const tag = String(block.tagName || '').toLowerCase();
|
|
168
186
|
const text = blockText(block);
|
|
169
187
|
const height = Math.max(0, rect.bottom - rect.top);
|
|
170
|
-
const offset = rect.top - view.top;
|
|
188
|
+
const offset = intendedOffset(el, tag, text, rect.top - view.top);
|
|
171
189
|
return {
|
|
172
190
|
messageId: String(foundScope?.dataset?.messageId || '').trim() || null,
|
|
173
191
|
// Read in the live bubble: the reply that replaces it is another row.
|
|
@@ -220,6 +238,7 @@ export function restoreReadingBlock(el, anchor, { scope = null } = {}) {
|
|
|
220
238
|
: Number(anchor.offsetFraction) * Math.max(0, rect.bottom - rect.top);
|
|
221
239
|
const delta = rect.top - view.top - target;
|
|
222
240
|
if (Math.abs(delta) > 0.5) el.scrollTop = Number(el.scrollTop || 0) + delta;
|
|
241
|
+
lastRestored.set(el, { tag: anchor.tag, text: anchor.text, offset: target, scrollTop: Number(el.scrollTop || 0) });
|
|
223
242
|
return true;
|
|
224
243
|
}
|
|
225
244
|
|