@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 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.8.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
@@ -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
- /** Mounts the surface for `handle` into `element`. Null if props have not arrived. */
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, and a request still open is aborted — the new conversation
128
- * never waits behind it. Subscribers are notified with `{ type: "reset" }`.
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
- // 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 —
@@ -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, and anything still open is aborted.
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 = [
@@ -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;