@oar-sh/oar 0.9.10 → 0.9.11
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 +314 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +95 -22
- package/server/claude-worker/claude-session-process.mjs +89 -11
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/db-schema.mjs +59 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- 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/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 +251 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/bootstrap.js +158 -32
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- 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-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +136 -93
- 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 +787 -22
- 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-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +40 -8
- 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 +80 -14
- package/server/public/app/store.js +96 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +30 -6
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
- package/server/public/app/transcript-jump-bottom.mjs +132 -21
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/index.html +386 -15
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +1 -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 +184 -65
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +771 -193
- package/server/server-runtime.mjs +316 -100
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -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/remote-relay-dispatcher.mjs +57 -5
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +227 -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/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/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +3 -1
- package/shared/share-path-privacy.mjs +30 -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/worker-websocket-link.mjs +47 -15
|
@@ -3,8 +3,15 @@
|
|
|
3
3
|
// Decisions live in transcript-jump-bottom.mjs; this module reads the
|
|
4
4
|
// scroller and the rows and applies them.
|
|
5
5
|
//
|
|
6
|
+
// The transcript moves by itself in two cases only: the reader is at the
|
|
7
|
+
// end, or the reader has just sent something. Both are asked here, before
|
|
8
|
+
// the row is put on the page: followsTranscriptEnd() by every live update,
|
|
9
|
+
// ownSendFollows() by the reader's own sends. "At the end" is one margin
|
|
10
|
+
// everywhere (resolveNearBottomThresholdPx). An update that is not followed
|
|
11
|
+
// leaves the transcript where it is; what it added marks the button.
|
|
12
|
+
//
|
|
6
13
|
// Following the end has no state of its own: every live update asks
|
|
7
|
-
//
|
|
14
|
+
// followsTranscriptEnd() before it appends and scrolls. When the composer grows
|
|
8
15
|
// (the background-task panel appears or opens, a banner, a taller draft) the
|
|
9
16
|
// transcript shrinks from below, scrollTop stays and no scroll event fires,
|
|
10
17
|
// so the reader is left that many pixels above the end and the next update
|
|
@@ -20,8 +27,9 @@ import {
|
|
|
20
27
|
shouldHoldFocusOnJump,
|
|
21
28
|
} from './composer-keyboard.mjs';
|
|
22
29
|
import {
|
|
30
|
+
isScrollOnSendEnabled,
|
|
23
31
|
isNearBottom,
|
|
24
|
-
|
|
32
|
+
jumpButtonPresentation,
|
|
25
33
|
jumpScrollBehavior,
|
|
26
34
|
nextJumpButtonState,
|
|
27
35
|
shouldRepinOnResize,
|
|
@@ -30,7 +38,10 @@ import {
|
|
|
30
38
|
|
|
31
39
|
// The final snap of a smooth jump where the browser has no scrollend event.
|
|
32
40
|
const SMOOTH_JUMP_FALLBACK_MS = 900;
|
|
33
|
-
|
|
41
|
+
// A card that waits for the reader: a question or an approval, or a board
|
|
42
|
+
// whose actions are still open.
|
|
43
|
+
const WAITING_CARD_SELECTOR = '.relay-question-container, .relay-board-inline .relay-board-actions';
|
|
44
|
+
const STATUS_ID = 'jump-to-bottom-status';
|
|
34
45
|
|
|
35
46
|
let initialized = false;
|
|
36
47
|
let state = null;
|
|
@@ -43,6 +54,15 @@ let observedClientHeight = null;
|
|
|
43
54
|
let jump = null;
|
|
44
55
|
let updateScheduled = false;
|
|
45
56
|
let loadConversationEnd = null;
|
|
57
|
+
// Counts the times the reader left the end: a scroll that took the position
|
|
58
|
+
// up and away from it, whatever caused it (a wheel, a drag, a key with the
|
|
59
|
+
// focus anywhere, the scrollbar). An input that moves nothing is none: a
|
|
60
|
+
// wheel notch at the end, a key typed into a card. What scrolls to the end on
|
|
61
|
+
// the reader's behalf after their own send checks that this has not happened
|
|
62
|
+
// since.
|
|
63
|
+
let readerMoveEpoch = 0;
|
|
64
|
+
let lastScrollTop = 0;
|
|
65
|
+
const endLeftListeners = new Set();
|
|
46
66
|
|
|
47
67
|
function scroller() {
|
|
48
68
|
return document.getElementById('messages');
|
|
@@ -58,7 +78,6 @@ function rowKeys(el) {
|
|
|
58
78
|
if (!node.classList?.contains('msg')) continue;
|
|
59
79
|
const key = transcriptRowKey({
|
|
60
80
|
messageId: node.dataset.messageId,
|
|
61
|
-
exchangePromptId: node.dataset.agentExchangePromptId,
|
|
62
81
|
live: node.id === 'thinking-indicator',
|
|
63
82
|
});
|
|
64
83
|
if (key) keys.push(key);
|
|
@@ -66,42 +85,79 @@ function rowKeys(el) {
|
|
|
66
85
|
return keys;
|
|
67
86
|
}
|
|
68
87
|
|
|
69
|
-
|
|
88
|
+
// Whether a waiting card lies wholly below what the reader sees.
|
|
89
|
+
function questionWaitingBelow(el) {
|
|
90
|
+
const bottom = el.getBoundingClientRect().bottom;
|
|
91
|
+
for (const card of el.querySelectorAll(WAITING_CARD_SELECTOR)) {
|
|
92
|
+
if (card.getBoundingClientRect().top >= bottom) return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// What a screen reader hears when the button changes its state: the button's
|
|
98
|
+
// own label is only read once it has the focus.
|
|
99
|
+
function statusRegion(button) {
|
|
100
|
+
let region = document.getElementById(STATUS_ID);
|
|
101
|
+
if (region || !button?.parentNode) return region;
|
|
102
|
+
region = document.createElement('div');
|
|
103
|
+
region.id = STATUS_ID;
|
|
104
|
+
region.setAttribute('role', 'status');
|
|
105
|
+
region.setAttribute('aria-live', 'polite');
|
|
106
|
+
region.style.cssText = 'position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0;';
|
|
107
|
+
button.parentNode.insertBefore(region, button.nextSibling);
|
|
108
|
+
return region;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function render(next) {
|
|
70
112
|
const button = document.getElementById('jump-to-bottom-btn');
|
|
71
113
|
if (!button) return;
|
|
72
|
-
|
|
114
|
+
const shown = jumpButtonPresentation(next || {});
|
|
115
|
+
button.classList.toggle('visible', next?.visible === true);
|
|
116
|
+
if (button.dataset.state !== shown.state) button.dataset.state = shown.state;
|
|
73
117
|
const badge = button.querySelector('.jump-to-bottom-badge');
|
|
74
|
-
const text = visible ? jumpBadgeText(count) : '';
|
|
75
118
|
if (badge) {
|
|
76
|
-
badge.textContent =
|
|
77
|
-
badge.
|
|
119
|
+
if (badge.textContent !== shown.badge) badge.textContent = shown.badge;
|
|
120
|
+
badge.classList.toggle('jump-to-bottom-badge-dot', shown.dot);
|
|
121
|
+
badge.hidden = !shown.badge && !shown.dot;
|
|
78
122
|
}
|
|
79
|
-
|
|
80
|
-
|
|
123
|
+
if (button.getAttribute('aria-label') !== shown.label) {
|
|
124
|
+
button.setAttribute('aria-label', shown.label);
|
|
125
|
+
button.title = shown.label;
|
|
126
|
+
}
|
|
127
|
+
const region = statusRegion(button);
|
|
128
|
+
if (region && region.textContent !== shown.announcement) region.textContent = shown.announcement;
|
|
81
129
|
}
|
|
82
130
|
|
|
83
|
-
function
|
|
84
|
-
updateScheduled = false;
|
|
85
|
-
const el = scroller();
|
|
86
|
-
if (!el) return;
|
|
131
|
+
function syncStateConversation() {
|
|
87
132
|
const conversationId = String(currentConvId || '').trim();
|
|
88
133
|
if (conversationId !== stateConversationId) {
|
|
89
134
|
state = null;
|
|
90
135
|
stateConversationId = conversationId;
|
|
91
136
|
}
|
|
137
|
+
return conversationId;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function update() {
|
|
141
|
+
updateScheduled = false;
|
|
142
|
+
const el = scroller();
|
|
143
|
+
if (!el) return;
|
|
144
|
+
const conversationId = syncStateConversation();
|
|
92
145
|
if (!conversationId || jump) {
|
|
93
|
-
render(
|
|
146
|
+
render(null);
|
|
94
147
|
return;
|
|
95
148
|
}
|
|
149
|
+
notePositionAfterContentChange(el);
|
|
96
150
|
const distance = distanceFromBottom(el);
|
|
97
151
|
const nearBottomPx = resolveNearBottomThresholdPx(el.clientHeight);
|
|
152
|
+
const away = !isNearBottom(distance, nearBottomPx);
|
|
98
153
|
state = nextJumpButtonState(state, {
|
|
99
154
|
distance,
|
|
100
155
|
clientHeight: el.clientHeight,
|
|
101
156
|
nearBottomPx,
|
|
102
|
-
rowKeys:
|
|
157
|
+
rowKeys: away ? rowKeys(el) : [],
|
|
158
|
+
question: away && questionWaitingBelow(el),
|
|
103
159
|
});
|
|
104
|
-
render(state
|
|
160
|
+
render(state);
|
|
105
161
|
}
|
|
106
162
|
|
|
107
163
|
function scheduleUpdate() {
|
|
@@ -110,6 +166,103 @@ function scheduleUpdate() {
|
|
|
110
166
|
requestAnimationFrame(update);
|
|
111
167
|
}
|
|
112
168
|
|
|
169
|
+
/** A card or a board was put on the page, or taken off it. */
|
|
170
|
+
export function refreshJumpButton() {
|
|
171
|
+
scheduleUpdate();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* `listener` runs when a reader who followed the end is found away from it
|
|
176
|
+
* without having scrolled: content landed that was not followed. Whoever
|
|
177
|
+
* keeps "at the end" from the last scroll event lets go of it then.
|
|
178
|
+
*/
|
|
179
|
+
export function onTranscriptEndLeft(listener) {
|
|
180
|
+
if (typeof listener === 'function') endLeftListeners.add(listener);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Asked by every live update before it changes the transcript: true when
|
|
185
|
+
* the reader is at the end and the update scrolls with it. Otherwise the
|
|
186
|
+
* transcript stays where it is. Only a question: what was really added says
|
|
187
|
+
* so itself (a row is counted, anything else calls noteTranscriptNews()).
|
|
188
|
+
*/
|
|
189
|
+
export function followsTranscriptEnd() {
|
|
190
|
+
return isMessagesNearBottom();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Something landed below a reader who is not at the end and added no row to
|
|
195
|
+
* count (a reply growing in its bubble, a thought, a tool line): the button
|
|
196
|
+
* says that there is news until the reader is at the end again.
|
|
197
|
+
*/
|
|
198
|
+
export function noteTranscriptNews() {
|
|
199
|
+
const el = scroller();
|
|
200
|
+
if (!el || !syncStateConversation()) return;
|
|
201
|
+
state = {
|
|
202
|
+
visible: state?.visible === true,
|
|
203
|
+
baseline: Array.isArray(state?.baseline) ? state.baseline : rowKeys(el),
|
|
204
|
+
count: state?.count || 0,
|
|
205
|
+
unseen: true,
|
|
206
|
+
question: state?.question === true,
|
|
207
|
+
};
|
|
208
|
+
scheduleUpdate();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The reader sends something themselves (the composer, a steer, Resend, an
|
|
213
|
+
* answer on a card, a board's action): taken at that moment, before anything
|
|
214
|
+
* is awaited. Nothing a relay or an agent puts into the conversation comes
|
|
215
|
+
* here.
|
|
216
|
+
*/
|
|
217
|
+
export function beginOwnSend() {
|
|
218
|
+
return { conversationId: String(currentConvId || '').trim(), epoch: readerMoveEpoch, follows: false };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A send from the new-chat screen has no conversation; the relay's answer
|
|
223
|
+
* creates it. Named here once it is on screen, so the send goes on in it and
|
|
224
|
+
* in no other.
|
|
225
|
+
*/
|
|
226
|
+
export function nameOwnSendConversation(send, conversationId) {
|
|
227
|
+
if (send && !send.conversationId) send.conversationId = String(conversationId || '').trim();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// The send is still the last thing the reader did to this conversation's
|
|
231
|
+
// position: it is on screen, and they have not scrolled away since.
|
|
232
|
+
function ownSendHolds(send) {
|
|
233
|
+
return !!send
|
|
234
|
+
&& send.conversationId === String(currentConvId || '').trim()
|
|
235
|
+
&& send.epoch === readerMoveEpoch;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Whether what the send puts on the page is followed to the end; asked
|
|
240
|
+
* before each row and each step. A reader who is at the end is followed like
|
|
241
|
+
* with anything else, whatever became of the send. The send adds one right
|
|
242
|
+
* to that: while it holds, it takes the reader to the end from wherever they
|
|
243
|
+
* are (unless that is switched off in the settings) and keeps them there
|
|
244
|
+
* through the layout that follows it. Once the reader has scrolled away, or
|
|
245
|
+
* another conversation is on screen, that right is gone.
|
|
246
|
+
*/
|
|
247
|
+
export function ownSendFollows(send) {
|
|
248
|
+
const atEnd = isMessagesNearBottom();
|
|
249
|
+
if (!ownSendHolds(send)) return atEnd;
|
|
250
|
+
send.follows = send.follows || atEnd || isScrollOnSendEnabled();
|
|
251
|
+
return send.follows;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* To the end after the reader's own send, and again through the layout that
|
|
256
|
+
* follows it (the bubble's separator, a picture that loads, the composer
|
|
257
|
+
* shrinking back): each step asks again.
|
|
258
|
+
*/
|
|
259
|
+
export function scrollAfterOwnSend(send) {
|
|
260
|
+
const step = () => { if (ownSendFollows(send)) scrollBottom(); };
|
|
261
|
+
step();
|
|
262
|
+
requestAnimationFrame(step);
|
|
263
|
+
setTimeout(step, 120);
|
|
264
|
+
}
|
|
265
|
+
|
|
113
266
|
// A scroll event is dispatched a frame after the scroll, before that frame's
|
|
114
267
|
// ResizeObserver round: when the composer grew in between, the transcript
|
|
115
268
|
// has already shrunk, and the reader would read as having left the end. So
|
|
@@ -122,6 +275,18 @@ function notePosition(el) {
|
|
|
122
275
|
pinnedScrollTop = el.scrollTop;
|
|
123
276
|
}
|
|
124
277
|
|
|
278
|
+
// The content changed without a scroll event. A reader who followed the end
|
|
279
|
+
// and is away from it now was not followed (rows landed while they held a
|
|
280
|
+
// selection, a card grew): a later resize must not take them to the end.
|
|
281
|
+
function notePositionAfterContentChange(el) {
|
|
282
|
+
const followed = pinned;
|
|
283
|
+
notePosition(el);
|
|
284
|
+
if (!followed || pinned) return;
|
|
285
|
+
for (const listener of endLeftListeners) {
|
|
286
|
+
try { listener(); } catch {}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
125
290
|
function endJump() {
|
|
126
291
|
if (!jump) return;
|
|
127
292
|
jump.cleanup();
|
|
@@ -175,7 +340,7 @@ export function jumpTranscriptToBottom() {
|
|
|
175
340
|
syncThinkingFollowButtons(document, null);
|
|
176
341
|
}
|
|
177
342
|
state = null;
|
|
178
|
-
render(
|
|
343
|
+
render(null);
|
|
179
344
|
// A window in the middle of the history (a search result, a pin) ends
|
|
180
345
|
// above the conversation's end: the end is loaded instead.
|
|
181
346
|
if (loadConversationEnd && !isConversationWindowAtTail()) {
|
|
@@ -199,11 +364,16 @@ export function jumpTranscriptToBottom() {
|
|
|
199
364
|
function handleScroll() {
|
|
200
365
|
const el = scroller();
|
|
201
366
|
if (!el) return;
|
|
367
|
+
const movedUp = el.scrollTop < lastScrollTop - 1;
|
|
368
|
+
lastScrollTop = el.scrollTop;
|
|
202
369
|
if (jump) {
|
|
203
370
|
if (distanceFromBottom(el) <= 1) finishJump();
|
|
204
371
|
return;
|
|
205
372
|
}
|
|
206
373
|
notePosition(el);
|
|
374
|
+
// `pinned` is "at the end" as of this scroll. A clamp (the page got
|
|
375
|
+
// shorter) also takes scrollTop down, and ends at the end.
|
|
376
|
+
if (movedUp && !pinned) readerMoveEpoch += 1;
|
|
207
377
|
scheduleUpdate();
|
|
208
378
|
}
|
|
209
379
|
|
|
@@ -267,6 +437,7 @@ export function initTranscriptJumpToBottom({ loadEnd } = {}) {
|
|
|
267
437
|
const button = document.getElementById('jump-to-bottom-btn');
|
|
268
438
|
if (!el || !button) return;
|
|
269
439
|
initialized = true;
|
|
440
|
+
lastScrollTop = el.scrollTop;
|
|
270
441
|
pinnedScrollTop = el.scrollTop;
|
|
271
442
|
observedClientHeight = el.clientHeight;
|
|
272
443
|
button.addEventListener('click', (event) => {
|
|
@@ -280,8 +451,11 @@ export function initTranscriptJumpToBottom({ loadEnd } = {}) {
|
|
|
280
451
|
window.visualViewport?.addEventListener?.('resize', noteViewport, { passive: true });
|
|
281
452
|
window.addEventListener('resize', noteViewport, { passive: true });
|
|
282
453
|
el.addEventListener('scroll', handleScroll, { passive: true });
|
|
454
|
+
// The whole subtree: the button shows at any distance once something new
|
|
455
|
+
// is below, so it also has to go when the end comes back to the reader
|
|
456
|
+
// without a scroll (the live bubble folding its thoughts, a row shrinking).
|
|
283
457
|
if (typeof MutationObserver === 'function') {
|
|
284
|
-
new MutationObserver(scheduleUpdate).observe(el, { childList: true });
|
|
458
|
+
new MutationObserver(scheduleUpdate).observe(el, { childList: true, subtree: true, characterData: true });
|
|
285
459
|
}
|
|
286
460
|
if (typeof ResizeObserver === 'function') {
|
|
287
461
|
new ResizeObserver(handleResize).observe(el);
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
// The transcript's jump-to-bottom button, without the DOM: when it shows,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
2
|
+
// which of its three states it is in (plain, something new below, a question
|
|
3
|
+
// waits below), what its badge counts, how far a click scrolls smoothly, and
|
|
4
|
+
// whether a resize of the transcript re-pins a reader who was following the
|
|
5
|
+
// end.
|
|
4
6
|
// transcript-jump-bottom-view.mjs reads the scroller and the rows and applies
|
|
5
7
|
// what this module decides.
|
|
6
8
|
//
|
|
7
9
|
// "At the bottom" is the composer's near-bottom rule (isMessagesNearBottom,
|
|
8
10
|
// resolveNearBottomThresholdPx); the caller passes its threshold in.
|
|
11
|
+
//
|
|
12
|
+
// Also here: the setting that decides whether the reader's own send jumps to
|
|
13
|
+
// the end.
|
|
9
14
|
|
|
10
15
|
/** The button fades in once the reader is this many screens above the end. */
|
|
11
16
|
export const JUMP_BUTTON_SHOW_SCREENS = 1;
|
|
@@ -14,15 +19,11 @@ export const JUMP_SMOOTH_MAX_SCREENS = 3;
|
|
|
14
19
|
export const JUMP_BADGE_MAX = 99;
|
|
15
20
|
|
|
16
21
|
/**
|
|
17
|
-
* One key per row the reader sees.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* bubble of a turn outside a fold has the prompt's id as its message id, so
|
|
21
|
-
* it gets a key of its own.
|
|
22
|
+
* One key per row the reader sees. The live bubble of a turn has the
|
|
23
|
+
* prompt's id as its message id, so it gets a key of its own. A folded agent
|
|
24
|
+
* prompt is a row like any other: its reply shows below it and counts too.
|
|
22
25
|
*/
|
|
23
|
-
export function transcriptRowKey({ messageId = '',
|
|
24
|
-
const fold = String(exchangePromptId || '').trim();
|
|
25
|
-
if (fold) return `fold:${fold}`;
|
|
26
|
+
export function transcriptRowKey({ messageId = '', live = false } = {}) {
|
|
26
27
|
const id = String(messageId || '').trim();
|
|
27
28
|
if (!id) return '';
|
|
28
29
|
return live ? `live:${id}` : `msg:${id}`;
|
|
@@ -57,20 +58,85 @@ export function isNearBottom(distance, nearBottomPx) {
|
|
|
57
58
|
/**
|
|
58
59
|
* The button's next state from the scroller's geometry. At the bottom it
|
|
59
60
|
* hides and forgets the baseline. Away from it, the rows on the page when the
|
|
60
|
-
* reader left are the baseline the badge counts from; the button shows
|
|
61
|
-
* one screen above the end and, once shown, stays until the bottom.
|
|
61
|
+
* reader left are the baseline the badge counts from; the plain button shows
|
|
62
|
+
* from one screen above the end and, once shown, stays until the bottom.
|
|
63
|
+
*
|
|
64
|
+
* The transcript does not move under a reader who is away from the end, so
|
|
65
|
+
* the button is how they learn of what landed there, at any distance:
|
|
66
|
+
* `unseen` is a live update that was not followed (it holds until the
|
|
67
|
+
* bottom: a reply growing in its bubble adds no row to count), `question` a
|
|
68
|
+
* card waiting for an answer below the viewport (as it is now: answered
|
|
69
|
+
* elsewhere, it is gone).
|
|
62
70
|
*/
|
|
63
|
-
export function nextJumpButtonState(previous, {
|
|
64
|
-
|
|
71
|
+
export function nextJumpButtonState(previous, {
|
|
72
|
+
distance,
|
|
73
|
+
clientHeight,
|
|
74
|
+
nearBottomPx,
|
|
75
|
+
rowKeys = [],
|
|
76
|
+
unseen = false,
|
|
77
|
+
question = false,
|
|
78
|
+
} = {}) {
|
|
79
|
+
if (isNearBottom(distance, nearBottomPx)) {
|
|
80
|
+
return { visible: false, baseline: null, count: 0, unseen: false, question: false };
|
|
81
|
+
}
|
|
65
82
|
const baseline = Array.isArray(previous?.baseline) ? previous.baseline : rowKeys.slice();
|
|
66
83
|
const screens = Number(clientHeight) > 0 ? Number(distance) / Number(clientHeight) : 0;
|
|
84
|
+
const count = countNewRows(baseline, rowKeys);
|
|
85
|
+
const unseenNow = previous?.unseen === true || unseen === true;
|
|
86
|
+
const questionNow = question === true;
|
|
67
87
|
return {
|
|
68
|
-
visible: previous?.visible === true || screens >= JUMP_BUTTON_SHOW_SCREENS,
|
|
88
|
+
visible: previous?.visible === true || screens >= JUMP_BUTTON_SHOW_SCREENS || count > 0 || unseenNow || questionNow,
|
|
69
89
|
baseline,
|
|
70
|
-
count
|
|
90
|
+
count,
|
|
91
|
+
unseen: unseenNow,
|
|
92
|
+
question: questionNow,
|
|
71
93
|
};
|
|
72
94
|
}
|
|
73
95
|
|
|
96
|
+
export const JUMP_BUTTON_LABEL = 'Jump to the latest message';
|
|
97
|
+
export const JUMP_BUTTON_QUESTION_LABEL = 'A question is waiting for your answer. Jump to it';
|
|
98
|
+
export const JUMP_QUESTION_BADGE = '?';
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* How a state looks and reads. `state` is 'plain', 'new' or 'question'; a
|
|
102
|
+
* waiting question wins over news. `badge` is the count, '?' for a question,
|
|
103
|
+
* 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.
|
|
106
|
+
*/
|
|
107
|
+
export function jumpButtonPresentation({ visible = false, count = 0, unseen = false, question = false } = {}) {
|
|
108
|
+
if (!visible) return { state: 'plain', label: JUMP_BUTTON_LABEL, badge: '', dot: false, announcement: '' };
|
|
109
|
+
if (question) {
|
|
110
|
+
return {
|
|
111
|
+
state: 'question',
|
|
112
|
+
label: JUMP_BUTTON_QUESTION_LABEL,
|
|
113
|
+
badge: JUMP_QUESTION_BADGE,
|
|
114
|
+
dot: false,
|
|
115
|
+
announcement: 'A question is waiting for your answer below.',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const badge = jumpBadgeText(count);
|
|
119
|
+
if (badge) {
|
|
120
|
+
return {
|
|
121
|
+
state: 'new',
|
|
122
|
+
label: `${JUMP_BUTTON_LABEL} (${Math.trunc(Number(count))} new)`,
|
|
123
|
+
badge,
|
|
124
|
+
dot: false,
|
|
125
|
+
announcement: 'New messages below.',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (unseen) {
|
|
129
|
+
return {
|
|
130
|
+
state: 'new',
|
|
131
|
+
label: `${JUMP_BUTTON_LABEL} (new below)`,
|
|
132
|
+
badge: '',
|
|
133
|
+
dot: true,
|
|
134
|
+
announcement: 'New messages below.',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return { state: 'plain', label: JUMP_BUTTON_LABEL, badge: '', dot: false, announcement: '' };
|
|
138
|
+
}
|
|
139
|
+
|
|
74
140
|
/** 'smooth' up to JUMP_SMOOTH_MAX_SCREENS above the end, else 'instant'. */
|
|
75
141
|
export function jumpScrollBehavior({ distance, clientHeight, reducedMotion = false } = {}) {
|
|
76
142
|
if (reducedMotion) return 'instant';
|
|
@@ -88,12 +154,57 @@ export function jumpBadgeText(count) {
|
|
|
88
154
|
/**
|
|
89
155
|
* Whether a resize of the transcript (the composer or the background-task
|
|
90
156
|
* panel growing under it) re-pins it to the end. The scroller shrinking from
|
|
91
|
-
* below keeps scrollTop and fires no scroll event, so
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
157
|
+
* below keeps scrollTop and fires no scroll event, so the reader is still
|
|
158
|
+
* described by the last note of their position: `pinned` (they followed the
|
|
159
|
+
* end, by a scroll event or when the content last changed) and the scrollTop
|
|
160
|
+
* it was taken at. While scrollTop is that one, the note alone decides: a
|
|
161
|
+
* reader who was not at the end is never taken there by a resize. Once
|
|
162
|
+
* scrollTop has moved since (a restore, a clamp, the browser's anchoring),
|
|
163
|
+
* the note is stale and the geometry as it is now decides.
|
|
95
164
|
*/
|
|
96
165
|
export function shouldRepinOnResize({ pinned, pinnedScrollTop, scrollTop, nearBottomNow }) {
|
|
97
|
-
if (
|
|
166
|
+
if (Number(scrollTop) === Number(pinnedScrollTop)) return pinned === true;
|
|
98
167
|
return nearBottomNow === true;
|
|
99
168
|
}
|
|
169
|
+
|
|
170
|
+
// "Jump to the end when I send a message" (Settings › General). A setting of
|
|
171
|
+
// this browser, kept in localStorage like the theme. On unless switched off:
|
|
172
|
+
// with it off, the reader's own send follows the same rule as everything
|
|
173
|
+
// else and only scrolls a transcript that is at its end already. The
|
|
174
|
+
// document and the storage are passed in by the tests.
|
|
175
|
+
|
|
176
|
+
export const SCROLL_ON_SEND_STORAGE_KEY = 'copilot_scroll_on_send';
|
|
177
|
+
export const SCROLL_ON_SEND_TOGGLE_ID = 'scroll-on-send-toggle';
|
|
178
|
+
|
|
179
|
+
/** Anything but an explicit off is on: a missing or damaged value included. */
|
|
180
|
+
export function parseScrollOnSendSetting(stored) {
|
|
181
|
+
const value = String(stored ?? '').trim().toLowerCase();
|
|
182
|
+
if (!value) return true;
|
|
183
|
+
return value !== '0' && value !== 'false';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function serializeScrollOnSendSetting(enabled) {
|
|
187
|
+
return enabled ? '1' : '0';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function storage() {
|
|
191
|
+
try { return globalThis.localStorage || null; } catch { return null; }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function isScrollOnSendEnabled(store = storage()) {
|
|
195
|
+
try {
|
|
196
|
+
return parseScrollOnSendSetting(store?.getItem(SCROLL_ON_SEND_STORAGE_KEY));
|
|
197
|
+
} catch {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function syncScrollOnSendToggle(doc = globalThis.document, store = storage()) {
|
|
203
|
+
const checkbox = doc?.getElementById?.(SCROLL_ON_SEND_TOGGLE_ID);
|
|
204
|
+
if (checkbox) checkbox.checked = isScrollOnSendEnabled(store);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function updateScrollOnSendSetting(enabled, { doc = globalThis.document, store = storage() } = {}) {
|
|
208
|
+
try { store?.setItem(SCROLL_ON_SEND_STORAGE_KEY, serializeScrollOnSendSetting(!!enabled)); } catch {}
|
|
209
|
+
syncScrollOnSendToggle(doc, store);
|
|
210
|
+
}
|
|
@@ -94,6 +94,135 @@ 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
|
+
/**
|
|
131
|
+
* The block of text the reader is at: the first one that starts inside the
|
|
132
|
+
* viewport, else the one that reaches into it from above (a code block
|
|
133
|
+
* taller than the screen). Finer than the row anchor above, for the times
|
|
134
|
+
* the rows themselves are rebuilt: a reload of the conversation, or the reply
|
|
135
|
+
* that takes the live bubble's place. There the same text comes back in a
|
|
136
|
+
* row whose other parts (folded thoughts, tool lines, the usage line) have
|
|
137
|
+
* another height, and a row or a pixel anchor moves the text by that much.
|
|
138
|
+
*
|
|
139
|
+
* `scope` limits the search to one element (the live bubble's stream);
|
|
140
|
+
* without it the rows that cross the viewport are searched. Returns null
|
|
141
|
+
* when no block is there (the reader looks at an image, or at a card).
|
|
142
|
+
*/
|
|
143
|
+
export function captureReadingBlock(el, { scope = null } = {}) {
|
|
144
|
+
if (!el) return null;
|
|
145
|
+
const view = el.getBoundingClientRect();
|
|
146
|
+
const scopes = scope ? [scope] : transcriptRows(el).filter((row) => {
|
|
147
|
+
const rect = row.getBoundingClientRect();
|
|
148
|
+
return rect.bottom > view.top && rect.top < view.bottom;
|
|
149
|
+
});
|
|
150
|
+
let found = null;
|
|
151
|
+
let foundScope = null;
|
|
152
|
+
for (const candidate of scopes) {
|
|
153
|
+
for (const block of candidate.querySelectorAll(READING_BLOCK_SELECTOR)) {
|
|
154
|
+
const rect = block.getBoundingClientRect();
|
|
155
|
+
if (!isReadable(block, rect) || rect.bottom <= view.top) continue;
|
|
156
|
+
if (rect.top >= view.bottom) break;
|
|
157
|
+
found = { block, rect };
|
|
158
|
+
foundScope = candidate;
|
|
159
|
+
// The first block that starts in view ends the search; one that only
|
|
160
|
+
// reaches into it is kept until a later one does.
|
|
161
|
+
if (rect.top >= view.top) break;
|
|
162
|
+
}
|
|
163
|
+
if (found && found.rect.top >= view.top) break;
|
|
164
|
+
}
|
|
165
|
+
if (!found) return null;
|
|
166
|
+
const { block, rect } = found;
|
|
167
|
+
const tag = String(block.tagName || '').toLowerCase();
|
|
168
|
+
const text = blockText(block);
|
|
169
|
+
const height = Math.max(0, rect.bottom - rect.top);
|
|
170
|
+
const offset = rect.top - view.top;
|
|
171
|
+
return {
|
|
172
|
+
messageId: String(foundScope?.dataset?.messageId || '').trim() || null,
|
|
173
|
+
// Read in the live bubble: the reply that replaces it is another row.
|
|
174
|
+
live: foundScope?.id === LIVE_ROW_ID,
|
|
175
|
+
tag,
|
|
176
|
+
text,
|
|
177
|
+
// Which of the row's blocks with this very text (a repeated line).
|
|
178
|
+
occurrence: Math.max(0, sameBlocks(foundScope, tag, text).indexOf(block)),
|
|
179
|
+
offset,
|
|
180
|
+
// Scaled to the block's height when its top is scrolled past, so the
|
|
181
|
+
// same share of it stays hidden if it comes back with another height.
|
|
182
|
+
offsetFraction: offset < 0 && height > 0 ? offset / height : null,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The rows the captured block can be in now. A block read in the live bubble
|
|
187
|
+
// is in that bubble while the turn runs, and in the reply once it has ended:
|
|
188
|
+
// one of the rows that follow the prompt.
|
|
189
|
+
function readingBlockHosts(el, anchor) {
|
|
190
|
+
const messageId = String(anchor.messageId || '').trim();
|
|
191
|
+
if (!messageId) return [];
|
|
192
|
+
const rows = transcriptRows(el);
|
|
193
|
+
const matches = (row) => String(row.dataset?.messageId || '').trim() === messageId;
|
|
194
|
+
if (!anchor.live) return rows.filter((row) => matches(row) && row.id !== LIVE_ROW_ID).slice(0, 1);
|
|
195
|
+
const live = rows.find((row) => matches(row) && row.id === LIVE_ROW_ID);
|
|
196
|
+
if (live) return [live];
|
|
197
|
+
const prompt = rows.findIndex(matches);
|
|
198
|
+
return prompt < 0 ? [] : rows.slice(prompt + 1, prompt + 1 + REPLY_SEARCH_ROWS);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Scrolls so the captured block sits where it was. It is looked for in
|
|
203
|
+
* `scope` when given (the reply that replaced the live bubble), else in the
|
|
204
|
+
* row it was captured in. Returns false when it is not there; the caller
|
|
205
|
+
* falls back to what it did before.
|
|
206
|
+
*/
|
|
207
|
+
export function restoreReadingBlock(el, anchor, { scope = null } = {}) {
|
|
208
|
+
if (!el || !anchor?.tag) return false;
|
|
209
|
+
let block = null;
|
|
210
|
+
for (const host of (scope ? [scope] : readingBlockHosts(el, anchor))) {
|
|
211
|
+
const matches = sameBlocks(host, anchor.tag, anchor.text).filter((node) => isReadable(node, node.getBoundingClientRect()));
|
|
212
|
+
block = matches[anchor.occurrence] || matches[0] || null;
|
|
213
|
+
if (block) break;
|
|
214
|
+
}
|
|
215
|
+
if (!block) return false;
|
|
216
|
+
const view = el.getBoundingClientRect();
|
|
217
|
+
const rect = block.getBoundingClientRect();
|
|
218
|
+
const target = anchor.offsetFraction === null || anchor.offsetFraction === undefined
|
|
219
|
+
? Number(anchor.offset || 0)
|
|
220
|
+
: Number(anchor.offsetFraction) * Math.max(0, rect.bottom - rect.top);
|
|
221
|
+
const delta = rect.top - view.top - target;
|
|
222
|
+
if (Math.abs(delta) > 0.5) el.scrollTop = Number(el.scrollTop || 0) + delta;
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
97
226
|
const KEYBOARD_INPUT_TYPES = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'number', 'password']);
|
|
98
227
|
|
|
99
228
|
/**
|
|
@@ -201,8 +330,19 @@ export function createTranscriptResizeKeeper({
|
|
|
201
330
|
return apply(el);
|
|
202
331
|
}
|
|
203
332
|
|
|
333
|
+
// Content landed under a reader who followed the end and was not followed
|
|
334
|
+
// (no scroll event tells): the position is what it is now, not "the end".
|
|
335
|
+
function recordEndLeft() {
|
|
336
|
+
if (settleTimer !== null) return false;
|
|
337
|
+
const el = element();
|
|
338
|
+
const next = el ? capture(el) : null;
|
|
339
|
+
if (next) committed = next;
|
|
340
|
+
return Boolean(next);
|
|
341
|
+
}
|
|
342
|
+
|
|
204
343
|
return {
|
|
205
344
|
recordScroll,
|
|
345
|
+
recordEndLeft,
|
|
206
346
|
handleResize,
|
|
207
347
|
isSettling: () => settleTimer !== null,
|
|
208
348
|
committedAnchor: () => committed,
|