@oar-sh/oar 0.9.10 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/CHANGELOG.md +440 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +112 -25
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +217 -11
  8. package/server/claude-worker/claude-session-worker.mjs +26 -1
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +68 -2
  15. package/server/grok-worker/acp-client.mjs +2 -1
  16. package/server/grok-worker/acp-host-services.mjs +46 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0013-previews.mjs +130 -0
  22. package/server/migrations/0014-conversation-groups.mjs +89 -0
  23. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  24. package/server/migrations/0016-share-access-log.mjs +110 -0
  25. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  30. package/server/public/app/api-client.js +264 -16
  31. package/server/public/app/ask-user-view.js +17 -15
  32. package/server/public/app/background-tasks-view.mjs +285 -16
  33. package/server/public/app/bootstrap.js +253 -37
  34. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  35. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  36. package/server/public/app/context-menu.mjs +90 -6
  37. package/server/public/app/conversation-group-drag.mjs +191 -0
  38. package/server/public/app/conversation-group-order.mjs +265 -0
  39. package/server/public/app/conversation-group-state.mjs +100 -0
  40. package/server/public/app/conversation-list-drag.mjs +449 -0
  41. package/server/public/app/conversation-payload-cache.mjs +357 -0
  42. package/server/public/app/conversation-turn-state.mjs +81 -0
  43. package/server/public/app/conversation-view.js +567 -94
  44. package/server/public/app/conversation-window-retention.mjs +96 -0
  45. package/server/public/app/cwd-picker.js +1 -1
  46. package/server/public/app/hidden-messages-view.mjs +312 -0
  47. package/server/public/app/host-suspend-ui.js +7 -1
  48. package/server/public/app/journal-view.js +1033 -82
  49. package/server/public/app/preview-cards.mjs +117 -31
  50. package/server/public/app/preview-command.mjs +10 -1
  51. package/server/public/app/relay-board-view.js +19 -6
  52. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  53. package/server/public/app/relay-restart-error.mjs +16 -0
  54. package/server/public/app/settings-modal.js +150 -10
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/share-format.mjs +49 -0
  57. package/server/public/app/share-preview-marks.mjs +167 -0
  58. package/server/public/app/share-state.js +109 -0
  59. package/server/public/app/shared-conversations-settings.js +480 -0
  60. package/server/public/app/socket-handlers.js +138 -20
  61. package/server/public/app/store.js +121 -30
  62. package/server/public/app/subagent-row.mjs +3 -3
  63. package/server/public/app/subagent-viewer.mjs +37 -7
  64. package/server/public/app/task-console-render.mjs +237 -0
  65. package/server/public/app/task-console-watch.mjs +42 -0
  66. package/server/public/app/task-console.mjs +630 -0
  67. package/server/public/app/task-output-text.mjs +234 -0
  68. package/server/public/app/thinking-follow.js +0 -4
  69. package/server/public/app/transcript-island.mjs +73 -0
  70. package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
  71. package/server/public/app/transcript-jump-bottom.mjs +170 -22
  72. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  73. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  74. package/server/public/app/transcript-viewport-anchor.mjs +159 -0
  75. package/server/public/index.html +625 -21
  76. package/server/relay-tools.md +3 -1
  77. package/server/relay.mjs +2 -1
  78. package/server/repositories/conversation-group-repository.mjs +444 -0
  79. package/server/repositories/message-repository.mjs +9 -0
  80. package/server/repositories/question-repository.mjs +4 -0
  81. package/server/repositories/session-repository.mjs +12 -37
  82. package/server/repositories/share-repository.mjs +322 -0
  83. package/server/repositories/share-visibility-statements.mjs +10 -0
  84. package/server/routes/about-routes.mjs +15 -0
  85. package/server/routes/cache-routes.mjs +4 -0
  86. package/server/routes/conversation-group-routes.mjs +171 -0
  87. package/server/routes/messages-routes.mjs +433 -68
  88. package/server/routes/preview-routes.mjs +9 -1
  89. package/server/routes/sessions-routes.mjs +901 -253
  90. package/server/server-runtime.mjs +392 -99
  91. package/server/services/about-service.mjs +461 -0
  92. package/server/services/background-task-store.mjs +365 -0
  93. package/server/services/background-work-view.mjs +228 -0
  94. package/server/services/cache-rebuild-service.mjs +30 -0
  95. package/server/services/cli-process-runner.mjs +4 -21
  96. package/server/services/conversation-history-page.mjs +199 -0
  97. package/server/services/delete-archive-service.mjs +4 -1
  98. package/server/services/detached-worker-watch-service.mjs +150 -0
  99. package/server/services/hidden-message-list.mjs +21 -0
  100. package/server/services/host-suspend-command.mjs +2 -1
  101. package/server/services/message-pin-list.mjs +1 -1
  102. package/server/services/preview-health-probe.mjs +8 -1
  103. package/server/services/preview-proxy-server.mjs +8 -4
  104. package/server/services/preview-registry-service.mjs +676 -26
  105. package/server/services/preview-static-handler.mjs +40 -7
  106. package/server/services/preview-store.mjs +274 -0
  107. package/server/services/rate-limit-service.mjs +12 -1
  108. package/server/services/relay-auth.mjs +183 -0
  109. package/server/services/relay-cli-launcher-service.mjs +3 -1
  110. package/server/services/relay-event-service.mjs +17 -0
  111. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  112. package/server/services/remote-relay-dispatcher.mjs +187 -7
  113. package/server/services/request-boundary.mjs +183 -0
  114. package/server/services/session-activity-tracker.mjs +231 -19
  115. package/server/services/session-worker-launch-service.mjs +190 -6
  116. package/server/services/session-worker-process-service.mjs +10 -6
  117. package/server/services/share-access-log-service.mjs +123 -0
  118. package/server/services/share-path-redaction.mjs +424 -0
  119. package/server/services/status-event-service.mjs +7 -3
  120. package/server/services/task-output-rooms.mjs +259 -0
  121. package/server/services/task-output-service.mjs +562 -0
  122. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  123. package/server/services/windows-autostart-service.mjs +6 -1
  124. package/server/services/windows-boot-autostart-service.mjs +207 -23
  125. package/shared/claude-background-command-limit.mjs +83 -0
  126. package/shared/preview-tool-core.mjs +15 -5
  127. package/shared/remote-relay-contract.mjs +35 -3
  128. package/shared/share-path-privacy.mjs +30 -0
  129. package/shared/task-output-text.mjs +737 -0
  130. package/shared/windows-system-tools.mjs +106 -0
  131. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  132. package/shared/worker-runtime/heartbeat.mjs +24 -0
  133. package/shared/worker-runtime/kept-posts.mjs +83 -0
  134. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  135. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -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
