@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haikit/client",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "The haikit browser runtime: streams tool payloads into interactive UI surfaces. Plain ESM, no build step, no dependencies.",
5
5
  "keywords": [
6
6
  "haikit",
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
- const renderEmpty = () => transcript.replaceChildren(emptyState());
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
- if (state.blocks.length === 0) renderEmpty();
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
- renderEmpty();
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
- /** Mounts the surface for `handle` into `element`. Null if props have not arrived. */
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, and a request still open is aborted — the new conversation
128
- * never waits behind it. Subscribers are notified with `{ type: "reset" }`.
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
- // Checked again here, not only when queued: a long turn ahead of this
105
- // request can run past the deadline while it waits.
106
- if (closed()) return;
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, like any other queued request
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, and anything still open is aborted.
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 = [
@@ -12,10 +12,17 @@ export function h<K extends keyof HTMLElementTagNameMap>(
12
12
  ): HTMLElementTagNameMap[K];
13
13
 
14
14
  /**
15
- * The default transcript renderer. Replaces `root`'s children on every call.
16
- * While the reader is at the bottom, it keeps the newest content in view,
17
- * surfaces included once they have mounted and as they grow. Once the reader
18
- * scrolls up, it keeps their place until they scroll back down. Deliberately
19
- * plain — this is the part you are expected to replace.
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
- if (state.blocks !== follow.blocks) Object.assign(follow, { blocks: state.blocks, stick: true, top: 0 });
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
- root.replaceChildren();
30
- for (const block of state.blocks) root.append(renderBlock(block, chat));
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
- // Surfaces can grow after they mount, as a table expands or an image loads.
42
- // While following, the observer keeps the newest content in view as they do.
43
- // Otherwise it returns the reader to their place, which a rebuild may have
44
- // had to clamp while the surfaces above it were still short. The browser
45
- // reports scrolls before resizes in a frame, so a scroll the reader has just
46
- // made is already their place by then.
47
- if (follow.observer) {
48
- follow.observer.disconnect();
49
- follow.observer.observe(root);
50
- for (const child of root.children) follow.observer.observe(child);
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
- root.addEventListener("scroll", () => follow.noticeScroll(), { passive: true });
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;