@haikit/client 0.6.2 → 0.8.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 +26 -3
- package/src/index.js +74 -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
|
@@ -37,6 +37,8 @@ export interface MountCtx {
|
|
|
37
37
|
handle: string;
|
|
38
38
|
mode: SurfaceMode;
|
|
39
39
|
state: SurfaceState;
|
|
40
|
+
/** What the user picked, when the surface is mounted already frozen. */
|
|
41
|
+
selection?: unknown;
|
|
40
42
|
send(action: string, value: unknown): Promise<void>;
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -48,10 +50,16 @@ export interface MountCtx {
|
|
|
48
50
|
* `expire` is called when the conversation goes out of date. By then the
|
|
49
51
|
* runtime has already made the element `inert` and set `data-expired` on it,
|
|
50
52
|
* so nothing inside can reach the server; this hook is only for looks.
|
|
53
|
+
*
|
|
54
|
+
* `unmount` is called when the surface goes away: `reset()` starts a new
|
|
55
|
+
* conversation, `close()` ends the chat, or the surface is mounted again.
|
|
56
|
+
* Release whatever `mount` set up there — timers, subscriptions, a React root.
|
|
57
|
+
* The default transcript mounts each surface once, not on every event.
|
|
51
58
|
*/
|
|
52
59
|
export interface SurfaceInstance {
|
|
53
60
|
freeze?(selection?: unknown): void;
|
|
54
61
|
expire?(): void;
|
|
62
|
+
unmount?(): void;
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
/** Your component. `props` is whatever the surface's `props` schema produces. */
|
|
@@ -72,6 +80,8 @@ export interface SurfaceRecord {
|
|
|
72
80
|
mode: SurfaceMode;
|
|
73
81
|
state: SurfaceState;
|
|
74
82
|
props: unknown | null;
|
|
83
|
+
/** What the user picked, once frozen. */
|
|
84
|
+
selection?: unknown;
|
|
75
85
|
instance: SurfaceInstance | null;
|
|
76
86
|
/** The element it was last mounted into. */
|
|
77
87
|
element: HTMLElement | null;
|
|
@@ -120,14 +130,27 @@ export interface Chat {
|
|
|
120
130
|
*/
|
|
121
131
|
start(): Promise<boolean>;
|
|
122
132
|
interact(handle: string, action: string, value: unknown): Promise<void>;
|
|
123
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* Mounts the surface for `handle` into `element`. Null if props have not
|
|
135
|
+
* arrived, or the chat is closed. Mounting a surface again unmounts the
|
|
136
|
+
* instance it had before.
|
|
137
|
+
*/
|
|
124
138
|
mount(handle: string, element: HTMLElement): SurfaceInstance | null;
|
|
125
139
|
/**
|
|
126
140
|
* Start a new conversation on the next send. Anything queued for the old one
|
|
127
|
-
* is never sent,
|
|
128
|
-
* never waits behind it
|
|
141
|
+
* is never sent, a request still open is aborted — the new conversation
|
|
142
|
+
* never waits behind it — and every surface is unmounted. Subscribers are
|
|
143
|
+
* notified with `{ type: "reset" }`.
|
|
129
144
|
*/
|
|
130
145
|
reset(): void;
|
|
146
|
+
/**
|
|
147
|
+
* Done with this chat for good, as when the page showing it goes away.
|
|
148
|
+
* Anything queued is never sent, a request still open is aborted, and every
|
|
149
|
+
* surface is unmounted. Subscribers are notified with `{ type: "closed" }`
|
|
150
|
+
* and then dropped. From then on `send` and `start` resolve `false`, and
|
|
151
|
+
* `interact`, `mount` and `reset` do nothing. `mountChat` removes its shell.
|
|
152
|
+
*/
|
|
153
|
+
close(): void;
|
|
131
154
|
/** Returns an unsubscribe function. */
|
|
132
155
|
subscribe(fn: (state: ChatState, event: WireEvent) => void): () => void;
|
|
133
156
|
}
|
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 —
|
|
@@ -217,6 +222,8 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
217
222
|
const surface = state.surfaces.get(event.handle);
|
|
218
223
|
if (surface) {
|
|
219
224
|
surface.state = event.state;
|
|
225
|
+
// kept, so a surface mounted again once frozen still knows the answer
|
|
226
|
+
surface.selection = event.selection;
|
|
220
227
|
surface.instance?.freeze?.(event.selection);
|
|
221
228
|
}
|
|
222
229
|
break;
|
|
@@ -293,10 +300,14 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
293
300
|
/**
|
|
294
301
|
* Mount a surface into an element. Strict allowlist: an unregistered
|
|
295
302
|
* component renders an error card, never an improvised UI.
|
|
303
|
+
*
|
|
304
|
+
* One instance per surface. Mounting it again, into this element or another,
|
|
305
|
+
* unmounts the one before, so nothing that instance set up outlives it.
|
|
296
306
|
*/
|
|
297
307
|
function mount(handle, element) {
|
|
298
308
|
const surface = state.surfaces.get(handle);
|
|
299
|
-
if (!surface || !surface.props) return null;
|
|
309
|
+
if (shutDown || !surface || !surface.props) return null;
|
|
310
|
+
unmount(surface);
|
|
300
311
|
|
|
301
312
|
const definition = registry[surface.component];
|
|
302
313
|
if (!definition) {
|
|
@@ -311,6 +322,7 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
311
322
|
handle,
|
|
312
323
|
mode: surface.mode,
|
|
313
324
|
state: surface.state,
|
|
325
|
+
selection: surface.selection,
|
|
314
326
|
// The ONLY channel a component has to the server. It passes an action
|
|
315
327
|
// name declared in the contract — never a tool, never a handler.
|
|
316
328
|
send: (action, value) => interact(handle, action, value),
|
|
@@ -319,6 +331,18 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
319
331
|
return surface.instance;
|
|
320
332
|
}
|
|
321
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Let a surface's instance go. A component that throws on its way out still
|
|
336
|
+
* goes: the error is reported, not allowed to stop a reset or a close part
|
|
337
|
+
* way through.
|
|
338
|
+
*/
|
|
339
|
+
function unmount(surface) {
|
|
340
|
+
const { instance } = surface;
|
|
341
|
+
surface.instance = null;
|
|
342
|
+
surface.element = null;
|
|
343
|
+
attempt(() => instance?.unmount?.());
|
|
344
|
+
}
|
|
345
|
+
|
|
322
346
|
// A message is queued, never refused for being busy. It resolves to whether
|
|
323
347
|
// the server took it: false if the conversation went out of date first —
|
|
324
348
|
// at the call, or while it waited behind a long turn — if reset() started
|
|
@@ -328,7 +352,7 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
328
352
|
// caller can also check `state.expired` straight after calling and never
|
|
329
353
|
// clear the input at all.
|
|
330
354
|
async function send(text) {
|
|
331
|
-
if (!text.trim() || closed()) return false;
|
|
355
|
+
if (shutDown || !text.trim() || closed()) return false;
|
|
332
356
|
return enqueue("/chat", { message: text });
|
|
333
357
|
}
|
|
334
358
|
|
|
@@ -350,7 +374,7 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
350
374
|
* like send().
|
|
351
375
|
*/
|
|
352
376
|
async function start() {
|
|
353
|
-
if (begun() || closed()) return false;
|
|
377
|
+
if (shutDown || begun() || closed()) return false;
|
|
354
378
|
return enqueue("/start", {});
|
|
355
379
|
}
|
|
356
380
|
|
|
@@ -360,19 +384,22 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
360
384
|
// checked first, so a click that cannot be sent still closes a conversation
|
|
361
385
|
// whose timer slept through its deadline.
|
|
362
386
|
async function interact(handle, action, value) {
|
|
363
|
-
if (closed() || busy) return;
|
|
387
|
+
if (shutDown || closed() || busy) return;
|
|
364
388
|
await enqueue("/interact", { handle, action, value });
|
|
365
389
|
}
|
|
366
390
|
|
|
367
391
|
/**
|
|
368
392
|
* Start over. The next send begins a new conversation. Anything queued for
|
|
369
|
-
* the old one is never sent,
|
|
393
|
+
* the old one is never sent, anything still open is aborted, and every
|
|
394
|
+
* surface it showed is unmounted.
|
|
370
395
|
*/
|
|
371
396
|
function reset() {
|
|
397
|
+
if (shutDown) return;
|
|
372
398
|
generation++;
|
|
373
399
|
aborter.abort();
|
|
374
400
|
aborter = new AbortController();
|
|
375
401
|
clearTimeout(timer);
|
|
402
|
+
for (const surface of state.surfaces.values()) unmount(surface);
|
|
376
403
|
Object.assign(state, {
|
|
377
404
|
conversationId: null,
|
|
378
405
|
status: "idle",
|
|
@@ -385,6 +412,26 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
385
412
|
notify({ type: "reset" });
|
|
386
413
|
}
|
|
387
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Done with this chat for good, as when the page that showed it goes away.
|
|
417
|
+
* Anything queued is never sent, anything open is aborted, every surface is
|
|
418
|
+
* unmounted, and subscribers hear `{ type: "closed" }` so they can let go of
|
|
419
|
+
* what they hold. From then on the chat does nothing.
|
|
420
|
+
*/
|
|
421
|
+
function close() {
|
|
422
|
+
if (shutDown) return;
|
|
423
|
+
shutDown = true;
|
|
424
|
+
generation++;
|
|
425
|
+
aborter.abort();
|
|
426
|
+
clearTimeout(timer);
|
|
427
|
+
for (const surface of state.surfaces.values()) unmount(surface);
|
|
428
|
+
// Every subscriber hears it, even after one that throws, and none is held
|
|
429
|
+
// on to: they are let go of before they are told.
|
|
430
|
+
const subscribers = [...listeners];
|
|
431
|
+
listeners.clear();
|
|
432
|
+
for (const fn of subscribers) attempt(() => fn(state, { type: "closed" }));
|
|
433
|
+
}
|
|
434
|
+
|
|
388
435
|
return {
|
|
389
436
|
state,
|
|
390
437
|
start,
|
|
@@ -392,13 +439,30 @@ export function createChat({ endpoint = "/hai", registry }) {
|
|
|
392
439
|
interact,
|
|
393
440
|
mount,
|
|
394
441
|
reset,
|
|
442
|
+
close,
|
|
395
443
|
subscribe(fn) {
|
|
444
|
+
// a closed chat tells no one anything again, so it keeps no one either
|
|
445
|
+
if (shutDown) return () => {};
|
|
396
446
|
listeners.add(fn);
|
|
397
447
|
return () => listeners.delete(fn);
|
|
398
448
|
},
|
|
399
449
|
};
|
|
400
450
|
}
|
|
401
451
|
|
|
452
|
+
/**
|
|
453
|
+
* Run `fn`, reporting what it throws without letting it stop the caller: the
|
|
454
|
+
* error surfaces as an uncaught one would, after the caller has finished.
|
|
455
|
+
*/
|
|
456
|
+
function attempt(fn) {
|
|
457
|
+
try {
|
|
458
|
+
fn();
|
|
459
|
+
} catch (err) {
|
|
460
|
+
queueMicrotask(() => {
|
|
461
|
+
throw err;
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
402
466
|
/** "2 hours", "15 minutes" — for a sentence a person reads. */
|
|
403
467
|
function duration(ms) {
|
|
404
468
|
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;
|