+ }
@@ -94,6 +94,154 @@ 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
+ // 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
+
148
+ /**
149
+ * The block of text the reader is at: the first one that starts inside the
150
+ * viewport, else the one that reaches into it from above (a code block
151
+ * taller than the screen). Finer than the row anchor above, for the times
152
+ * the rows themselves are rebuilt: a reload of the conversation, or the reply
153
+ * that takes the live bubble's place. There the same text comes back in a
154
+ * row whose other parts (folded thoughts, tool lines, the usage line) have
155
+ * another height, and a row or a pixel anchor moves the text by that much.
156
+ *
157
+ * `scope` limits the search to one element (the live bubble's stream);
158
+ * without it the rows that cross the viewport are searched. Returns null
159
+ * when no block is there (the reader looks at an image, or at a card).
160
+ */
161
+ export function captureReadingBlock(el, { scope = null } = {}) {
162
+ if (!el) return null;
163
+ const view = el.getBoundingClientRect();
164
+ const scopes = scope ? [scope] : transcriptRows(el).filter((row) => {
165
+ const rect = row.getBoundingClientRect();
166
+ return rect.bottom > view.top && rect.top < view.bottom;
167
+ });
168
+ let found = null;
169
+ let foundScope = null;
170
+ for (const candidate of scopes) {
171
+ for (const block of candidate.querySelectorAll(READING_BLOCK_SELECTOR)) {
172
+ const rect = block.getBoundingClientRect();
173
+ if (!isReadable(block, rect) || rect.bottom <= view.top) continue;
174
+ if (rect.top >= view.bottom) break;
175
+ found = { block, rect };
176
+ foundScope = candidate;
177
+ // The first block that starts in view ends the search; one that only
178
+ // reaches into it is kept until a later one does.
179
+ if (rect.top >= view.top) break;
180
+ }
181
+ if (found && found.rect.top >= view.top) break;
182
+ }
183
+ if (!found) return null;
184
+ const { block, rect } = found;
185
+ const tag = String(block.tagName || '').toLowerCase();
186
+ const text = blockText(block);
187
+ const height = Math.max(0, rect.bottom - rect.top);
188
+ const offset = intendedOffset(el, tag, text, rect.top - view.top);
189
+ return {
190
+ messageId: String(foundScope?.dataset?.messageId || '').trim() || null,
191
+ // Read in the live bubble: the reply that replaces it is another row.
192
+ live: foundScope?.id === LIVE_ROW_ID,
193
+ tag,
194
+ text,
195
+ // Which of the row's blocks with this very text (a repeated line).
196
+ occurrence: Math.max(0, sameBlocks(foundScope, tag, text).indexOf(block)),
197
+ offset,
198
+ // Scaled to the block's height when its top is scrolled past, so the
199
+ // same share of it stays hidden if it comes back with another height.
200
+ offsetFraction: offset < 0 && height > 0 ? offset / height : null,
201
+ };
202
+ }
203
+
204
+ // The rows the captured block can be in now. A block read in the live bubble
205
+ // is in that bubble while the turn runs, and in the reply once it has ended:
206
+ // one of the rows that follow the prompt.
207
+ function readingBlockHosts(el, anchor) {
208
+ const messageId = String(anchor.messageId || '').trim();
209
+ if (!messageId) return [];
210
+ const rows = transcriptRows(el);
211
+ const matches = (row) => String(row.dataset?.messageId || '').trim() === messageId;
212
+ if (!anchor.live) return rows.filter((row) => matches(row) && row.id !== LIVE_ROW_ID).slice(0, 1);
213
+ const live = rows.find((row) => matches(row) && row.id === LIVE_ROW_ID);
214
+ if (live) return [live];
215
+ const prompt = rows.findIndex(matches);
216
+ return prompt < 0 ? [] : rows.slice(prompt + 1, prompt + 1 + REPLY_SEARCH_ROWS);
217
+ }
218
+
219
+ /**
220
+ * Scrolls so the captured block sits where it was. It is looked for in
221
+ * `scope` when given (the reply that replaced the live bubble), else in the
222
+ * row it was captured in. Returns false when it is not there; the caller
223
+ * falls back to what it did before.
224
+ */
225
+ export function restoreReadingBlock(el, anchor, { scope = null } = {}) {
226
+ if (!el || !anchor?.tag) return false;
227
+ let block = null;
228
+ for (const host of (scope ? [scope] : readingBlockHosts(el, anchor))) {
229
+ const matches = sameBlocks(host, anchor.tag, anchor.text).filter((node) => isReadable(node, node.getBoundingClientRect()));
230
+ block = matches[anchor.occurrence] || matches[0] || null;
231
+ if (block) break;
232
+ }
233
+ if (!block) return false;
234
+ const view = el.getBoundingClientRect();
235
+ const rect = block.getBoundingClientRect();
236
+ const target = anchor.offsetFraction === null || anchor.offsetFraction === undefined
237
+ ? Number(anchor.offset || 0)
238
+ : Number(anchor.offsetFraction) * Math.max(0, rect.bottom - rect.top);
239
+ const delta = rect.top - view.top - target;
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) });
242
+ return true;
243
+ }
244
+
97
245
  const KEYBOARD_INPUT_TYPES = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'number', 'password']);
98
246
 
99
247
  /**
@@ -201,8 +349,19 @@ export function createTranscriptResizeKeeper({
201
349
  return apply(el);
202
350
  }
203
351
 
352
+ // Content landed under a reader who followed the end and was not followed
353
+ // (no scroll event tells): the position is what it is now, not "the end".
354
+ function recordEndLeft() {
355
+ if (settleTimer !== null) return false;
356
+ const el = element();
357
+ const next = el ? capture(el) : null;
358
+ if (next) committed = next;
359
+ return Boolean(next);
360
+ }
361
+
204
362
  return {
205
363
  recordScroll,
364
+ recordEndLeft,
206
365
  handleResize,
207
366
  isSettling: () => settleTimer !== null,
208
367
  committedAnchor: () => committed,