@oar-sh/oar 0.9.9 → 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 +561 -1
- package/README.md +162 -909
- package/bin/bat/oar.bat +46 -46
- package/package.json +14 -14
- package/server/README.md +369 -13
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +121 -9
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +95 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -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/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 +187 -0
- package/server/public/app/api-client.js +312 -16
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +17 -12
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +336 -59
- package/server/public/app/chat-actions-menu-state.mjs +126 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -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-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +436 -117
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +13 -4
- package/server/public/app/journal-view.js +930 -45
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +19 -3
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +93 -9
- 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 +137 -40
- package/server/public/app/store.js +115 -32
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +558 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
- package/server/public/app/transcript-jump-bottom.mjs +210 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +774 -12
- 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 +30 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- 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/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +698 -114
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +843 -193
- package/server/server-runtime.mjs +559 -160
- 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/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -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 +49 -27
- 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/push-dispatch-service.mjs +18 -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-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1952 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +551 -7
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +695 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- 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/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/upgrade-socket-guard.mjs +16 -0
- 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/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +160 -6
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -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
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
// The transcript's jump-to-bottom button, and the re-pin that keeps a reader
|
|
2
|
+
// who follows the end at the end when the transcript resizes under them.
|
|
3
|
+
// Decisions live in transcript-jump-bottom.mjs; this module reads the
|
|
4
|
+
// scroller and the rows and applies them.
|
|
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
|
+
//
|
|
13
|
+
// Following the end has no state of its own: every live update asks
|
|
14
|
+
// followsTranscriptEnd() before it appends and scrolls. When the composer grows
|
|
15
|
+
// (the background-task panel appears or opens, a banner, a taller draft) the
|
|
16
|
+
// transcript shrinks from below, scrollTop stays and no scroll event fires,
|
|
17
|
+
// so the reader is left that many pixels above the end and the next update
|
|
18
|
+
// stops following. The ResizeObserver here puts them back before paint.
|
|
19
|
+
|
|
20
|
+
import { currentConvId, isMessagesNearBottom, scrollBottom } from './store.js';
|
|
21
|
+
import { isConversationWindowAtTail } from './conversation-view.js';
|
|
22
|
+
import { disarmThinkingFollow, getThinkingFollowMode, syncThinkingFollowButtons } from './thinking-follow.js';
|
|
23
|
+
import { resolveNearBottomThresholdPx } from './transcript-viewport-anchor.mjs';
|
|
24
|
+
import {
|
|
25
|
+
createViewportBaseline,
|
|
26
|
+
isOnScreenKeyboardOpen,
|
|
27
|
+
shouldHoldFocusOnJump,
|
|
28
|
+
} from './composer-keyboard.mjs';
|
|
29
|
+
import {
|
|
30
|
+
isScrollOnSendEnabled,
|
|
31
|
+
isNearBottom,
|
|
32
|
+
jumpButtonPresentation,
|
|
33
|
+
jumpScrollBehavior,
|
|
34
|
+
nextJumpButtonState,
|
|
35
|
+
shouldRepinOnResize,
|
|
36
|
+
transcriptRowKey,
|
|
37
|
+
} from './transcript-jump-bottom.mjs';
|
|
38
|
+
|
|
39
|
+
// The final snap of a smooth jump where the browser has no scrollend event.
|
|
40
|
+
const SMOOTH_JUMP_FALLBACK_MS = 900;
|
|
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';
|
|
45
|
+
|
|
46
|
+
let initialized = false;
|
|
47
|
+
let state = null;
|
|
48
|
+
let stateConversationId = '';
|
|
49
|
+
// The reader followed the end as of the last scroll event, at this scrollTop.
|
|
50
|
+
let pinned = true;
|
|
51
|
+
let pinnedScrollTop = 0;
|
|
52
|
+
// The transcript's height as of the last ResizeObserver round.
|
|
53
|
+
let observedClientHeight = null;
|
|
54
|
+
let jump = null;
|
|
55
|
+
let updateScheduled = false;
|
|
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();
|
|
66
|
+
|
|
67
|
+
function scroller() {
|
|
68
|
+
return document.getElementById('messages');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function distanceFromBottom(el) {
|
|
72
|
+
return Math.max(0, el.scrollHeight - el.clientHeight - el.scrollTop);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function rowKeys(el) {
|
|
76
|
+
const keys = [];
|
|
77
|
+
for (const node of el.children) {
|
|
78
|
+
if (!node.classList?.contains('msg')) continue;
|
|
79
|
+
const key = transcriptRowKey({
|
|
80
|
+
messageId: node.dataset.messageId,
|
|
81
|
+
live: node.id === 'thinking-indicator',
|
|
82
|
+
});
|
|
83
|
+
if (key) keys.push(key);
|
|
84
|
+
}
|
|
85
|
+
return keys;
|
|
86
|
+
}
|
|
87
|
+
|
|
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) {
|
|
112
|
+
const button = document.getElementById('jump-to-bottom-btn');
|
|
113
|
+
if (!button) return;
|
|
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;
|
|
117
|
+
const badge = button.querySelector('.jump-to-bottom-badge');
|
|
118
|
+
if (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;
|
|
122
|
+
}
|
|
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;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function syncStateConversation() {
|
|
132
|
+
const conversationId = String(currentConvId || '').trim();
|
|
133
|
+
if (conversationId !== stateConversationId) {
|
|
134
|
+
state = null;
|
|
135
|
+
stateConversationId = conversationId;
|
|
136
|
+
}
|
|
137
|
+
return conversationId;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function update() {
|
|
141
|
+
updateScheduled = false;
|
|
142
|
+
const el = scroller();
|
|
143
|
+
if (!el) return;
|
|
144
|
+
const conversationId = syncStateConversation();
|
|
145
|
+
if (!conversationId || jump) {
|
|
146
|
+
render(null);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
notePositionAfterContentChange(el);
|
|
150
|
+
const distance = distanceFromBottom(el);
|
|
151
|
+
const nearBottomPx = resolveNearBottomThresholdPx(el.clientHeight);
|
|
152
|
+
const away = !isNearBottom(distance, nearBottomPx);
|
|
153
|
+
state = nextJumpButtonState(state, {
|
|
154
|
+
distance,
|
|
155
|
+
clientHeight: el.clientHeight,
|
|
156
|
+
nearBottomPx,
|
|
157
|
+
rowKeys: away ? rowKeys(el) : [],
|
|
158
|
+
question: away && questionWaitingBelow(el),
|
|
159
|
+
});
|
|
160
|
+
render(state);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function scheduleUpdate() {
|
|
164
|
+
if (updateScheduled) return;
|
|
165
|
+
updateScheduled = true;
|
|
166
|
+
requestAnimationFrame(update);
|
|
167
|
+
}
|
|
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
|
+
|
|
266
|
+
// A scroll event is dispatched a frame after the scroll, before that frame's
|
|
267
|
+
// ResizeObserver round: when the composer grew in between, the transcript
|
|
268
|
+
// has already shrunk, and the reader would read as having left the end. So
|
|
269
|
+
// the position is judged at the height the observer last saw; a resize it
|
|
270
|
+
// has not handled yet is its own to judge.
|
|
271
|
+
function notePosition(el) {
|
|
272
|
+
const height = observedClientHeight ?? el.clientHeight;
|
|
273
|
+
const distance = Math.max(0, el.scrollHeight - height - el.scrollTop);
|
|
274
|
+
pinned = isNearBottom(distance, resolveNearBottomThresholdPx(height));
|
|
275
|
+
pinnedScrollTop = el.scrollTop;
|
|
276
|
+
}
|
|
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
|
+
|
|
290
|
+
function endJump() {
|
|
291
|
+
if (!jump) return;
|
|
292
|
+
jump.cleanup();
|
|
293
|
+
jump = null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Rows that landed during the animation were not followed (the reader was
|
|
297
|
+
// not at the end yet): the end is wherever it is now.
|
|
298
|
+
function finishJump() {
|
|
299
|
+
endJump();
|
|
300
|
+
const el = scroller();
|
|
301
|
+
scrollBottom();
|
|
302
|
+
if (el) notePosition(el);
|
|
303
|
+
state = null;
|
|
304
|
+
scheduleUpdate();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function startSmoothJump(el) {
|
|
308
|
+
endJump();
|
|
309
|
+
// A wheel or a touch during the animation is the reader taking over.
|
|
310
|
+
const takeOver = () => {
|
|
311
|
+
endJump();
|
|
312
|
+
notePosition(el);
|
|
313
|
+
scheduleUpdate();
|
|
314
|
+
};
|
|
315
|
+
const timer = setTimeout(finishJump, SMOOTH_JUMP_FALLBACK_MS);
|
|
316
|
+
el.addEventListener('scrollend', finishJump);
|
|
317
|
+
el.addEventListener('wheel', takeOver, { passive: true });
|
|
318
|
+
el.addEventListener('touchstart', takeOver, { passive: true });
|
|
319
|
+
jump = {
|
|
320
|
+
cleanup() {
|
|
321
|
+
clearTimeout(timer);
|
|
322
|
+
el.removeEventListener('scrollend', finishJump);
|
|
323
|
+
el.removeEventListener('wheel', takeOver);
|
|
324
|
+
el.removeEventListener('touchstart', takeOver);
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function prefersReducedMotion() {
|
|
331
|
+
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function jumpTranscriptToBottom() {
|
|
335
|
+
const el = scroller();
|
|
336
|
+
if (!el) return;
|
|
337
|
+
// The end wins over a followed section of the live bubble.
|
|
338
|
+
if (getThinkingFollowMode()) {
|
|
339
|
+
disarmThinkingFollow();
|
|
340
|
+
syncThinkingFollowButtons(document, null);
|
|
341
|
+
}
|
|
342
|
+
state = null;
|
|
343
|
+
render(null);
|
|
344
|
+
// A window in the middle of the history (a search result, a pin) ends
|
|
345
|
+
// above the conversation's end: the end is loaded instead.
|
|
346
|
+
if (loadConversationEnd && !isConversationWindowAtTail()) {
|
|
347
|
+
endJump();
|
|
348
|
+
jump = { cleanup() {} };
|
|
349
|
+
void Promise.resolve()
|
|
350
|
+
.then(() => loadConversationEnd())
|
|
351
|
+
.catch(() => {})
|
|
352
|
+
.finally(finishJump);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const behavior = jumpScrollBehavior({
|
|
356
|
+
distance: distanceFromBottom(el),
|
|
357
|
+
clientHeight: el.clientHeight,
|
|
358
|
+
reducedMotion: prefersReducedMotion(),
|
|
359
|
+
});
|
|
360
|
+
if (behavior === 'smooth') startSmoothJump(el);
|
|
361
|
+
else finishJump();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function handleScroll() {
|
|
365
|
+
const el = scroller();
|
|
366
|
+
if (!el) return;
|
|
367
|
+
const movedUp = el.scrollTop < lastScrollTop - 1;
|
|
368
|
+
lastScrollTop = el.scrollTop;
|
|
369
|
+
if (jump) {
|
|
370
|
+
if (distanceFromBottom(el) <= 1) finishJump();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
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;
|
|
377
|
+
scheduleUpdate();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function handleResize() {
|
|
381
|
+
const el = scroller();
|
|
382
|
+
if (!el) return;
|
|
383
|
+
// A followed section of the live bubble pins itself (thinking-follow.js).
|
|
384
|
+
if (!jump && !getThinkingFollowMode() && distanceFromBottom(el) > 0 && shouldRepinOnResize({
|
|
385
|
+
pinned,
|
|
386
|
+
pinnedScrollTop,
|
|
387
|
+
scrollTop: el.scrollTop,
|
|
388
|
+
nearBottomNow: isMessagesNearBottom(),
|
|
389
|
+
})) {
|
|
390
|
+
scrollBottom();
|
|
391
|
+
}
|
|
392
|
+
observedClientHeight = el.clientHeight;
|
|
393
|
+
notePosition(el);
|
|
394
|
+
scheduleUpdate();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const viewportBaseline = createViewportBaseline();
|
|
398
|
+
|
|
399
|
+
function noteViewport() {
|
|
400
|
+
const height = window.visualViewport?.height || window.innerHeight || 0;
|
|
401
|
+
return { height, tallest: viewportBaseline.note(window.innerWidth || 0, height) };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function isEditableElement(node) {
|
|
405
|
+
return !!node && (node.tagName === 'TEXTAREA' || node.tagName === 'INPUT' || node.isContentEditable === true);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// A tap that keeps a focused field focused raises the phone's keyboard even
|
|
409
|
+
// when none was showing (the composer can be focused with the keyboard
|
|
410
|
+
// down). So the button holds the focus for a mouse, or while the keyboard is
|
|
411
|
+
// open; otherwise the field lets it go.
|
|
412
|
+
function handleButtonPointerDown(event) {
|
|
413
|
+
const active = document.activeElement;
|
|
414
|
+
const { height, tallest } = noteViewport();
|
|
415
|
+
const keyboardOpen = isOnScreenKeyboardOpen({
|
|
416
|
+
editableFocused: isEditableElement(active),
|
|
417
|
+
visibleHeight: height,
|
|
418
|
+
tallestHeight: tallest,
|
|
419
|
+
});
|
|
420
|
+
if (shouldHoldFocusOnJump({ pointerType: event.pointerType, keyboardOpen })) {
|
|
421
|
+
event.preventDefault();
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (isEditableElement(active)) {
|
|
425
|
+
try { active.blur(); } catch {}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* `loadEnd` loads the conversation's end in place of a history window; the
|
|
431
|
+
* button calls it when the page holds a window in the middle of the history.
|
|
432
|
+
*/
|
|
433
|
+
export function initTranscriptJumpToBottom({ loadEnd } = {}) {
|
|
434
|
+
if (typeof loadEnd === 'function') loadConversationEnd = loadEnd;
|
|
435
|
+
if (initialized) return;
|
|
436
|
+
const el = scroller();
|
|
437
|
+
const button = document.getElementById('jump-to-bottom-btn');
|
|
438
|
+
if (!el || !button) return;
|
|
439
|
+
initialized = true;
|
|
440
|
+
lastScrollTop = el.scrollTop;
|
|
441
|
+
pinnedScrollTop = el.scrollTop;
|
|
442
|
+
observedClientHeight = el.clientHeight;
|
|
443
|
+
button.addEventListener('click', (event) => {
|
|
444
|
+
event.preventDefault();
|
|
445
|
+
jumpTranscriptToBottom();
|
|
446
|
+
});
|
|
447
|
+
// With the keyboard open, a tap must not take the composer's focus (and
|
|
448
|
+
// with it the keyboard) away; with it closed, it must not raise it.
|
|
449
|
+
button.addEventListener('pointerdown', handleButtonPointerDown);
|
|
450
|
+
noteViewport();
|
|
451
|
+
window.visualViewport?.addEventListener?.('resize', noteViewport, { passive: true });
|
|
452
|
+
window.addEventListener('resize', noteViewport, { passive: true });
|
|
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).
|
|
457
|
+
if (typeof MutationObserver === 'function') {
|
|
458
|
+
new MutationObserver(scheduleUpdate).observe(el, { childList: true, subtree: true, characterData: true });
|
|
459
|
+
}
|
|
460
|
+
if (typeof ResizeObserver === 'function') {
|
|
461
|
+
new ResizeObserver(handleResize).observe(el);
|
|
462
|
+
}
|
|
463
|
+
scheduleUpdate();
|
|
464
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// The transcript's jump-to-bottom button, without the DOM: when it shows,
|
|
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.
|
|
6
|
+
// transcript-jump-bottom-view.mjs reads the scroller and the rows and applies
|
|
7
|
+
// what this module decides.
|
|
8
|
+
//
|
|
9
|
+
// "At the bottom" is the composer's near-bottom rule (isMessagesNearBottom,
|
|
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.
|
|
14
|
+
|
|
15
|
+
/** The button fades in once the reader is this many screens above the end. */
|
|
16
|
+
export const JUMP_BUTTON_SHOW_SCREENS = 1;
|
|
17
|
+
/** Further than this many screens a click jumps at once instead of scrolling. */
|
|
18
|
+
export const JUMP_SMOOTH_MAX_SCREENS = 3;
|
|
19
|
+
export const JUMP_BADGE_MAX = 99;
|
|
20
|
+
|
|
21
|
+
/**
|
|
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.
|
|
25
|
+
*/
|
|
26
|
+
export function transcriptRowKey({ messageId = '', live = false } = {}) {
|
|
27
|
+
const id = String(messageId || '').trim();
|
|
28
|
+
if (!id) return '';
|
|
29
|
+
return live ? `live:${id}` : `msg:${id}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Rows that arrived below the reader since `baselineKeys` was taken: keys
|
|
34
|
+
* after the last row the baseline knows, not in the baseline, each once.
|
|
35
|
+
* Older history paged in above, or a row re-rendered under a new node, is not
|
|
36
|
+
* news. A transcript sharing no row with the baseline is another window (a
|
|
37
|
+
* reload, a jump into the history): nothing is counted there.
|
|
38
|
+
*/
|
|
39
|
+
export function countNewRows(baselineKeys, currentKeys) {
|
|
40
|
+
const baseline = new Set((baselineKeys || []).filter(Boolean));
|
|
41
|
+
const current = (currentKeys || []).filter(Boolean);
|
|
42
|
+
let lastKnown = -1;
|
|
43
|
+
current.forEach((key, index) => {
|
|
44
|
+
if (baseline.has(key)) lastKnown = index;
|
|
45
|
+
});
|
|
46
|
+
if (lastKnown < 0 && baseline.size) return 0;
|
|
47
|
+
const fresh = new Set();
|
|
48
|
+
for (const key of current.slice(lastKnown + 1)) {
|
|
49
|
+
if (!baseline.has(key)) fresh.add(key);
|
|
50
|
+
}
|
|
51
|
+
return fresh.size;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isNearBottom(distance, nearBottomPx) {
|
|
55
|
+
return Math.max(0, Number(distance) || 0) <= Math.max(0, Number(nearBottomPx) || 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The button's next state from the scroller's geometry. At the bottom it
|
|
60
|
+
* hides and forgets the baseline. Away from it, the rows on the page when the
|
|
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).
|
|
70
|
+
*/
|
|
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
|
+
}
|
|
82
|
+
const baseline = Array.isArray(previous?.baseline) ? previous.baseline : rowKeys.slice();
|
|
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;
|
|
87
|
+
return {
|
|
88
|
+
visible: previous?.visible === true || screens >= JUMP_BUTTON_SHOW_SCREENS || count > 0 || unseenNow || questionNow,
|
|
89
|
+
baseline,
|
|
90
|
+
count,
|
|
91
|
+
unseen: unseenNow,
|
|
92
|
+
question: questionNow,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
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
|
+
|
|
140
|
+
/** 'smooth' up to JUMP_SMOOTH_MAX_SCREENS above the end, else 'instant'. */
|
|
141
|
+
export function jumpScrollBehavior({ distance, clientHeight, reducedMotion = false } = {}) {
|
|
142
|
+
if (reducedMotion) return 'instant';
|
|
143
|
+
const height = Number(clientHeight);
|
|
144
|
+
if (!(height > 0)) return 'instant';
|
|
145
|
+
return Number(distance) / height <= JUMP_SMOOTH_MAX_SCREENS ? 'smooth' : 'instant';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function jumpBadgeText(count) {
|
|
149
|
+
const value = Math.max(0, Math.trunc(Number(count) || 0));
|
|
150
|
+
if (!value) return '';
|
|
151
|
+
return value > JUMP_BADGE_MAX ? `${JUMP_BADGE_MAX}+` : String(value);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Whether a resize of the transcript (the composer or the background-task
|
|
156
|
+
* panel growing under it) re-pins it to the end. The scroller shrinking from
|
|
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.
|
|
164
|
+
*/
|
|
165
|
+
export function shouldRepinOnResize({ pinned, pinnedScrollTop, scrollTop, nearBottomNow }) {
|
|
166
|
+
if (Number(scrollTop) === Number(pinnedScrollTop)) return pinned === true;
|
|
167
|
+
return nearBottomNow === true;
|
|
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
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// A reload of the transcript rebuilds it from what its request returned. A
|
|
2
|
+
// row the socket put on the page while that request was on its way can be
|
|
3
|
+
// missing from the answer (the relay stored it after the request read the
|
|
4
|
+
// conversation), and the rebuild would take it off the page again until the
|
|
5
|
+
// next reload. The socket's rows are counted (getTranscriptLiveRowEpoch in
|
|
6
|
+
// conversation-view.js); when the count moved during the request, the
|
|
7
|
+
// request is made again, now that the row is stored.
|
|
8
|
+
|
|
9
|
+
/** Requests before a reload gives up on an answer that keeps going stale. */
|
|
10
|
+
export const LIVE_ROW_RELOAD_ATTEMPTS = 3;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `stale` is true when rows still arrived during the last attempt: the page
|
|
14
|
+
* holds rows the answer lacks, so the caller leaves the transcript alone and
|
|
15
|
+
* the next reload asks again.
|
|
16
|
+
*/
|
|
17
|
+
export async function loadKeepingLiveRows(load, getLiveRowEpoch, { attempts = LIVE_ROW_RELOAD_ATTEMPTS } = {}) {
|
|
18
|
+
let response = null;
|
|
19
|
+
for (let attempt = 1; attempt <= Math.max(1, attempts); attempt += 1) {
|
|
20
|
+
const epoch = getLiveRowEpoch();
|
|
21
|
+
response = await load();
|
|
22
|
+
if (!response || getLiveRowEpoch() === epoch) return { response, stale: false };
|
|
23
|
+
}
|
|
24
|
+
return { response, stale: true };
|
|
25
|
+
}
|