@oar-sh/oar 0.9.11 → 0.9.13
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 +332 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +201 -12
- 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 +179 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +152 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -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 +19 -0
- package/server/grok-worker/acp-host-services.mjs +48 -6
- package/server/grok-worker/grok-background-tasks.mjs +248 -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/0017-conversation-history-indexes.mjs +89 -0
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +70 -3
- package/server/public/app/background-tasks-view.mjs +334 -16
- package/server/public/app/bootstrap.js +102 -5
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +462 -8
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +259 -67
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +162 -2
- package/server/public/app/socket-handlers.js +59 -7
- package/server/public/app/store.js +25 -0
- package/server/public/app/subagent-viewer.mjs +7 -1
- package/server/public/app/task-console-render.mjs +401 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +730 -0
- package/server/public/app/task-output-text.mjs +243 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
- package/server/public/app/transcript-jump-bottom.mjs +68 -11
- 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 +20 -1
- package/server/public/index.html +306 -6
- package/server/relay-tools.md +6 -0
- package/server/repositories/message-repository.mjs +11 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +397 -23
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +208 -66
- package/server/server-runtime.mjs +269 -137
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +470 -6
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +9 -2
- package/server/services/relay-event-service.mjs +27 -9
- package/server/services/relay-prompt-sanitizer.mjs +18 -4
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +380 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +316 -20
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +761 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +47 -10
- package/shared/task-output-text.mjs +850 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Browser copy of the parts of shared/task-output-text.mjs the task console
|
|
2
|
+
// needs: the incremental line folder, the SGR colour segments (with the
|
|
3
|
+
// control stripping they use) and the stall minutes. The browser cannot
|
|
4
|
+
// import shared/ (only server/public is served), so these are hand-kept
|
|
5
|
+
// copies; task-output-text.test.mjs pins them to the original. Scrubbing and
|
|
6
|
+
// runner parsing stay on the relay: what reaches the page is already clean.
|
|
7
|
+
|
|
8
|
+
export const TASK_OUTPUT_LINE_CHARS_MAX = 4000;
|
|
9
|
+
|
|
10
|
+
function toMillis(value) {
|
|
11
|
+
if (value == null || value === '') return NaN;
|
|
12
|
+
const number = Number(value);
|
|
13
|
+
if (Number.isFinite(number)) return number;
|
|
14
|
+
return Date.parse(String(value));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Whole minutes since the last output, or null when unknown. */
|
|
18
|
+
export function stallMinutes(lastOutputAt, now = Date.now()) {
|
|
19
|
+
const last = toMillis(lastOutputAt);
|
|
20
|
+
const current = toMillis(now);
|
|
21
|
+
if (!Number.isFinite(last) || !Number.isFinite(current) || last <= 0) return null;
|
|
22
|
+
return Math.max(0, Math.floor((current - last) / 60_000));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// SGR (colours and text style) is the only escape kept. Its parameters are
|
|
26
|
+
// digits and semicolons; the colon form is rare and goes with the rest.
|
|
27
|
+
const SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
|
|
28
|
+
// OSC (titles, hyperlinks, clipboard): ESC ] … BEL | ST, stripped whole. An
|
|
29
|
+
// unterminated one at the end is stripped to the end.
|
|
30
|
+
const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g;
|
|
31
|
+
// Every CSI (cursor moves, erases, modes) that is not SGR.
|
|
32
|
+
const CSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
33
|
+
// Character-set designations (ESC ( B) and the remaining two-byte escapes.
|
|
34
|
+
const CHARSET_PATTERN = /\x1b[()*+\-./][0-~]/g;
|
|
35
|
+
// The remaining two-byte escapes (ESC 7, ESC =, ESC M, a CSI cut short) and
|
|
36
|
+
// a lone ESC; never the start of a kept SGR.
|
|
37
|
+
const OTHER_ESCAPE_PATTERN = /\x1b(?!\[[0-9;]*m)[0-~]?/g;
|
|
38
|
+
// C0 except \t \n \r, DEL, C1, and a lone ESC left over.
|
|
39
|
+
const CONTROL_PATTERN = /[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f-\x9f]/g;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Drops OSC, every CSI but SGR, the other escapes, C1 and C0 controls except
|
|
43
|
+
* `\n`, `\r` and `\t`; keeps `ESC [ … m`.
|
|
44
|
+
*/
|
|
45
|
+
export function stripControlKeepSgr(text) {
|
|
46
|
+
const value = String(text ?? '');
|
|
47
|
+
if (!/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f-\x9f]/.test(value)) return value;
|
|
48
|
+
return value
|
|
49
|
+
.replace(OSC_PATTERN, '')
|
|
50
|
+
.replace(CSI_PATTERN, (match) => (/^\x1b\[[0-9;]*m$/.test(match) ? match : ''))
|
|
51
|
+
.replace(CHARSET_PATTERN, '')
|
|
52
|
+
.replace(OTHER_ESCAPE_PATTERN, '')
|
|
53
|
+
.replace(CONTROL_PATTERN, '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Lines ───────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function cutLine(text) {
|
|
59
|
+
return text.length > TASK_OUTPUT_LINE_CHARS_MAX ? text.slice(0, TASK_OUTPUT_LINE_CHARS_MAX) : text;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A line's final state: a `\r` not followed by `\n` discards what came before
|
|
63
|
+
// it (a trailing one still waits for what follows).
|
|
64
|
+
function finalLineState(raw) {
|
|
65
|
+
const body = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
66
|
+
const at = body.lastIndexOf('\r');
|
|
67
|
+
return at === -1 ? body : body.slice(at + 1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Lines from a stream fed in arbitrary pieces. `feed(text)` returns
|
|
72
|
+
* `{ lines, partial, changedFrom }`: every completed line so far as
|
|
73
|
+
* `{ text, count }` (adjacent identical lines folded, `count` unbounded), the
|
|
74
|
+
* open partial line in its current state ('' when there is none), and the
|
|
75
|
+
* index of the first line this feed added or changed (a folded repeat bumps
|
|
76
|
+
* the count of the line before). `end()` closes the partial line and returns
|
|
77
|
+
* the same shape. `lines` is the folder's own array: copy it to keep it.
|
|
78
|
+
*/
|
|
79
|
+
export function createLineFolder() {
|
|
80
|
+
const lines = [];
|
|
81
|
+
let partial = '';
|
|
82
|
+
let changedFrom = 0;
|
|
83
|
+
|
|
84
|
+
function push(text) {
|
|
85
|
+
const line = cutLine(text);
|
|
86
|
+
const last = lines[lines.length - 1];
|
|
87
|
+
if (last && last.text === line) {
|
|
88
|
+
last.count += 1;
|
|
89
|
+
changedFrom = Math.min(changedFrom, lines.length - 1);
|
|
90
|
+
} else {
|
|
91
|
+
changedFrom = Math.min(changedFrom, lines.length);
|
|
92
|
+
lines.push({ text: line, count: 1 });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function result() {
|
|
97
|
+
const out = { lines, partial: finalLineState(partial), changedFrom };
|
|
98
|
+
changedFrom = lines.length;
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
feed(text) {
|
|
104
|
+
const chunk = String(text ?? '');
|
|
105
|
+
if (chunk) {
|
|
106
|
+
const parts = (partial + chunk).split('\n');
|
|
107
|
+
partial = parts.pop();
|
|
108
|
+
for (const raw of parts) push(finalLineState(raw));
|
|
109
|
+
// The open line stays small: a progress bar redraws itself
|
|
110
|
+
// thousands of times on one line.
|
|
111
|
+
const keepCr = partial.endsWith('\r');
|
|
112
|
+
partial = cutLine(finalLineState(partial)) + (keepCr ? '\r' : '');
|
|
113
|
+
}
|
|
114
|
+
return result();
|
|
115
|
+
},
|
|
116
|
+
end() {
|
|
117
|
+
const last = finalLineState(partial);
|
|
118
|
+
partial = '';
|
|
119
|
+
if (last) push(last);
|
|
120
|
+
return result();
|
|
121
|
+
},
|
|
122
|
+
get lines() {
|
|
123
|
+
return lines;
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Colours ─────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
// The 16-colour palette (xterm defaults), for mapping 256 and truecolor.
|
|
131
|
+
const PALETTE_16 = [
|
|
132
|
+
[0, 0, 0], [205, 0, 0], [0, 205, 0], [205, 205, 0], [0, 0, 238], [205, 0, 205], [0, 205, 205], [229, 229, 229],
|
|
133
|
+
[127, 127, 127], [255, 0, 0], [0, 255, 0], [255, 255, 0], [92, 92, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255],
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
function nearest16([r, g, b]) {
|
|
137
|
+
let best = 0;
|
|
138
|
+
let bestDistance = Infinity;
|
|
139
|
+
PALETTE_16.forEach(([pr, pg, pb], index) => {
|
|
140
|
+
const distance = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2;
|
|
141
|
+
if (distance < bestDistance) {
|
|
142
|
+
best = index;
|
|
143
|
+
bestDistance = distance;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
return best;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function rgbOf256(n) {
|
|
150
|
+
if (n < 16) return PALETTE_16[n];
|
|
151
|
+
if (n >= 232) {
|
|
152
|
+
const level = 8 + (n - 232) * 10;
|
|
153
|
+
return [level, level, level];
|
|
154
|
+
}
|
|
155
|
+
const index = n - 16;
|
|
156
|
+
const steps = [0, 95, 135, 175, 215, 255];
|
|
157
|
+
return [steps[Math.floor(index / 36)], steps[Math.floor(index / 6) % 6], steps[index % 6]];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function clampByte(value) {
|
|
161
|
+
const number = Number(value);
|
|
162
|
+
return Number.isFinite(number) ? Math.min(255, Math.max(0, number)) : 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function plainStyle() {
|
|
166
|
+
return { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false, inverse: false };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function applySgr(state, params) {
|
|
170
|
+
const codes = params === '' ? [0] : params.split(';').map((part) => (part === '' ? 0 : Number(part)));
|
|
171
|
+
for (let i = 0; i < codes.length; i += 1) {
|
|
172
|
+
const code = codes[i];
|
|
173
|
+
if (code === 0) Object.assign(state, plainStyle());
|
|
174
|
+
else if (code === 1) state.bold = true;
|
|
175
|
+
else if (code === 2) state.dim = true;
|
|
176
|
+
else if (code === 3) state.italic = true;
|
|
177
|
+
else if (code === 4) state.underline = true;
|
|
178
|
+
else if (code === 7) state.inverse = true;
|
|
179
|
+
else if (code === 22) {
|
|
180
|
+
state.bold = false;
|
|
181
|
+
state.dim = false;
|
|
182
|
+
} else if (code === 23) state.italic = false;
|
|
183
|
+
else if (code === 24) state.underline = false;
|
|
184
|
+
else if (code === 27) state.inverse = false;
|
|
185
|
+
else if (code >= 30 && code <= 37) state.fg = code - 30;
|
|
186
|
+
else if (code === 39) state.fg = null;
|
|
187
|
+
else if (code >= 40 && code <= 47) state.bg = code - 40;
|
|
188
|
+
else if (code === 49) state.bg = null;
|
|
189
|
+
else if (code >= 90 && code <= 97) state.fg = code - 90 + 8;
|
|
190
|
+
else if (code >= 100 && code <= 107) state.bg = code - 100 + 8;
|
|
191
|
+
else if (code === 38 || code === 48) {
|
|
192
|
+
const key = code === 38 ? 'fg' : 'bg';
|
|
193
|
+
if (codes[i + 1] === 5 && i + 2 < codes.length) {
|
|
194
|
+
state[key] = nearest16(rgbOf256(Math.min(255, Math.max(0, codes[i + 2] || 0))));
|
|
195
|
+
i += 2;
|
|
196
|
+
} else if (codes[i + 1] === 2 && i + 4 < codes.length) {
|
|
197
|
+
state[key] = nearest16([clampByte(codes[i + 2]), clampByte(codes[i + 3]), clampByte(codes[i + 4])]);
|
|
198
|
+
i += 4;
|
|
199
|
+
} else {
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* One line as styled segments `{ text, fg, bg, bold, dim, italic, underline }`,
|
|
208
|
+
* colours 0–15 or null (256 and truecolor mapped to the nearest of the 16).
|
|
209
|
+
* Inverse text (SGR 7, until 27 or 0) comes with its colours already swapped
|
|
210
|
+
* and `inverse: true`: a null `fg` then means the default background as the
|
|
211
|
+
* text colour, a null `bg` the default text colour as the background (the
|
|
212
|
+
* filled RUN / PASS / FAIL badges of vitest). Adjacent segments never share a
|
|
213
|
+
* style. Any escape that is not SGR is dropped from the text.
|
|
214
|
+
*/
|
|
215
|
+
export function ansiToSegments(lineText) {
|
|
216
|
+
const text = String(lineText ?? '');
|
|
217
|
+
const state = plainStyle();
|
|
218
|
+
const segments = [];
|
|
219
|
+
const pushText = (value) => {
|
|
220
|
+
const clean = stripControlKeepSgr(value).replace(/\x1b/g, '');
|
|
221
|
+
if (!clean) return;
|
|
222
|
+
const fg = state.inverse ? state.bg : state.fg;
|
|
223
|
+
const bg = state.inverse ? state.fg : state.bg;
|
|
224
|
+
const last = segments[segments.length - 1];
|
|
225
|
+
if (last && last.fg === fg && last.bg === bg && last.bold === state.bold && last.dim === state.dim
|
|
226
|
+
&& last.italic === state.italic && last.underline === state.underline && !!last.inverse === state.inverse) {
|
|
227
|
+
last.text += clean;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const segment = { text: clean, fg, bg, bold: state.bold, dim: state.dim, italic: state.italic, underline: state.underline };
|
|
231
|
+
if (state.inverse) segment.inverse = true;
|
|
232
|
+
segments.push(segment);
|
|
233
|
+
};
|
|
234
|
+
let at = 0;
|
|
235
|
+
const pattern = new RegExp(SGR_PATTERN.source, 'g');
|
|
236
|
+
for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
|
|
237
|
+
pushText(text.slice(at, match.index));
|
|
238
|
+
applySgr(state, match[1]);
|
|
239
|
+
at = match.index + match[0].length;
|
|
240
|
+
}
|
|
241
|
+
pushText(text.slice(at));
|
|
242
|
+
return segments;
|
|
243
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Live rows for a window that ends above the conversation's end, without the
|
|
2
|
+
// DOM. Such a window (an "island": a jump to a pin or a search result, a
|
|
3
|
+
// reload around the place read, a kept window more was written below than one
|
|
4
|
+
// page holds) has rows between its newest row and the end that are not on the
|
|
5
|
+
// page. A live row put under it would hide that gap: the newer pages load
|
|
6
|
+
// from the window's newest row, and nothing would load what lies between. So
|
|
7
|
+
// the row is held back: the window keeps saying "newer rows exist", the
|
|
8
|
+
// jump-to-bottom button counts the row, and the newer pages bring it in their
|
|
9
|
+
// turn. Once the window meets the end, live rows are put on the page again.
|
|
10
|
+
//
|
|
11
|
+
// conversation-view.js keeps one of these for the window on the page and
|
|
12
|
+
// applies what it decides; transcript-jump-bottom.mjs draws the count.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The rows held back for the window on the page. `follow` is told of every
|
|
16
|
+
* change of that window: one that meets the end, or another conversation's,
|
|
17
|
+
* holds none.
|
|
18
|
+
*/
|
|
19
|
+
export function createHeldBackLiveRows() {
|
|
20
|
+
let conversationId = '';
|
|
21
|
+
let ids = [];
|
|
22
|
+
let unnamed = 0;
|
|
23
|
+
return {
|
|
24
|
+
/** A live row for `conversation` that was not put on the page. */
|
|
25
|
+
note(conversation, messageId) {
|
|
26
|
+
const id = String(conversation || '').trim();
|
|
27
|
+
if (!id) return 0;
|
|
28
|
+
if (id !== conversationId) {
|
|
29
|
+
conversationId = id;
|
|
30
|
+
ids = [];
|
|
31
|
+
unnamed = 0;
|
|
32
|
+
}
|
|
33
|
+
const rowId = String(messageId || '').trim();
|
|
34
|
+
if (!rowId) unnamed += 1;
|
|
35
|
+
else if (!ids.includes(rowId)) ids.push(rowId);
|
|
36
|
+
return ids.length + unnamed;
|
|
37
|
+
},
|
|
38
|
+
/** How many rows wait for `conversation`'s window. */
|
|
39
|
+
count(conversation) {
|
|
40
|
+
return String(conversation || '').trim() === conversationId ? ids.length + unnamed : 0;
|
|
41
|
+
},
|
|
42
|
+
/** The ids of the rows that wait, oldest first. */
|
|
43
|
+
ids(conversation) {
|
|
44
|
+
return String(conversation || '').trim() === conversationId ? ids.slice() : [];
|
|
45
|
+
},
|
|
46
|
+
/** The window on the page now. True when rows were let go. */
|
|
47
|
+
follow({ conversationId: conversation = '', atTail = true } = {}) {
|
|
48
|
+
const id = String(conversation || '').trim();
|
|
49
|
+
if (!atTail && id === conversationId) return false;
|
|
50
|
+
const had = ids.length + unnamed > 0;
|
|
51
|
+
conversationId = atTail ? '' : id;
|
|
52
|
+
ids = [];
|
|
53
|
+
unnamed = 0;
|
|
54
|
+
return had;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether a newer page that says it is the last one really brings the window
|
|
61
|
+
* to the end. It can have been fetched before a held-back row was written
|
|
62
|
+
* (a page waiting in the prefetch buffer, a request answered while the row's
|
|
63
|
+
* event was on its way): then a row the page lacks, and the page does not
|
|
64
|
+
* show either, is still to come, and one more page is asked for. An empty
|
|
65
|
+
* page ends it: a row that is still missing was deleted since.
|
|
66
|
+
*/
|
|
67
|
+
export function newerPageReachesEnd({ pageHasMore = false, pageMessageIds = [], heldBackIds = [], renderedIds = [] } = {}) {
|
|
68
|
+
if (pageHasMore) return false;
|
|
69
|
+
const page = (pageMessageIds || []).map((id) => String(id || '').trim()).filter(Boolean);
|
|
70
|
+
if (!page.length) return true;
|
|
71
|
+
const known = new Set([...page, ...(renderedIds || []).map((id) => String(id || '').trim())]);
|
|
72
|
+
return (heldBackIds || []).every((id) => known.has(String(id || '').trim()));
|
|
73
|
+
}
|
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
// (the background-task panel appears or opens, a banner, a taller draft) the
|
|
16
16
|
// transcript shrinks from below, scrollTop stays and no scroll event fires,
|
|
17
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
|
|
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).
|
|
19
21
|
|
|
20
22
|
import { currentConvId, isMessagesNearBottom, scrollBottom } from './store.js';
|
|
21
|
-
import { isConversationWindowAtTail } from './conversation-view.js';
|
|
23
|
+
import { getHeldBackLiveRowCount, isConversationWindowAtTail, releaseReadingAnchorHold } from './conversation-view.js';
|
|
22
24
|
import { disarmThinkingFollow, getThinkingFollowMode, syncThinkingFollowButtons } from './thinking-follow.js';
|
|
23
25
|
import { resolveNearBottomThresholdPx } from './transcript-viewport-anchor.mjs';
|
|
24
26
|
import {
|
|
@@ -27,7 +29,9 @@ import {
|
|
|
27
29
|
shouldHoldFocusOnJump,
|
|
28
30
|
} from './composer-keyboard.mjs';
|
|
29
31
|
import {
|
|
32
|
+
baselineBeforeUpdate,
|
|
30
33
|
isScrollOnSendEnabled,
|
|
34
|
+
followsEndThroughResize,
|
|
31
35
|
isNearBottom,
|
|
32
36
|
jumpButtonPresentation,
|
|
33
37
|
jumpScrollBehavior,
|
|
@@ -156,6 +160,8 @@ function update() {
|
|
|
156
160
|
nearBottomPx,
|
|
157
161
|
rowKeys: away ? rowKeys(el) : [],
|
|
158
162
|
question: away && questionWaitingBelow(el),
|
|
163
|
+
windowAtTail: isConversationWindowAtTail(),
|
|
164
|
+
waiting: getHeldBackLiveRowCount(),
|
|
159
165
|
});
|
|
160
166
|
render(state);
|
|
161
167
|
}
|
|
@@ -187,7 +193,21 @@ export function onTranscriptEndLeft(listener) {
|
|
|
187
193
|
* so itself (a row is counted, anything else calls noteTranscriptNews()).
|
|
188
194
|
*/
|
|
189
195
|
export function followsTranscriptEnd() {
|
|
190
|
-
|
|
196
|
+
// The bottom of a window that ends above the conversation's end is not the
|
|
197
|
+
// end: nothing live is drawn there, and nothing scrolls to it.
|
|
198
|
+
if (!isConversationWindowAtTail()) return false;
|
|
199
|
+
const el = scroller();
|
|
200
|
+
const height = jump ? null : observedClientHeight;
|
|
201
|
+
const follows = followsEndThroughResize({
|
|
202
|
+
nearBottomNow: isMessagesNearBottom(),
|
|
203
|
+
clientHeight: el?.clientHeight,
|
|
204
|
+
observedClientHeight: height,
|
|
205
|
+
distanceAtObservedHeight: el && height !== null ? Math.max(0, el.scrollHeight - height - el.scrollTop) : null,
|
|
206
|
+
nearBottomPxAtObservedHeight: height !== null ? resolveNearBottomThresholdPx(height) : 0,
|
|
207
|
+
});
|
|
208
|
+
// What the update adds is counted against the rows on the page before it.
|
|
209
|
+
if (!follows && el && !jump && syncStateConversation()) state = baselineBeforeUpdate(state, rowKeys(el));
|
|
210
|
+
return follows;
|
|
191
211
|
}
|
|
192
212
|
|
|
193
213
|
/**
|
|
@@ -257,7 +277,13 @@ export function ownSendFollows(send) {
|
|
|
257
277
|
* shrinking back): each step asks again.
|
|
258
278
|
*/
|
|
259
279
|
export function scrollAfterOwnSend(send) {
|
|
260
|
-
const step = () => {
|
|
280
|
+
const step = () => {
|
|
281
|
+
if (!ownSendFollows(send)) return;
|
|
282
|
+
// A place held while images load would take this scroll for the reader's
|
|
283
|
+
// and let go only then; the send moves the reader, so it lets go first.
|
|
284
|
+
releaseReadingAnchorHold();
|
|
285
|
+
scrollBottom();
|
|
286
|
+
};
|
|
261
287
|
step();
|
|
262
288
|
requestAnimationFrame(step);
|
|
263
289
|
setTimeout(step, 120);
|
|
@@ -331,10 +357,18 @@ function prefersReducedMotion() {
|
|
|
331
357
|
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
|
|
332
358
|
}
|
|
333
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Takes the reader to the end. From a window that ends above the
|
|
362
|
+
* conversation's end, the end is loaded first: the promise settles once it
|
|
363
|
+
* is on the page (or the load failed), which an own send from such a window
|
|
364
|
+
* waits for.
|
|
365
|
+
*/
|
|
334
366
|
export function jumpTranscriptToBottom() {
|
|
335
367
|
const el = scroller();
|
|
336
|
-
if (!el) return;
|
|
337
|
-
// The end wins over a
|
|
368
|
+
if (!el) return Promise.resolve();
|
|
369
|
+
// The end wins over a place held while images load, as over a followed
|
|
370
|
+
// section of the live bubble.
|
|
371
|
+
releaseReadingAnchorHold();
|
|
338
372
|
if (getThinkingFollowMode()) {
|
|
339
373
|
disarmThinkingFollow();
|
|
340
374
|
syncThinkingFollowButtons(document, null);
|
|
@@ -346,11 +380,10 @@ export function jumpTranscriptToBottom() {
|
|
|
346
380
|
if (loadConversationEnd && !isConversationWindowAtTail()) {
|
|
347
381
|
endJump();
|
|
348
382
|
jump = { cleanup() {} };
|
|
349
|
-
|
|
383
|
+
return Promise.resolve()
|
|
350
384
|
.then(() => loadConversationEnd())
|
|
351
385
|
.catch(() => {})
|
|
352
386
|
.finally(finishJump);
|
|
353
|
-
return;
|
|
354
387
|
}
|
|
355
388
|
const behavior = jumpScrollBehavior({
|
|
356
389
|
distance: distanceFromBottom(el),
|
|
@@ -359,6 +392,7 @@ export function jumpTranscriptToBottom() {
|
|
|
359
392
|
});
|
|
360
393
|
if (behavior === 'smooth') startSmoothJump(el);
|
|
361
394
|
else finishJump();
|
|
395
|
+
return Promise.resolve();
|
|
362
396
|
}
|
|
363
397
|
|
|
364
398
|
function handleScroll() {
|
|
@@ -75,21 +75,51 @@ export function nextJumpButtonState(previous, {
|
|
|
75
75
|
rowKeys = [],
|
|
76
76
|
unseen = false,
|
|
77
77
|
question = false,
|
|
78
|
+
windowAtTail = true,
|
|
79
|
+
waiting = 0,
|
|
78
80
|
} = {}) {
|
|
79
|
-
|
|
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 };
|
|
81
87
|
}
|
|
82
|
-
|
|
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());
|
|
83
92
|
const screens = Number(clientHeight) > 0 ? Number(distance) / Number(clientHeight) : 0;
|
|
84
|
-
const count = countNewRows(baseline, rowKeys);
|
|
93
|
+
const count = windowAtTail ? countNewRows(baseline, rowKeys) : 0;
|
|
85
94
|
const unseenNow = previous?.unseen === true || unseen === true;
|
|
86
95
|
const questionNow = question === true;
|
|
87
96
|
return {
|
|
88
|
-
visible: previous?.visible === true || screens >= JUMP_BUTTON_SHOW_SCREENS || count > 0 || unseenNow || questionNow,
|
|
97
|
+
visible: previous?.visible === true || screens >= JUMP_BUTTON_SHOW_SCREENS || count > 0 || waitingNow > 0 || unseenNow || questionNow,
|
|
89
98
|
baseline,
|
|
90
99
|
count,
|
|
91
100
|
unseen: unseenNow,
|
|
92
101
|
question: questionNow,
|
|
102
|
+
waiting: waitingNow,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The state before a live update that does not follow the end changes the
|
|
108
|
+
* transcript: the baseline is taken now, from the rows before it, if there
|
|
109
|
+
* is none yet. Otherwise it is taken by the next frame's nextJumpButtonState,
|
|
110
|
+
* by then with the update's row in it, and a row that lands between the
|
|
111
|
+
* reader's scroll away and that frame (one frame, or a tenth of a second on
|
|
112
|
+
* a busy page) is not counted.
|
|
113
|
+
*/
|
|
114
|
+
export function baselineBeforeUpdate(previous, rowKeys = []) {
|
|
115
|
+
if (Array.isArray(previous?.baseline)) return previous;
|
|
116
|
+
return {
|
|
117
|
+
visible: previous?.visible === true,
|
|
118
|
+
baseline: rowKeys.slice(),
|
|
119
|
+
count: 0,
|
|
120
|
+
unseen: previous?.unseen === true,
|
|
121
|
+
question: previous?.question === true,
|
|
122
|
+
waiting: 0,
|
|
93
123
|
};
|
|
94
124
|
}
|
|
95
125
|
|
|
@@ -97,14 +127,22 @@ export const JUMP_BUTTON_LABEL = 'Jump to the latest message';
|
|
|
97
127
|
export const JUMP_BUTTON_QUESTION_LABEL = 'A question is waiting for your answer. Jump to it';
|
|
98
128
|
export const JUMP_QUESTION_BADGE = '?';
|
|
99
129
|
|
|
130
|
+
/**
|
|
131
|
+
* How the rows held back for a window above the end show on the badge:
|
|
132
|
+
* 'count' adds them to the count, 'dot' draws the dot of news without a row.
|
|
133
|
+
*/
|
|
134
|
+
export const HELD_BACK_ROWS_BADGE = 'count';
|
|
135
|
+
|
|
100
136
|
/**
|
|
101
137
|
* How a state looks and reads. `state` is 'plain', 'new' or 'question'; a
|
|
102
138
|
* waiting question wins over news. `badge` is the count, '?' for a question,
|
|
103
139
|
* or empty: with `dot`, an empty badge is drawn as a dot (news without a row
|
|
104
|
-
* to count). `
|
|
105
|
-
*
|
|
140
|
+
* to count). `waiting` rows are held back below the page
|
|
141
|
+
* (HELD_BACK_ROWS_BADGE). `announcement` is what a screen reader is told
|
|
142
|
+
* when the state is entered; the label alone is only read when the button is
|
|
143
|
+
* focused.
|
|
106
144
|
*/
|
|
107
|
-
export function jumpButtonPresentation({ visible = false, count = 0, unseen = false, question = false } = {}) {
|
|
145
|
+
export function jumpButtonPresentation({ visible = false, count = 0, unseen = false, question = false, waiting = 0 } = {}, { heldBackRowsBadge = HELD_BACK_ROWS_BADGE } = {}) {
|
|
108
146
|
if (!visible) return { state: 'plain', label: JUMP_BUTTON_LABEL, badge: '', dot: false, announcement: '' };
|
|
109
147
|
if (question) {
|
|
110
148
|
return {
|
|
@@ -115,17 +153,19 @@ export function jumpButtonPresentation({ visible = false, count = 0, unseen = fa
|
|
|
115
153
|
announcement: 'A question is waiting for your answer below.',
|
|
116
154
|
};
|
|
117
155
|
}
|
|
118
|
-
const
|
|
156
|
+
const held = Math.max(0, Math.trunc(Number(waiting) || 0));
|
|
157
|
+
const counted = Math.max(0, Math.trunc(Number(count) || 0)) + (heldBackRowsBadge === 'count' ? held : 0);
|
|
158
|
+
const badge = jumpBadgeText(counted);
|
|
119
159
|
if (badge) {
|
|
120
160
|
return {
|
|
121
161
|
state: 'new',
|
|
122
|
-
label: `${JUMP_BUTTON_LABEL} (${
|
|
162
|
+
label: `${JUMP_BUTTON_LABEL} (${counted} new)`,
|
|
123
163
|
badge,
|
|
124
164
|
dot: false,
|
|
125
165
|
announcement: 'New messages below.',
|
|
126
166
|
};
|
|
127
167
|
}
|
|
128
|
-
if (unseen) {
|
|
168
|
+
if (unseen || held > 0) {
|
|
129
169
|
return {
|
|
130
170
|
state: 'new',
|
|
131
171
|
label: `${JUMP_BUTTON_LABEL} (new below)`,
|
|
@@ -167,6 +207,23 @@ export function shouldRepinOnResize({ pinned, pinnedScrollTop, scrollTop, nearBo
|
|
|
167
207
|
return nearBottomNow === true;
|
|
168
208
|
}
|
|
169
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Whether a live update follows the end. A reader at the end now is
|
|
212
|
+
* followed. So is one the re-pin above is on its way to: the transcript
|
|
213
|
+
* shrank from below (`clientHeight` under the height the last
|
|
214
|
+
* ResizeObserver round saw) and at that height the reader is at the end.
|
|
215
|
+
* An update that lands between the shrink and that round would otherwise
|
|
216
|
+
* find them the panel's height above the end, stay put, and leave them
|
|
217
|
+
* there. The geometry decides, not the last note of the position: a scroll
|
|
218
|
+
* to the end (the update before) is noted only by its scroll event a frame
|
|
219
|
+
* later, and the shrink and this update can both land before it.
|
|
220
|
+
*/
|
|
221
|
+
export function followsEndThroughResize({ nearBottomNow, clientHeight, observedClientHeight, distanceAtObservedHeight, nearBottomPxAtObservedHeight }) {
|
|
222
|
+
if (nearBottomNow === true) return true;
|
|
223
|
+
if (!(Number(clientHeight) < Number(observedClientHeight))) return false;
|
|
224
|
+
return isNearBottom(distanceAtObservedHeight, nearBottomPxAtObservedHeight);
|
|
225
|
+
}
|
|
226
|
+
|
|
170
227
|
// "Jump to the end when I send a message" (Settings › General). A setting of
|
|
171
228
|
// this browser, kept in localStorage like the theme. On unless switched off:
|
|
172
229
|
// with it off, the reader's own send follows the same rule as everything
|