@haikit/client 0.6.2 → 0.7.0
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/README.md +15 -0
- package/package.json +1 -1
- package/src/app.d.ts +3 -0
- package/src/app.js +22 -13
- package/src/index.d.ts +22 -3
- package/src/index.js +71 -10
- package/src/transcript.d.ts +12 -5
- package/src/transcript.js +84 -14
package/README.md
CHANGED
|
@@ -18,6 +18,21 @@ not style the inside of your surfaces; your components emit their own class
|
|
|
18
18
|
names and your stylesheet owns them. Every colour is a `--hai-*` custom
|
|
19
19
|
property, so override what you like.
|
|
20
20
|
|
|
21
|
+
## Surface lifecycle
|
|
22
|
+
|
|
23
|
+
The default transcript keeps each block's element for as long as what it
|
|
24
|
+
shows is unchanged, so a surface mounts **once** and keeps its own state — a
|
|
25
|
+
filter, an expanded row, a React tree — while the conversation streams on.
|
|
26
|
+
`mount` may return `{ freeze, expire, unmount }`. `unmount` is called when the
|
|
27
|
+
surface goes away: `chat.reset()` starts a new conversation, `chat.close()`
|
|
28
|
+
ends the chat, or the surface is mounted again. Release there whatever `mount`
|
|
29
|
+
set up.
|
|
30
|
+
|
|
31
|
+
`chat.close()` is for good: anything open is aborted, every surface is
|
|
32
|
+
unmounted, and subscribers hear `{ type: "closed" }`. `mountChat` then removes
|
|
33
|
+
its shell. A page using `renderTranscript` directly calls `closeTranscript(root)`
|
|
34
|
+
when it removes a transcript, so its resize observer and scroll listener let go.
|
|
35
|
+
|
|
21
36
|
## Out-of-date conversations
|
|
22
37
|
|
|
23
38
|
The server closes a conversation once any surface passes its freshness window.
|
package/package.json
CHANGED
package/src/app.d.ts
CHANGED
|
@@ -21,6 +21,9 @@ export interface MountChatOptions {
|
|
|
21
21
|
* soon as it opens, and again after `reset()`, so a server with an init tool
|
|
22
22
|
* runs it before the user types.
|
|
23
23
|
*
|
|
24
|
+
* Returns the chat. `chat.close()` ends it for good and removes the shell, so a
|
|
25
|
+
* page that takes the chat away again leaves nothing of it behind.
|
|
26
|
+
*
|
|
24
27
|
* It is a convenience, not the API — everything it does is built on `createChat`
|
|
25
28
|
* and `renderTranscript`. Dropping to those when this shell stops fitting is the
|
|
26
29
|
* expected path, not a failure mode.
|
package/src/app.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { createChat } from "./index.js";
|
|
18
|
-
import { renderTranscript, h } from "./transcript.js";
|
|
18
|
+
import { renderTranscript, closeTranscript, h } from "./transcript.js";
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* @param {{
|
|
@@ -111,24 +111,33 @@ export function mountChat({
|
|
|
111
111
|
return empty;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
// Every render goes through the transcript, an empty one included, so the
|
|
115
|
+
// elements a reset leaves behind are taken out and stop being observed.
|
|
116
|
+
// The suggestions follow it until the user engages — a first message, or an
|
|
117
|
+
// answer to something init showed. What the conversation opened with (an
|
|
118
|
+
// init tool's row, a welcome surface) doesn't replace them.
|
|
119
|
+
const render = () => {
|
|
120
|
+
renderTranscript(transcript, chat);
|
|
121
|
+
if (!chat.state.blocks.some((b) => b.kind === "user" || b.kind === "interaction")) {
|
|
122
|
+
transcript.append(emptyState());
|
|
123
|
+
}
|
|
124
|
+
};
|
|
115
125
|
|
|
116
126
|
// ── wiring ─────────────────────────────────────────────────────────
|
|
117
127
|
chat.subscribe((state, event) => {
|
|
128
|
+
// chat.close(): the surfaces are unmounted and the chat lets go of this
|
|
129
|
+
// listener; the shell goes too, and nothing of it stays observed.
|
|
130
|
+
if (event.type === "closed") {
|
|
131
|
+
closeTranscript(transcript);
|
|
132
|
+
shell.remove();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
118
136
|
// A new conversation starts at once, so the server's init tool — if it
|
|
119
137
|
// has one — runs before anything is typed.
|
|
120
138
|
if (event.type === "reset") chat.start();
|
|
121
139
|
|
|
122
|
-
|
|
123
|
-
else {
|
|
124
|
-
renderTranscript(transcript, chat);
|
|
125
|
-
// what the conversation opened with (an init tool's row, a welcome
|
|
126
|
-
// surface) doesn't replace the suggestions; the user engaging does —
|
|
127
|
-
// a first message, or an answer to something init showed
|
|
128
|
-
if (!state.blocks.some((b) => b.kind === "user" || b.kind === "interaction")) {
|
|
129
|
-
transcript.append(emptyState());
|
|
130
|
-
}
|
|
131
|
-
}
|
|
140
|
+
render();
|
|
132
141
|
|
|
133
142
|
modelEl.textContent = state.model;
|
|
134
143
|
input.disabled = state.expired !== null;
|
|
@@ -154,7 +163,7 @@ export function mountChat({
|
|
|
154
163
|
}
|
|
155
164
|
};
|
|
156
165
|
|
|
157
|
-
|
|
166
|
+
render();
|
|
158
167
|
chat.start();
|
|
159
168
|
return chat;
|
|
160
169
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -48,10 +48,16 @@ export interface MountCtx {
|
|
|
48
48
|
* `expire` is called when the conversation goes out of date. By then the
|
|
49
49
|
* runtime has already made the element `inert` and set `data-expired` on it,
|
|
50
50
|
* so nothing inside can reach the server; this hook is only for looks.
|
|
51
|
+
*
|
|
52
|
+
* `unmount` is called when the surface goes away: `reset()` starts a new
|
|
53
|
+
* conversation, `close()` ends the chat, or the surface is mounted again.
|
|
54
|
+
* Release whatever `mount` set up there — timers, subscriptions, a React root.
|
|
55
|
+
* The default transcript mounts each surface once, not on every event.
|
|
51
56
|
*/
|
|
52
57
|
export interface SurfaceInstance {
|
|
53
58
|
freeze?(selection?: unknown): void;
|
|
54
59
|
expire?(): void;
|
|
60
|
+
unmount?(): void;
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
/** Your component. `props` is whatever the surface's `props` schema produces. */
|
|
@@ -120,14 +126,27 @@ export interface Chat {
|
|
|
120
126
|
*/
|
|
121
127
|
start(): Promise<boolean>;
|
|
122
128
|
interact(handle: string, action: string, value: unknown): Promise<void>;
|
|
123
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Mounts the surface for `handle` into `element`. Null if props have not
|
|
131
|
+
* arrived, or the chat is closed. Mounting a surface again unmounts the
|
|
132
|
+
* instance it had before.
|
|
133
|
+
*/
|
|
124
134
|
mount(handle: string, element: HTMLElement): SurfaceInstance | null;
|
|
125
135
|
/**
|
|
126
136
|
* Start a new conversation on the next send. Anything queued for the old one
|
|
127
|
-
* is never sent,
|
|
128
|
-
* never waits behind it
|
|
137
|
+
* is never sent, a request still open is aborted — the new conversation
|
|
138
|
+
* never waits behind it — and every surface is unmounted. Subscribers are
|
|
139
|
+
* notified with `{ type: "reset" }`.
|
|
129
140
|
*/
|
|
130
141
|
reset(): void;
|
|
142
|
+
/**
|
|
143
|
+
* Done with this chat for good, as when the page showing it goes away.
|
|
144
|
+
* Anything queued is never sent, a request still open is aborted, and every
|
|
145
|
+
* surface is unmounted. Subscribers are notified with `{ type: "closed" }`
|
|
146
|
+
* and then dropped. From then on `send` and `start` resolve `false`, and
|
|
147
|
+
* `interact`, `mount` and `reset` do nothing. `mountChat` removes its shell.
|
|
148
|
+
*/
|
|
149
|
+
close(): void;
|
|
131
150
|
/** Returns an unsubscribe function. */
|
|
132
151
|
subscribe(fn: (state: ChatState, event: WireEvent) => void): () => void;
|
|
133
152
|
}
|
package/src/index.js
CHANGED
|
@@ -64,6 +64,9 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
64
64
|
let generation = 0;
|
|
65
65
|
let aborter = new AbortController();
|
|
66
66
|
|
|
67
|
+
/** Set by close(): this chat is done for good, and does nothing more. */
|
|
68
|
+
let shutDown = false;
|
|
69
|
+
|
|
67
70
|
/**
|
|
68
71
|
* When the request now streaming was sent. The server stamps every surface
|
|
69
72
|
* it renders after that request arrived, so a freshness deadline counted from
|
|
@@ -101,9 +104,11 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
101
104
|
});
|
|
102
105
|
|
|
103
106
|
async function pump(path, body, gen, signal, outcome) {
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
+
// A request left over from before reset() or close() does nothing at all:
|
|
108
|
+
// not even closing an out-of-date conversation that is no longer this one.
|
|
109
|
+
// The deadline is checked again here, not only when queued: a long turn
|
|
110
|
+
// ahead of this request can run past it while it waits.
|
|
111
|
+
if (gen !== generation || closed()) return;
|
|
107
112
|
sentAt = Date.now();
|
|
108
113
|
let res = await post(path, body, signal);
|
|
109
114
|
|
|
@@ -113,8 +118,8 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
113
118
|
// retry covers the first and gives up honestly on the second.
|
|
114
119
|
if (res.status === 409) {
|
|
115
120
|
await new Promise((r) => setTimeout(r, 150));
|
|
116
|
-
// the deadline can pass during the wait,
|
|
117
|
-
if (closed()) return;
|
|
121
|
+
// the deadline can pass during the wait, and reset() or close() come
|
|
122
|
+
if (gen !== generation || closed()) return;
|
|
118
123
|
res = await post(path, body, signal); // rejects at once if reset() aborted it meanwhile
|
|
119
124
|
}
|
|
120
125
|
// Anything but a second 409 means the server took the request up itself —
|
|
@@ -293,10 +298,14 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
293
298
|
/**
|
|
294
299
|
* Mount a surface into an element. Strict allowlist: an unregistered
|
|
295
300
|
* component renders an error card, never an improvised UI.
|
|
301
|
+
*
|
|
302
|
+
* One instance per surface. Mounting it again, into this element or another,
|
|
303
|
+
* unmounts the one before, so nothing that instance set up outlives it.
|
|
296
304
|
*/
|
|
297
305
|
function mount(handle, element) {
|
|
298
306
|
const surface = state.surfaces.get(handle);
|
|
299
|
-
if (!surface || !surface.props) return null;
|
|
307
|
+
if (shutDown || !surface || !surface.props) return null;
|
|
308
|
+
unmount(surface);
|
|
300
309
|
|
|
301
310
|
const definition = registry[surface.component];
|
|
302
311
|
if (!definition) {
|
|
@@ -319,6 +328,18 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
319
328
|
return surface.instance;
|
|
320
329
|
}
|
|
321
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Let a surface's instance go. A component that throws on its way out still
|
|
333
|
+
* goes: the error is reported, not allowed to stop a reset or a close part
|
|
334
|
+
* way through.
|
|
335
|
+
*/
|
|
336
|
+
function unmount(surface) {
|
|
337
|
+
const { instance } = surface;
|
|
338
|
+
surface.instance = null;
|
|
339
|
+
surface.element = null;
|
|
340
|
+
attempt(() => instance?.unmount?.());
|
|
341
|
+
}
|
|
342
|
+
|
|
322
343
|
// A message is queued, never refused for being busy. It resolves to whether
|
|
323
344
|
// the server took it: false if the conversation went out of date first —
|
|
324
345
|
// at the call, or while it waited behind a long turn — if reset() started
|
|
@@ -328,7 +349,7 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
328
349
|
// caller can also check `state.expired` straight after calling and never
|
|
329
350
|
// clear the input at all.
|
|
330
351
|
async function send(text) {
|
|
331
|
-
if (!text.trim() || closed()) return false;
|
|
352
|
+
if (shutDown || !text.trim() || closed()) return false;
|
|
332
353
|
return enqueue("/chat", { message: text });
|
|
333
354
|
}
|
|
334
355
|
|
|
@@ -350,7 +371,7 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
350
371
|
* like send().
|
|
351
372
|
*/
|
|
352
373
|
async function start() {
|
|
353
|
-
if (begun() || closed()) return false;
|
|
374
|
+
if (shutDown || begun() || closed()) return false;
|
|
354
375
|
return enqueue("/start", {});
|
|
355
376
|
}
|
|
356
377
|
|
|
@@ -360,19 +381,22 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
360
381
|
// checked first, so a click that cannot be sent still closes a conversation
|
|
361
382
|
// whose timer slept through its deadline.
|
|
362
383
|
async function interact(handle, action, value) {
|
|
363
|
-
if (closed() || busy) return;
|
|
384
|
+
if (shutDown || closed() || busy) return;
|
|
364
385
|
await enqueue("/interact", { handle, action, value });
|
|
365
386
|
}
|
|
366
387
|
|
|
367
388
|
/**
|
|
368
389
|
* Start over. The next send begins a new conversation. Anything queued for
|
|
369
|
-
* the old one is never sent,
|
|
390
|
+
* the old one is never sent, anything still open is aborted, and every
|
|
391
|
+
* surface it showed is unmounted.
|
|
370
392
|
*/
|
|
371
393
|
function reset() {
|
|
394
|
+
if (shutDown) return;
|
|
372
395
|
generation++;
|
|
373
396
|
aborter.abort();
|
|
374
397
|
aborter = new AbortController();
|
|
375
398
|
clearTimeout(timer);
|
|
399
|
+
for (const surface of state.surfaces.values()) unmount(surface);
|
|
376
400
|
Object.assign(state, {
|
|
377
401
|
conversationId: null,
|
|
378
402
|
status: "idle",
|
|
@@ -385,6 +409,26 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
385
409
|
notify({ type: "reset" });
|
|
386
410
|
}
|
|
387
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Done with this chat for good, as when the page that showed it goes away.
|
|
414
|
+
* Anything queued is never sent, anything open is aborted, every surface is
|
|
415
|
+
* unmounted, and subscribers hear `{ type: "closed" }` so they can let go of
|
|
416
|
+
* what they hold. From then on the chat does nothing.
|
|
417
|
+
*/
|
|
418
|
+
function close() {
|
|
419
|
+
if (shutDown) return;
|
|
420
|
+
shutDown = true;
|
|
421
|
+
generation++;
|
|
422
|
+
aborter.abort();
|
|
423
|
+
clearTimeout(timer);
|
|
424
|
+
for (const surface of state.surfaces.values()) unmount(surface);
|
|
425
|
+
// Every subscriber hears it, even after one that throws, and none is held
|
|
426
|
+
// on to: they are let go of before they are told.
|
|
427
|
+
const subscribers = [...listeners];
|
|
428
|
+
listeners.clear();
|
|
429
|
+
for (const fn of subscribers) attempt(() => fn(state, { type: "closed" }));
|
|
430
|
+
}
|
|
431
|
+
|
|
388
432
|
return {
|
|
389
433
|
state,
|
|
390
434
|
start,
|
|
@@ -392,13 +436,30 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
392
436
|
interact,
|
|
393
437
|
mount,
|
|
394
438
|
reset,
|
|
439
|
+
close,
|
|
395
440
|
subscribe(fn) {
|
|
441
|
+
// a closed chat tells no one anything again, so it keeps no one either
|
|
442
|
+
if (shutDown) return () => {};
|
|
396
443
|
listeners.add(fn);
|
|
397
444
|
return () => listeners.delete(fn);
|
|
398
445
|
},
|
|
399
446
|
};
|
|
400
447
|
}
|
|
401
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Run `fn`, reporting what it throws without letting it stop the caller: the
|
|
451
|
+
* error surfaces as an uncaught one would, after the caller has finished.
|
|
452
|
+
*/
|
|
453
|
+
function attempt(fn) {
|
|
454
|
+
try {
|
|
455
|
+
fn();
|
|
456
|
+
} catch (err) {
|
|
457
|
+
queueMicrotask(() => {
|
|
458
|
+
throw err;
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
402
463
|
/** "2 hours", "15 minutes" — for a sentence a person reads. */
|
|
403
464
|
function duration(ms) {
|
|
404
465
|
const units = [
|
package/src/transcript.d.ts
CHANGED
|
@@ -12,10 +12,17 @@ export function h<K extends keyof HTMLElementTagNameMap>(
|
|
|
12
12
|
): HTMLElementTagNameMap[K];
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* The default transcript renderer.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* The default transcript renderer. Call it on every event: a block keeps its
|
|
16
|
+
* element while what it shows is unchanged, so each surface mounts once and
|
|
17
|
+
* keeps its own state. While the reader is at the bottom, it keeps the newest
|
|
18
|
+
* content in view, surfaces included once they have mounted and as they grow.
|
|
19
|
+
* Once the reader scrolls up, it keeps their place until they scroll back
|
|
20
|
+
* down. Deliberately plain — this is the part you are expected to replace.
|
|
20
21
|
*/
|
|
21
22
|
export function renderTranscript(root: HTMLElement, chat: Chat): void;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Stop following a transcript that is going away, so its resize observer and
|
|
26
|
+
* scroll listener let go of it. `mountChat` does this when its chat closes.
|
|
27
|
+
*/
|
|
28
|
+
export function closeTranscript(root: HTMLElement): void;
|
package/src/transcript.js
CHANGED
|
@@ -19,15 +19,37 @@ export function renderTranscript(root, chat) {
|
|
|
19
19
|
|
|
20
20
|
// reset() gives a new conversation a new blocks array, and a new
|
|
21
21
|
// conversation starts at the bottom, wherever the reader was in the last.
|
|
22
|
-
|
|
22
|
+
// Its elements are all new too: the old surfaces are already unmounted.
|
|
23
|
+
if (state.blocks !== follow.blocks) {
|
|
24
|
+
// the last conversation's elements may already be gone from the page; they
|
|
25
|
+
// stop being observed either way
|
|
26
|
+
for (const { el } of follow.shown.values()) follow.observer?.unobserve(el);
|
|
27
|
+
Object.assign(follow, { blocks: state.blocks, shown: new Map(), stick: true, top: 0 });
|
|
28
|
+
}
|
|
23
29
|
// A scroll the reader made since the last render, whose event hasn't arrived
|
|
24
30
|
// yet: taken now, or the pin below would overwrite it. Not while an earlier
|
|
25
31
|
// render in this task is still mounting: the reader can't scroll within a
|
|
26
32
|
// task, and the position read then is one clamped to a shorter transcript.
|
|
27
33
|
else if (!follow.mounting) follow.noticeScroll();
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
// A block keeps its element for as long as what it shows is unchanged, so a
|
|
36
|
+
// surface mounts once rather than on every event, and whatever its component
|
|
37
|
+
// holds — a filter, an expanded row, a React tree — lives as long as it does.
|
|
38
|
+
const shown = new Map();
|
|
39
|
+
const elements = state.blocks.map((block) => {
|
|
40
|
+
const look = appearance(block, chat);
|
|
41
|
+
const before = follow.shown.get(block.id);
|
|
42
|
+
let el = before?.el;
|
|
43
|
+
if (before?.look !== look) {
|
|
44
|
+
el = renderBlock(block, chat);
|
|
45
|
+
// an expanded tool row stays expanded when its status changes
|
|
46
|
+
if (before?.el.open) el.open = true;
|
|
47
|
+
}
|
|
48
|
+
shown.set(block.id, { el, look });
|
|
49
|
+
return el;
|
|
50
|
+
});
|
|
51
|
+
follow.shown = shown;
|
|
52
|
+
place(root, elements, follow.observer);
|
|
31
53
|
|
|
32
54
|
// Surfaces mount in microtasks queued by renderBlock, in block order. This
|
|
33
55
|
// one is queued after them, so it runs once they have content and height:
|
|
@@ -37,18 +59,57 @@ export function renderTranscript(root, chat) {
|
|
|
37
59
|
follow.mounting = false;
|
|
38
60
|
follow.stick ? follow.pin() : follow.hold();
|
|
39
61
|
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Stop following a transcript that is going away: its resize observer and
|
|
66
|
+
* scroll listener let go of it. `mountChat` does this when its chat closes; a
|
|
67
|
+
* page rendering a transcript itself does it when it removes one.
|
|
68
|
+
*/
|
|
69
|
+
export function closeTranscript(root) {
|
|
70
|
+
followers.get(root)?.close();
|
|
71
|
+
followers.delete(root);
|
|
72
|
+
}
|
|
40
73
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
74
|
+
/** What a block's element shows. While this is unchanged, the element is kept. */
|
|
75
|
+
function appearance(block, chat) {
|
|
76
|
+
// a surface shows a skeleton until its props arrive, and then itself for good
|
|
77
|
+
if (block.kind === "ui") return chat.state.surfaces.get(block.handle)?.props ? "mounted" : "loading";
|
|
78
|
+
return JSON.stringify(block);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Put `elements` in order under `root`, moving none that is already there:
|
|
83
|
+
* one taken out and put back loses its scroll position and focus, and any
|
|
84
|
+
* iframe or video in it starts over. Whatever else is there goes — a block's
|
|
85
|
+
* old element, or something the page added after the last render.
|
|
86
|
+
*/
|
|
87
|
+
function place(root, elements, observer) {
|
|
88
|
+
const wanted = new Set(elements);
|
|
89
|
+
const drop = (node) => {
|
|
90
|
+
const next = node.nextSibling;
|
|
91
|
+
node.remove();
|
|
92
|
+
// text and comments are dropped too, but only elements were ever observed
|
|
93
|
+
if (node.nodeType === 1) observer?.unobserve(node);
|
|
94
|
+
return next;
|
|
95
|
+
};
|
|
96
|
+
let at = root.firstChild;
|
|
97
|
+
for (const el of elements) {
|
|
98
|
+
while (at && !wanted.has(at)) at = drop(at);
|
|
99
|
+
if (at === el) {
|
|
100
|
+
at = at.nextSibling;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
root.insertBefore(el, at);
|
|
104
|
+
// Surfaces can grow after they mount, as a table expands or an image
|
|
105
|
+
// loads. While following, the observer keeps the newest content in view
|
|
106
|
+
// as they do. Otherwise it returns the reader to their place, should a
|
|
107
|
+
// surface above it have been short for a moment. The browser reports
|
|
108
|
+
// scrolls before resizes in a frame, so a scroll the reader has just made
|
|
109
|
+
// is already their place by then.
|
|
110
|
+
observer?.observe(el);
|
|
51
111
|
}
|
|
112
|
+
while (at) at = drop(at);
|
|
52
113
|
}
|
|
53
114
|
|
|
54
115
|
/** Within this many pixels of the bottom, the reader is following new content. */
|
|
@@ -83,6 +144,8 @@ function follower(root) {
|
|
|
83
144
|
top: 0,
|
|
84
145
|
// the blocks array of the conversation shown
|
|
85
146
|
blocks: null,
|
|
147
|
+
// block id → { el, look }: the element showing each block, and what it shows
|
|
148
|
+
shown: new Map(),
|
|
86
149
|
// a render's surfaces are still to mount
|
|
87
150
|
mounting: false,
|
|
88
151
|
// the reader has scrolled, unless this is where the transcript's own
|
|
@@ -112,9 +175,16 @@ function follower(root) {
|
|
|
112
175
|
typeof ResizeObserver === "undefined"
|
|
113
176
|
? null
|
|
114
177
|
: new ResizeObserver(() => (follow.stick ? follow.pin() : follow.hold())),
|
|
178
|
+
close() {
|
|
179
|
+
follow.observer?.disconnect();
|
|
180
|
+
root.removeEventListener("scroll", onScroll);
|
|
181
|
+
},
|
|
115
182
|
};
|
|
116
183
|
|
|
117
|
-
|
|
184
|
+
const onScroll = () => follow.noticeScroll();
|
|
185
|
+
root.addEventListener("scroll", onScroll, { passive: true });
|
|
186
|
+
// the viewport itself: a window resized while following stays at the bottom
|
|
187
|
+
follow.observer?.observe(root);
|
|
118
188
|
|
|
119
189
|
followers.set(root, follow);
|
|
120
190
|
return follow;
|