@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.
- package/CHANGELOG.md +440 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +112 -25
- 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 +217 -11
- package/server/claude-worker/claude-session-worker.mjs +26 -1
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -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 +68 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +46 -6
- package/server/grok-worker/grok-background-tasks.mjs +241 -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/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +264 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +253 -37
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +567 -94
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +1033 -82
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +150 -10
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +138 -20
- package/server/public/app/store.js +121 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +37 -7
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
- package/server/public/app/transcript-jump-bottom.mjs +170 -22
- 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 +159 -0
- package/server/public/index.html +625 -21
- package/server/relay-tools.md +3 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +9 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +433 -68
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +901 -253
- package/server/server-runtime.mjs +392 -99
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +365 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +187 -7
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +231 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +35 -3
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/task-output-text.mjs +737 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- 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,
|