@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
|
@@ -3,15 +3,24 @@
|
|
|
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
|
|
11
|
-
// stops following. The ResizeObserver here puts them back before paint
|
|
18
|
+
// stops following. The ResizeObserver here puts them back before paint, and
|
|
19
|
+
// an update that lands before its round still follows (a reply streaming in
|
|
20
|
+
// right after the panel grew).
|
|
12
21
|
|
|
13
22
|
import { currentConvId, isMessagesNearBottom, scrollBottom } from './store.js';
|
|
14
|
-
import { isConversationWindowAtTail } from './conversation-view.js';
|
|
23
|
+
import { getHeldBackLiveRowCount, isConversationWindowAtTail, releaseReadingAnchorHold } from './conversation-view.js';
|
|
15
24
|
import { disarmThinkingFollow, getThinkingFollowMode, syncThinkingFollowButtons } from './thinking-follow.js';
|
|
16
25
|
import { resolveNearBottomThresholdPx } from './transcript-viewport-anchor.mjs';
|
|
17
26
|
import {
|
|
@@ -20,8 +29,10 @@ import {
|
|
|
20
29
|
shouldHoldFocusOnJump,
|
|
21
30
|
} from './composer-keyboard.mjs';
|
|
22
31
|
import {
|
|
32
|
+
isScrollOnSendEnabled,
|
|
33
|
+
followsEndThroughResize,
|
|
23
34
|
isNearBottom,
|
|
24
|
-
|
|
35
|
+
jumpButtonPresentation,
|
|
25
36
|
jumpScrollBehavior,
|
|
26
37
|
nextJumpButtonState,
|
|
27
38
|
shouldRepinOnResize,
|
|
@@ -30,7 +41,10 @@ import {
|
|
|
30
41
|
|
|
31
42
|
// The final snap of a smooth jump where the browser has no scrollend event.
|
|
32
43
|
const SMOOTH_JUMP_FALLBACK_MS = 900;
|
|
33
|
-
|
|
44
|
+
// A card that waits for the reader: a question or an approval, or a board
|
|
45
|
+
// whose actions are still open.
|
|
46
|
+
const WAITING_CARD_SELECTOR = '.relay-question-container, .relay-board-inline .relay-board-actions';
|
|
47
|
+
const STATUS_ID = 'jump-to-bottom-status';
|
|
34
48
|
|
|
35
49
|
let initialized = false;
|
|
36
50
|
let state = null;
|
|
@@ -43,6 +57,15 @@ let observedClientHeight = null;
|
|
|
43
57
|
let jump = null;
|
|
44
58
|
let updateScheduled = false;
|
|
45
59
|
let loadConversationEnd = null;
|
|
60
|
+
// Counts the times the reader left the end: a scroll that took the position
|
|
61
|
+
// up and away from it, whatever caused it (a wheel, a drag, a key with the
|
|
62
|
+
// focus anywhere, the scrollbar). An input that moves nothing is none: a
|
|
63
|
+
// wheel notch at the end, a key typed into a card. What scrolls to the end on
|
|
64
|
+
// the reader's behalf after their own send checks that this has not happened
|
|
65
|
+
// since.
|
|
66
|
+
let readerMoveEpoch = 0;
|
|
67
|
+
let lastScrollTop = 0;
|
|
68
|
+
const endLeftListeners = new Set();
|
|
46
69
|
|
|
47
70
|
function scroller() {
|
|
48
71
|
return document.getElementById('messages');
|
|
@@ -58,7 +81,6 @@ function rowKeys(el) {
|
|
|
58
81
|
if (!node.classList?.contains('msg')) continue;
|
|
59
82
|
const key = transcriptRowKey({
|
|
60
83
|
messageId: node.dataset.messageId,
|
|
61
|
-
exchangePromptId: node.dataset.agentExchangePromptId,
|
|
62
84
|
live: node.id === 'thinking-indicator',
|
|
63
85
|
});
|
|
64
86
|
if (key) keys.push(key);
|
|
@@ -66,42 +88,81 @@ function rowKeys(el) {
|
|
|
66
88
|
return keys;
|
|
67
89
|
}
|
|
68
90
|
|
|
69
|
-
|
|
91
|
+
// Whether a waiting card lies wholly below what the reader sees.
|
|
92
|
+
function questionWaitingBelow(el) {
|
|
93
|
+
const bottom = el.getBoundingClientRect().bottom;
|
|
94
|
+
for (const card of el.querySelectorAll(WAITING_CARD_SELECTOR)) {
|
|
95
|
+
if (card.getBoundingClientRect().top >= bottom) return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// What a screen reader hears when the button changes its state: the button's
|
|
101
|
+
// own label is only read once it has the focus.
|
|
102
|
+
function statusRegion(button) {
|
|
103
|
+
let region = document.getElementById(STATUS_ID);
|
|
104
|
+
if (region || !button?.parentNode) return region;
|
|
105
|
+
region = document.createElement('div');
|
|
106
|
+
region.id = STATUS_ID;
|
|
107
|
+
region.setAttribute('role', 'status');
|
|
108
|
+
region.setAttribute('aria-live', 'polite');
|
|
109
|
+
region.style.cssText = 'position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0;';
|
|
110
|
+
button.parentNode.insertBefore(region, button.nextSibling);
|
|
111
|
+
return region;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function render(next) {
|
|
70
115
|
const button = document.getElementById('jump-to-bottom-btn');
|
|
71
116
|
if (!button) return;
|
|
72
|
-
|
|
117
|
+
const shown = jumpButtonPresentation(next || {});
|
|
118
|
+
button.classList.toggle('visible', next?.visible === true);
|
|
119
|
+
if (button.dataset.state !== shown.state) button.dataset.state = shown.state;
|
|
73
120
|
const badge = button.querySelector('.jump-to-bottom-badge');
|
|
74
|
-
const text = visible ? jumpBadgeText(count) : '';
|
|
75
121
|
if (badge) {
|
|
76
|
-
badge.textContent =
|
|
77
|
-
badge.
|
|
122
|
+
if (badge.textContent !== shown.badge) badge.textContent = shown.badge;
|
|
123
|
+
badge.classList.toggle('jump-to-bottom-badge-dot', shown.dot);
|
|
124
|
+
badge.hidden = !shown.badge && !shown.dot;
|
|
125
|
+
}
|
|
126
|
+
if (button.getAttribute('aria-label') !== shown.label) {
|
|
127
|
+
button.setAttribute('aria-label', shown.label);
|
|
128
|
+
button.title = shown.label;
|
|
78
129
|
}
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
130
|
+
const region = statusRegion(button);
|
|
131
|
+
if (region && region.textContent !== shown.announcement) region.textContent = shown.announcement;
|
|
81
132
|
}
|
|
82
133
|
|
|
83
|
-
function
|
|
84
|
-
updateScheduled = false;
|
|
85
|
-
const el = scroller();
|
|
86
|
-
if (!el) return;
|
|
134
|
+
function syncStateConversation() {
|
|
87
135
|
const conversationId = String(currentConvId || '').trim();
|
|
88
136
|
if (conversationId !== stateConversationId) {
|
|
89
137
|
state = null;
|
|
90
138
|
stateConversationId = conversationId;
|
|
91
139
|
}
|
|
140
|
+
return conversationId;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function update() {
|
|
144
|
+
updateScheduled = false;
|
|
145
|
+
const el = scroller();
|
|
146
|
+
if (!el) return;
|
|
147
|
+
const conversationId = syncStateConversation();
|
|
92
148
|
if (!conversationId || jump) {
|
|
93
|
-
render(
|
|
149
|
+
render(null);
|
|
94
150
|
return;
|
|
95
151
|
}
|
|
152
|
+
notePositionAfterContentChange(el);
|
|
96
153
|
const distance = distanceFromBottom(el);
|
|
97
154
|
const nearBottomPx = resolveNearBottomThresholdPx(el.clientHeight);
|
|
155
|
+
const away = !isNearBottom(distance, nearBottomPx);
|
|
98
156
|
state = nextJumpButtonState(state, {
|
|
99
157
|
distance,
|
|
100
158
|
clientHeight: el.clientHeight,
|
|
101
159
|
nearBottomPx,
|
|
102
|
-
rowKeys:
|
|
160
|
+
rowKeys: away ? rowKeys(el) : [],
|
|
161
|
+
question: away && questionWaitingBelow(el),
|
|
162
|
+
windowAtTail: isConversationWindowAtTail(),
|
|
163
|
+
waiting: getHeldBackLiveRowCount(),
|
|
103
164
|
});
|
|
104
|
-
render(state
|
|
165
|
+
render(state);
|
|
105
166
|
}
|
|
106
167
|
|
|
107
168
|
function scheduleUpdate() {
|
|
@@ -110,6 +171,120 @@ function scheduleUpdate() {
|
|
|
110
171
|
requestAnimationFrame(update);
|
|
111
172
|
}
|
|
112
173
|
|
|
174
|
+
/** A card or a board was put on the page, or taken off it. */
|
|
175
|
+
export function refreshJumpButton() {
|
|
176
|
+
scheduleUpdate();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* `listener` runs when a reader who followed the end is found away from it
|
|
181
|
+
* without having scrolled: content landed that was not followed. Whoever
|
|
182
|
+
* keeps "at the end" from the last scroll event lets go of it then.
|
|
183
|
+
*/
|
|
184
|
+
export function onTranscriptEndLeft(listener) {
|
|
185
|
+
if (typeof listener === 'function') endLeftListeners.add(listener);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Asked by every live update before it changes the transcript: true when
|
|
190
|
+
* the reader is at the end and the update scrolls with it. Otherwise the
|
|
191
|
+
* transcript stays where it is. Only a question: what was really added says
|
|
192
|
+
* so itself (a row is counted, anything else calls noteTranscriptNews()).
|
|
193
|
+
*/
|
|
194
|
+
export function followsTranscriptEnd() {
|
|
195
|
+
// The bottom of a window that ends above the conversation's end is not the
|
|
196
|
+
// end: nothing live is drawn there, and nothing scrolls to it.
|
|
197
|
+
if (!isConversationWindowAtTail()) return false;
|
|
198
|
+
const el = scroller();
|
|
199
|
+
const height = jump ? null : observedClientHeight;
|
|
200
|
+
return followsEndThroughResize({
|
|
201
|
+
nearBottomNow: isMessagesNearBottom(),
|
|
202
|
+
clientHeight: el?.clientHeight,
|
|
203
|
+
observedClientHeight: height,
|
|
204
|
+
distanceAtObservedHeight: el && height !== null ? Math.max(0, el.scrollHeight - height - el.scrollTop) : null,
|
|
205
|
+
nearBottomPxAtObservedHeight: height !== null ? resolveNearBottomThresholdPx(height) : 0,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Something landed below a reader who is not at the end and added no row to
|
|
211
|
+
* count (a reply growing in its bubble, a thought, a tool line): the button
|
|
212
|
+
* says that there is news until the reader is at the end again.
|
|
213
|
+
*/
|
|
214
|
+
export function noteTranscriptNews() {
|
|
215
|
+
const el = scroller();
|
|
216
|
+
if (!el || !syncStateConversation()) return;
|
|
217
|
+
state = {
|
|
218
|
+
visible: state?.visible === true,
|
|
219
|
+
baseline: Array.isArray(state?.baseline) ? state.baseline : rowKeys(el),
|
|
220
|
+
count: state?.count || 0,
|
|
221
|
+
unseen: true,
|
|
222
|
+
question: state?.question === true,
|
|
223
|
+
};
|
|
224
|
+
scheduleUpdate();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The reader sends something themselves (the composer, a steer, Resend, an
|
|
229
|
+
* answer on a card, a board's action): taken at that moment, before anything
|
|
230
|
+
* is awaited. Nothing a relay or an agent puts into the conversation comes
|
|
231
|
+
* here.
|
|
232
|
+
*/
|
|
233
|
+
export function beginOwnSend() {
|
|
234
|
+
return { conversationId: String(currentConvId || '').trim(), epoch: readerMoveEpoch, follows: false };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A send from the new-chat screen has no conversation; the relay's answer
|
|
239
|
+
* creates it. Named here once it is on screen, so the send goes on in it and
|
|
240
|
+
* in no other.
|
|
241
|
+
*/
|
|
242
|
+
export function nameOwnSendConversation(send, conversationId) {
|
|
243
|
+
if (send && !send.conversationId) send.conversationId = String(conversationId || '').trim();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The send is still the last thing the reader did to this conversation's
|
|
247
|
+
// position: it is on screen, and they have not scrolled away since.
|
|
248
|
+
function ownSendHolds(send) {
|
|
249
|
+
return !!send
|
|
250
|
+
&& send.conversationId === String(currentConvId || '').trim()
|
|
251
|
+
&& send.epoch === readerMoveEpoch;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Whether what the send puts on the page is followed to the end; asked
|
|
256
|
+
* before each row and each step. A reader who is at the end is followed like
|
|
257
|
+
* with anything else, whatever became of the send. The send adds one right
|
|
258
|
+
* to that: while it holds, it takes the reader to the end from wherever they
|
|
259
|
+
* are (unless that is switched off in the settings) and keeps them there
|
|
260
|
+
* through the layout that follows it. Once the reader has scrolled away, or
|
|
261
|
+
* another conversation is on screen, that right is gone.
|
|
262
|
+
*/
|
|
263
|
+
export function ownSendFollows(send) {
|
|
264
|
+
const atEnd = isMessagesNearBottom();
|
|
265
|
+
if (!ownSendHolds(send)) return atEnd;
|
|
266
|
+
send.follows = send.follows || atEnd || isScrollOnSendEnabled();
|
|
267
|
+
return send.follows;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* To the end after the reader's own send, and again through the layout that
|
|
272
|
+
* follows it (the bubble's separator, a picture that loads, the composer
|
|
273
|
+
* shrinking back): each step asks again.
|
|
274
|
+
*/
|
|
275
|
+
export function scrollAfterOwnSend(send) {
|
|
276
|
+
const step = () => {
|
|
277
|
+
if (!ownSendFollows(send)) return;
|
|
278
|
+
// A place held while images load would take this scroll for the reader's
|
|
279
|
+
// and let go only then; the send moves the reader, so it lets go first.
|
|
280
|
+
releaseReadingAnchorHold();
|
|
281
|
+
scrollBottom();
|
|
282
|
+
};
|
|
283
|
+
step();
|
|
284
|
+
requestAnimationFrame(step);
|
|
285
|
+
setTimeout(step, 120);
|
|
286
|
+
}
|
|
287
|
+
|
|
113
288
|
// A scroll event is dispatched a frame after the scroll, before that frame's
|
|
114
289
|
// ResizeObserver round: when the composer grew in between, the transcript
|
|
115
290
|
// has already shrunk, and the reader would read as having left the end. So
|
|
@@ -122,6 +297,18 @@ function notePosition(el) {
|
|
|
122
297
|
pinnedScrollTop = el.scrollTop;
|
|
123
298
|
}
|
|
124
299
|
|
|
300
|
+
// The content changed without a scroll event. A reader who followed the end
|
|
301
|
+
// and is away from it now was not followed (rows landed while they held a
|
|
302
|
+
// selection, a card grew): a later resize must not take them to the end.
|
|
303
|
+
function notePositionAfterContentChange(el) {
|
|
304
|
+
const followed = pinned;
|
|
305
|
+
notePosition(el);
|
|
306
|
+
if (!followed || pinned) return;
|
|
307
|
+
for (const listener of endLeftListeners) {
|
|
308
|
+
try { listener(); } catch {}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
125
312
|
function endJump() {
|
|
126
313
|
if (!jump) return;
|
|
127
314
|
jump.cleanup();
|
|
@@ -166,26 +353,33 @@ function prefersReducedMotion() {
|
|
|
166
353
|
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
|
|
167
354
|
}
|
|
168
355
|
|
|
356
|
+
/**
|
|
357
|
+
* Takes the reader to the end. From a window that ends above the
|
|
358
|
+
* conversation's end, the end is loaded first: the promise settles once it
|
|
359
|
+
* is on the page (or the load failed), which an own send from such a window
|
|
360
|
+
* waits for.
|
|
361
|
+
*/
|
|
169
362
|
export function jumpTranscriptToBottom() {
|
|
170
363
|
const el = scroller();
|
|
171
|
-
if (!el) return;
|
|
172
|
-
// The end wins over a
|
|
364
|
+
if (!el) return Promise.resolve();
|
|
365
|
+
// The end wins over a place held while images load, as over a followed
|
|
366
|
+
// section of the live bubble.
|
|
367
|
+
releaseReadingAnchorHold();
|
|
173
368
|
if (getThinkingFollowMode()) {
|
|
174
369
|
disarmThinkingFollow();
|
|
175
370
|
syncThinkingFollowButtons(document, null);
|
|
176
371
|
}
|
|
177
372
|
state = null;
|
|
178
|
-
render(
|
|
373
|
+
render(null);
|
|
179
374
|
// A window in the middle of the history (a search result, a pin) ends
|
|
180
375
|
// above the conversation's end: the end is loaded instead.
|
|
181
376
|
if (loadConversationEnd && !isConversationWindowAtTail()) {
|
|
182
377
|
endJump();
|
|
183
378
|
jump = { cleanup() {} };
|
|
184
|
-
|
|
379
|
+
return Promise.resolve()
|
|
185
380
|
.then(() => loadConversationEnd())
|
|
186
381
|
.catch(() => {})
|
|
187
382
|
.finally(finishJump);
|
|
188
|
-
return;
|
|
189
383
|
}
|
|
190
384
|
const behavior = jumpScrollBehavior({
|
|
191
385
|
distance: distanceFromBottom(el),
|
|
@@ -194,16 +388,22 @@ export function jumpTranscriptToBottom() {
|
|
|
194
388
|
});
|
|
195
389
|
if (behavior === 'smooth') startSmoothJump(el);
|
|
196
390
|
else finishJump();
|
|
391
|
+
return Promise.resolve();
|
|
197
392
|
}
|
|
198
393
|
|
|
199
394
|
function handleScroll() {
|
|
200
395
|
const el = scroller();
|
|
201
396
|
if (!el) return;
|
|
397
|
+
const movedUp = el.scrollTop < lastScrollTop - 1;
|
|
398
|
+
lastScrollTop = el.scrollTop;
|
|
202
399
|
if (jump) {
|
|
203
400
|
if (distanceFromBottom(el) <= 1) finishJump();
|
|
204
401
|
return;
|
|
205
402
|
}
|
|
206
403
|
notePosition(el);
|
|
404
|
+
// `pinned` is "at the end" as of this scroll. A clamp (the page got
|
|
405
|
+
// shorter) also takes scrollTop down, and ends at the end.
|
|
406
|
+
if (movedUp && !pinned) readerMoveEpoch += 1;
|
|
207
407
|
scheduleUpdate();
|
|
208
408
|
}
|
|
209
409
|
|
|
@@ -267,6 +467,7 @@ export function initTranscriptJumpToBottom({ loadEnd } = {}) {
|
|
|
267
467
|
const button = document.getElementById('jump-to-bottom-btn');
|
|
268
468
|
if (!el || !button) return;
|
|
269
469
|
initialized = true;
|
|
470
|
+
lastScrollTop = el.scrollTop;
|
|
270
471
|
pinnedScrollTop = el.scrollTop;
|
|
271
472
|
observedClientHeight = el.clientHeight;
|
|
272
473
|
button.addEventListener('click', (event) => {
|
|
@@ -280,8 +481,11 @@ export function initTranscriptJumpToBottom({ loadEnd } = {}) {
|
|
|
280
481
|
window.visualViewport?.addEventListener?.('resize', noteViewport, { passive: true });
|
|
281
482
|
window.addEventListener('resize', noteViewport, { passive: true });
|
|
282
483
|
el.addEventListener('scroll', handleScroll, { passive: true });
|
|
484
|
+
// The whole subtree: the button shows at any distance once something new
|
|
485
|
+
// is below, so it also has to go when the end comes back to the reader
|
|
486
|
+
// without a scroll (the live bubble folding its thoughts, a row shrinking).
|
|
283
487
|
if (typeof MutationObserver === 'function') {
|
|
284
|
-
new MutationObserver(scheduleUpdate).observe(el, { childList: true });
|
|
488
|
+
new MutationObserver(scheduleUpdate).observe(el, { childList: true, subtree: true, characterData: true });
|
|
285
489
|
}
|
|
286
490
|
if (typeof ResizeObserver === 'function') {
|
|
287
491
|
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,105 @@ 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
|
-
|
|
65
|
-
|
|
71
|
+
export function nextJumpButtonState(previous, {
|
|
72
|
+
distance,
|
|
73
|
+
clientHeight,
|
|
74
|
+
nearBottomPx,
|
|
75
|
+
rowKeys = [],
|
|
76
|
+
unseen = false,
|
|
77
|
+
question = false,
|
|
78
|
+
windowAtTail = true,
|
|
79
|
+
waiting = 0,
|
|
80
|
+
} = {}) {
|
|
81
|
+
// Live rows held back for a window that ends above the conversation's end
|
|
82
|
+
// (transcript-island.mjs): the bottom of the page is not the end, so the
|
|
83
|
+
// button stays while they wait.
|
|
84
|
+
const waitingNow = windowAtTail ? 0 : Math.max(0, Math.trunc(Number(waiting) || 0));
|
|
85
|
+
if (isNearBottom(distance, nearBottomPx) && !waitingNow) {
|
|
86
|
+
return { visible: false, baseline: null, count: 0, unseen: false, question: false, waiting: 0 };
|
|
87
|
+
}
|
|
88
|
+
// Below such a window the rows put on the page are the newer pages, older
|
|
89
|
+
// than anything live: they are not counted, and the baseline is taken once
|
|
90
|
+
// the window meets the end.
|
|
91
|
+
const baseline = !windowAtTail ? null : (Array.isArray(previous?.baseline) ? previous.baseline : rowKeys.slice());
|
|
66
92
|
const screens = Number(clientHeight) > 0 ? Number(distance) / Number(clientHeight) : 0;
|
|
93
|
+
const count = windowAtTail ? countNewRows(baseline, rowKeys) : 0;
|
|
94
|
+
const unseenNow = previous?.unseen === true || unseen === true;
|
|
95
|
+
const questionNow = question === true;
|
|
67
96
|
return {
|
|
68
|
-
visible: previous?.visible === true || screens >= JUMP_BUTTON_SHOW_SCREENS,
|
|
97
|
+
visible: previous?.visible === true || screens >= JUMP_BUTTON_SHOW_SCREENS || count > 0 || waitingNow > 0 || unseenNow || questionNow,
|
|
69
98
|
baseline,
|
|
70
|
-
count
|
|
99
|
+
count,
|
|
100
|
+
unseen: unseenNow,
|
|
101
|
+
question: questionNow,
|
|
102
|
+
waiting: waitingNow,
|
|
71
103
|
};
|
|
72
104
|
}
|
|
73
105
|
|
|
106
|
+
export const JUMP_BUTTON_LABEL = 'Jump to the latest message';
|
|
107
|
+
export const JUMP_BUTTON_QUESTION_LABEL = 'A question is waiting for your answer. Jump to it';
|
|
108
|
+
export const JUMP_QUESTION_BADGE = '?';
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* How the rows held back for a window above the end show on the badge:
|
|
112
|
+
* 'count' adds them to the count, 'dot' draws the dot of news without a row.
|
|
113
|
+
*/
|
|
114
|
+
export const HELD_BACK_ROWS_BADGE = 'count';
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* How a state looks and reads. `state` is 'plain', 'new' or 'question'; a
|
|
118
|
+
* waiting question wins over news. `badge` is the count, '?' for a question,
|
|
119
|
+
* or empty: with `dot`, an empty badge is drawn as a dot (news without a row
|
|
120
|
+
* to count). `waiting` rows are held back below the page
|
|
121
|
+
* (HELD_BACK_ROWS_BADGE). `announcement` is what a screen reader is told
|
|
122
|
+
* when the state is entered; the label alone is only read when the button is
|
|
123
|
+
* focused.
|
|
124
|
+
*/
|
|
125
|
+
export function jumpButtonPresentation({ visible = false, count = 0, unseen = false, question = false, waiting = 0 } = {}, { heldBackRowsBadge = HELD_BACK_ROWS_BADGE } = {}) {
|
|
126
|
+
if (!visible) return { state: 'plain', label: JUMP_BUTTON_LABEL, badge: '', dot: false, announcement: '' };
|
|
127
|
+
if (question) {
|
|
128
|
+
return {
|
|
129
|
+
state: 'question',
|
|
130
|
+
label: JUMP_BUTTON_QUESTION_LABEL,
|
|
131
|
+
badge: JUMP_QUESTION_BADGE,
|
|
132
|
+
dot: false,
|
|
133
|
+
announcement: 'A question is waiting for your answer below.',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const held = Math.max(0, Math.trunc(Number(waiting) || 0));
|
|
137
|
+
const counted = Math.max(0, Math.trunc(Number(count) || 0)) + (heldBackRowsBadge === 'count' ? held : 0);
|
|
138
|
+
const badge = jumpBadgeText(counted);
|
|
139
|
+
if (badge) {
|
|
140
|
+
return {
|
|
141
|
+
state: 'new',
|
|
142
|
+
label: `${JUMP_BUTTON_LABEL} (${counted} new)`,
|
|
143
|
+
badge,
|
|
144
|
+
dot: false,
|
|
145
|
+
announcement: 'New messages below.',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (unseen || held > 0) {
|
|
149
|
+
return {
|
|
150
|
+
state: 'new',
|
|
151
|
+
label: `${JUMP_BUTTON_LABEL} (new below)`,
|
|
152
|
+
badge: '',
|
|
153
|
+
dot: true,
|
|
154
|
+
announcement: 'New messages below.',
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return { state: 'plain', label: JUMP_BUTTON_LABEL, badge: '', dot: false, announcement: '' };
|
|
158
|
+
}
|
|
159
|
+
|
|
74
160
|
/** 'smooth' up to JUMP_SMOOTH_MAX_SCREENS above the end, else 'instant'. */
|
|
75
161
|
export function jumpScrollBehavior({ distance, clientHeight, reducedMotion = false } = {}) {
|
|
76
162
|
if (reducedMotion) return 'instant';
|
|
@@ -88,12 +174,74 @@ export function jumpBadgeText(count) {
|
|
|
88
174
|
/**
|
|
89
175
|
* Whether a resize of the transcript (the composer or the background-task
|
|
90
176
|
* 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
|
-
*
|
|
177
|
+
* below keeps scrollTop and fires no scroll event, so the reader is still
|
|
178
|
+
* described by the last note of their position: `pinned` (they followed the
|
|
179
|
+
* end, by a scroll event or when the content last changed) and the scrollTop
|
|
180
|
+
* it was taken at. While scrollTop is that one, the note alone decides: a
|
|
181
|
+
* reader who was not at the end is never taken there by a resize. Once
|
|
182
|
+
* scrollTop has moved since (a restore, a clamp, the browser's anchoring),
|
|
183
|
+
* the note is stale and the geometry as it is now decides.
|
|
95
184
|
*/
|
|
96
185
|
export function shouldRepinOnResize({ pinned, pinnedScrollTop, scrollTop, nearBottomNow }) {
|
|
97
|
-
if (
|
|
186
|
+
if (Number(scrollTop) === Number(pinnedScrollTop)) return pinned === true;
|
|
98
187
|
return nearBottomNow === true;
|
|
99
188
|
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Whether a live update follows the end. A reader at the end now is
|
|
192
|
+
* followed. So is one the re-pin above is on its way to: the transcript
|
|
193
|
+
* shrank from below (`clientHeight` under the height the last
|
|
194
|
+
* ResizeObserver round saw) and at that height the reader is at the end.
|
|
195
|
+
* An update that lands between the shrink and that round would otherwise
|
|
196
|
+
* find them the panel's height above the end, stay put, and leave them
|
|
197
|
+
* there. The geometry decides, not the last note of the position: a scroll
|
|
198
|
+
* to the end (the update before) is noted only by its scroll event a frame
|
|
199
|
+
* later, and the shrink and this update can both land before it.
|
|
200
|
+
*/
|
|
201
|
+
export function followsEndThroughResize({ nearBottomNow, clientHeight, observedClientHeight, distanceAtObservedHeight, nearBottomPxAtObservedHeight }) {
|
|
202
|
+
if (nearBottomNow === true) return true;
|
|
203
|
+
if (!(Number(clientHeight) < Number(observedClientHeight))) return false;
|
|
204
|
+
return isNearBottom(distanceAtObservedHeight, nearBottomPxAtObservedHeight);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// "Jump to the end when I send a message" (Settings › General). A setting of
|
|
208
|
+
// this browser, kept in localStorage like the theme. On unless switched off:
|
|
209
|
+
// with it off, the reader's own send follows the same rule as everything
|
|
210
|
+
// else and only scrolls a transcript that is at its end already. The
|
|
211
|
+
// document and the storage are passed in by the tests.
|
|
212
|
+
|
|
213
|
+
export const SCROLL_ON_SEND_STORAGE_KEY = 'copilot_scroll_on_send';
|
|
214
|
+
export const SCROLL_ON_SEND_TOGGLE_ID = 'scroll-on-send-toggle';
|
|
215
|
+
|
|
216
|
+
/** Anything but an explicit off is on: a missing or damaged value included. */
|
|
217
|
+
export function parseScrollOnSendSetting(stored) {
|
|
218
|
+
const value = String(stored ?? '').trim().toLowerCase();
|
|
219
|
+
if (!value) return true;
|
|
220
|
+
return value !== '0' && value !== 'false';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function serializeScrollOnSendSetting(enabled) {
|
|
224
|
+
return enabled ? '1' : '0';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function storage() {
|
|
228
|
+
try { return globalThis.localStorage || null; } catch { return null; }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function isScrollOnSendEnabled(store = storage()) {
|
|
232
|
+
try {
|
|
233
|
+
return parseScrollOnSendSetting(store?.getItem(SCROLL_ON_SEND_STORAGE_KEY));
|
|
234
|
+
} catch {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function syncScrollOnSendToggle(doc = globalThis.document, store = storage()) {
|
|
240
|
+
const checkbox = doc?.getElementById?.(SCROLL_ON_SEND_TOGGLE_ID);
|
|
241
|
+
if (checkbox) checkbox.checked = isScrollOnSendEnabled(store);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function updateScrollOnSendSetting(enabled, { doc = globalThis.document, store = storage() } = {}) {
|
|
245
|
+
try { store?.setItem(SCROLL_ON_SEND_STORAGE_KEY, serializeScrollOnSendSetting(!!enabled)); } catch {}
|
|
246
|
+
syncScrollOnSendToggle(doc, store);
|
|
247
|
+
}
|