@oar-sh/oar 0.9.11 → 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 +126 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +17 -3
- 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 +128 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -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 +9 -0
- package/server/grok-worker/acp-host-services.mjs +42 -3
- 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/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/api-client.js +13 -0
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +95 -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-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +433 -3
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/journal-view.js +252 -66
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/settings-modal.js +110 -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 +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/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
- package/server/public/app/transcript-jump-bottom.mjs +48 -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 +239 -6
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +8 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/routes/messages-routes.mjs +249 -3
- package/server/routes/sessions-routes.mjs +133 -63
- package/server/server-runtime.mjs +77 -0
- package/server/services/background-task-store.mjs +240 -3
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +130 -2
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/remote-relay-contract.mjs +32 -2
- package/shared/task-output-text.mjs +737 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
|
@@ -0,0 +1,234 @@
|
|
|
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 };
|
|
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 === 22) {
|
|
179
|
+
state.bold = false;
|
|
180
|
+
state.dim = false;
|
|
181
|
+
} else if (code === 23) state.italic = false;
|
|
182
|
+
else if (code === 24) state.underline = false;
|
|
183
|
+
else if (code >= 30 && code <= 37) state.fg = code - 30;
|
|
184
|
+
else if (code === 39) state.fg = null;
|
|
185
|
+
else if (code >= 40 && code <= 47) state.bg = code - 40;
|
|
186
|
+
else if (code === 49) state.bg = null;
|
|
187
|
+
else if (code >= 90 && code <= 97) state.fg = code - 90 + 8;
|
|
188
|
+
else if (code >= 100 && code <= 107) state.bg = code - 100 + 8;
|
|
189
|
+
else if (code === 38 || code === 48) {
|
|
190
|
+
const key = code === 38 ? 'fg' : 'bg';
|
|
191
|
+
if (codes[i + 1] === 5 && i + 2 < codes.length) {
|
|
192
|
+
state[key] = nearest16(rgbOf256(Math.min(255, Math.max(0, codes[i + 2] || 0))));
|
|
193
|
+
i += 2;
|
|
194
|
+
} else if (codes[i + 1] === 2 && i + 4 < codes.length) {
|
|
195
|
+
state[key] = nearest16([clampByte(codes[i + 2]), clampByte(codes[i + 3]), clampByte(codes[i + 4])]);
|
|
196
|
+
i += 4;
|
|
197
|
+
} else {
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* One line as styled segments `{ text, fg, bg, bold, dim, italic, underline }`,
|
|
206
|
+
* colours 0–15 or null (256 and truecolor mapped to the nearest of the 16).
|
|
207
|
+
* Adjacent segments never share a style. Any escape that is not SGR is
|
|
208
|
+
* dropped from the text.
|
|
209
|
+
*/
|
|
210
|
+
export function ansiToSegments(lineText) {
|
|
211
|
+
const text = String(lineText ?? '');
|
|
212
|
+
const state = plainStyle();
|
|
213
|
+
const segments = [];
|
|
214
|
+
const pushText = (value) => {
|
|
215
|
+
const clean = stripControlKeepSgr(value).replace(/\x1b/g, '');
|
|
216
|
+
if (!clean) return;
|
|
217
|
+
const last = segments[segments.length - 1];
|
|
218
|
+
if (last && last.fg === state.fg && last.bg === state.bg && last.bold === state.bold
|
|
219
|
+
&& last.dim === state.dim && last.italic === state.italic && last.underline === state.underline) {
|
|
220
|
+
last.text += clean;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
segments.push({ text: clean, ...state });
|
|
224
|
+
};
|
|
225
|
+
let at = 0;
|
|
226
|
+
const pattern = new RegExp(SGR_PATTERN.source, 'g');
|
|
227
|
+
for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
|
|
228
|
+
pushText(text.slice(at, match.index));
|
|
229
|
+
applySgr(state, match[1]);
|
|
230
|
+
at = match.index + match[0].length;
|
|
231
|
+
}
|
|
232
|
+
pushText(text.slice(at));
|
|
233
|
+
return segments;
|
|
234
|
+
}
|
|
@@ -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 {
|
|
@@ -28,6 +30,7 @@ import {
|
|
|
28
30
|
} from './composer-keyboard.mjs';
|
|
29
31
|
import {
|
|
30
32
|
isScrollOnSendEnabled,
|
|
33
|
+
followsEndThroughResize,
|
|
31
34
|
isNearBottom,
|
|
32
35
|
jumpButtonPresentation,
|
|
33
36
|
jumpScrollBehavior,
|
|
@@ -156,6 +159,8 @@ function update() {
|
|
|
156
159
|
nearBottomPx,
|
|
157
160
|
rowKeys: away ? rowKeys(el) : [],
|
|
158
161
|
question: away && questionWaitingBelow(el),
|
|
162
|
+
windowAtTail: isConversationWindowAtTail(),
|
|
163
|
+
waiting: getHeldBackLiveRowCount(),
|
|
159
164
|
});
|
|
160
165
|
render(state);
|
|
161
166
|
}
|
|
@@ -187,7 +192,18 @@ export function onTranscriptEndLeft(listener) {
|
|
|
187
192
|
* so itself (a row is counted, anything else calls noteTranscriptNews()).
|
|
188
193
|
*/
|
|
189
194
|
export function followsTranscriptEnd() {
|
|
190
|
-
|
|
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
|
+
});
|
|
191
207
|
}
|
|
192
208
|
|
|
193
209
|
/**
|
|
@@ -257,7 +273,13 @@ export function ownSendFollows(send) {
|
|
|
257
273
|
* shrinking back): each step asks again.
|
|
258
274
|
*/
|
|
259
275
|
export function scrollAfterOwnSend(send) {
|
|
260
|
-
const step = () => {
|
|
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
|
+
};
|
|
261
283
|
step();
|
|
262
284
|
requestAnimationFrame(step);
|
|
263
285
|
setTimeout(step, 120);
|
|
@@ -331,10 +353,18 @@ function prefersReducedMotion() {
|
|
|
331
353
|
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
|
|
332
354
|
}
|
|
333
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
|
+
*/
|
|
334
362
|
export function jumpTranscriptToBottom() {
|
|
335
363
|
const el = scroller();
|
|
336
|
-
if (!el) return;
|
|
337
|
-
// 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();
|
|
338
368
|
if (getThinkingFollowMode()) {
|
|
339
369
|
disarmThinkingFollow();
|
|
340
370
|
syncThinkingFollowButtons(document, null);
|
|
@@ -346,11 +376,10 @@ export function jumpTranscriptToBottom() {
|
|
|
346
376
|
if (loadConversationEnd && !isConversationWindowAtTail()) {
|
|
347
377
|
endJump();
|
|
348
378
|
jump = { cleanup() {} };
|
|
349
|
-
|
|
379
|
+
return Promise.resolve()
|
|
350
380
|
.then(() => loadConversationEnd())
|
|
351
381
|
.catch(() => {})
|
|
352
382
|
.finally(finishJump);
|
|
353
|
-
return;
|
|
354
383
|
}
|
|
355
384
|
const behavior = jumpScrollBehavior({
|
|
356
385
|
distance: distanceFromBottom(el),
|
|
@@ -359,6 +388,7 @@ export function jumpTranscriptToBottom() {
|
|
|
359
388
|
});
|
|
360
389
|
if (behavior === 'smooth') startSmoothJump(el);
|
|
361
390
|
else finishJump();
|
|
391
|
+
return Promise.resolve();
|
|
362
392
|
}
|
|
363
393
|
|
|
364
394
|
function handleScroll() {
|
|
@@ -75,21 +75,31 @@ 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,
|
|
93
103
|
};
|
|
94
104
|
}
|
|
95
105
|
|
|
@@ -97,14 +107,22 @@ export const JUMP_BUTTON_LABEL = 'Jump to the latest message';
|
|
|
97
107
|
export const JUMP_BUTTON_QUESTION_LABEL = 'A question is waiting for your answer. Jump to it';
|
|
98
108
|
export const JUMP_QUESTION_BADGE = '?';
|
|
99
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
|
+
|
|
100
116
|
/**
|
|
101
117
|
* How a state looks and reads. `state` is 'plain', 'new' or 'question'; a
|
|
102
118
|
* waiting question wins over news. `badge` is the count, '?' for a question,
|
|
103
119
|
* or empty: with `dot`, an empty badge is drawn as a dot (news without a row
|
|
104
|
-
* to count). `
|
|
105
|
-
*
|
|
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.
|
|
106
124
|
*/
|
|
107
|
-
export function jumpButtonPresentation({ visible = false, count = 0, unseen = false, question = false } = {}) {
|
|
125
|
+
export function jumpButtonPresentation({ visible = false, count = 0, unseen = false, question = false, waiting = 0 } = {}, { heldBackRowsBadge = HELD_BACK_ROWS_BADGE } = {}) {
|
|
108
126
|
if (!visible) return { state: 'plain', label: JUMP_BUTTON_LABEL, badge: '', dot: false, announcement: '' };
|
|
109
127
|
if (question) {
|
|
110
128
|
return {
|
|
@@ -115,17 +133,19 @@ export function jumpButtonPresentation({ visible = false, count = 0, unseen = fa
|
|
|
115
133
|
announcement: 'A question is waiting for your answer below.',
|
|
116
134
|
};
|
|
117
135
|
}
|
|
118
|
-
const
|
|
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);
|
|
119
139
|
if (badge) {
|
|
120
140
|
return {
|
|
121
141
|
state: 'new',
|
|
122
|
-
label: `${JUMP_BUTTON_LABEL} (${
|
|
142
|
+
label: `${JUMP_BUTTON_LABEL} (${counted} new)`,
|
|
123
143
|
badge,
|
|
124
144
|
dot: false,
|
|
125
145
|
announcement: 'New messages below.',
|
|
126
146
|
};
|
|
127
147
|
}
|
|
128
|
-
if (unseen) {
|
|
148
|
+
if (unseen || held > 0) {
|
|
129
149
|
return {
|
|
130
150
|
state: 'new',
|
|
131
151
|
label: `${JUMP_BUTTON_LABEL} (new below)`,
|
|
@@ -167,6 +187,23 @@ export function shouldRepinOnResize({ pinned, pinnedScrollTop, scrollTop, nearBo
|
|
|
167
187
|
return nearBottomNow === true;
|
|
168
188
|
}
|
|
169
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
|
+
|
|
170
207
|
// "Jump to the end when I send a message" (Settings › General). A setting of
|
|
171
208
|
// this browser, kept in localStorage like the theme. On unless switched off:
|
|
172
209
|
// with it off, the reader's own send follows the same rule as everything
|