@haikit/client 0.16.0 → 0.17.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
@@ -76,10 +76,19 @@ colour at all.
76
76
  The default transcript keeps each block's element for as long as what it
77
77
  shows is unchanged, so a surface mounts **once** and keeps its own state — a
78
78
  filter, an expanded row, a React tree — while the conversation streams on.
79
- `mount` may return `{ freeze, expire, unmount }`. `unmount` is called when the
80
- surface goes away: `chat.reset()` starts a new conversation, `chat.close()`
81
- ends the chat, or the surface is mounted again. Release there whatever `mount`
82
- set up.
79
+ `mount` may return `{ freeze, expire, unmount, update }`. `unmount` is called
80
+ when the surface goes away: `chat.reset()` starts a new conversation,
81
+ `chat.close()` ends the chat, or the surface is mounted again. Release there
82
+ whatever `mount` set up.
83
+
84
+ **A tool can revise a surface in place** (`ctx.update`). The revision arrives
85
+ as a `ui_open` naming the handle it `replaces`. The client moves the surface's
86
+ record and transcript block to the new handle, keeping the same element, and
87
+ hands the new props to `update(props)`. Implement it to keep what the
88
+ component holds, such as its scroll, sort or a row the user picked. Without
89
+ it, the component is mounted again in place. Either way, `ctx.send` names the
90
+ surface's current handle, so a click after a revision reaches the new one.
91
+ Each surface keeps its own deadline, and a replaced surface's stops counting.
83
92
 
84
93
  `chat.close()` is for good: anything open is aborted, every surface is
85
94
  unmounted, and subscribers hear `{ type: "closed" }`. `mountChat` then removes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haikit/client",
3
- "version": "0.16.0",
3
+ "version": "0.17.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/index.d.ts CHANGED
@@ -63,7 +63,8 @@ export type WireEvent = { type: string } & Record<string, any>;
63
63
  * tool name, never a handler.
64
64
  */
65
65
  export interface MountCtx {
66
- handle: string;
66
+ /** The surface's handle as it is now: a revision in place (`update`) moves it on. */
67
+ readonly handle: string;
67
68
  mode: SurfaceMode;
68
69
  state: SurfaceState;
69
70
  /** What the user picked, when the surface is mounted already frozen. */
@@ -89,6 +90,13 @@ export interface SurfaceInstance {
89
90
  freeze?(selection?: unknown): void;
90
91
  expire?(): void;
91
92
  unmount?(): void;
93
+ /**
94
+ * The surface was revised in place (a tool's `ctx.update`): these are its
95
+ * new props. Implement it to keep what the component holds, such as its
96
+ * scroll, sort or expanded rows. Without it, the runtime mounts the
97
+ * component again in the same element, losing that.
98
+ */
99
+ update?(props: any): void;
92
100
  }
93
101
 
94
102
  /** Your component. `props` is whatever the surface's `props` schema produces. */
@@ -135,6 +143,9 @@ export interface SurfaceRecord {
135
143
  instance: SurfaceInstance | null;
136
144
  /** The element it was last mounted into. */
137
145
  element: HTMLElement | null;
146
+ /** When it goes out of date, in this browser's clock, or null if never. Its window, in ms. */
147
+ deadline?: number | null;
148
+ window?: number;
138
149
  }
139
150
 
140
151
  export interface ChatState {
package/src/index.js CHANGED
@@ -228,13 +228,9 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
228
228
  state.notices.push(notice);
229
229
  const block = { kind: "notice", id: `n:${event.seq}`, ...notice };
230
230
  // beside its surface — after any notices already there — or at the end
231
- const at = event.handle === undefined ? -1 : state.blocks.findIndex((b) => b.id === `ui:${event.handle}`);
231
+ const at = event.handle === undefined ? -1 : state.blocks.findIndex((b) => b.kind === "ui" && b.handle === event.handle);
232
232
  if (at < 0) state.blocks.push(block);
233
- else {
234
- let i = at + 1;
235
- while (state.blocks[i]?.kind === "notice" && state.blocks[i].handle === event.handle) i++;
236
- state.blocks.splice(i, 0, block);
237
- }
233
+ else state.blocks.splice(afterNotices(at), 0, block);
238
234
  break;
239
235
  }
240
236
 
@@ -271,44 +267,70 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
271
267
  break;
272
268
  }
273
269
 
274
- case "ui_open":
270
+ case "ui_open": {
275
271
  if (state.surfaces.has(event.handle)) break; // shown already: a rejoin's catch-up
276
- state.surfaces.set(event.handle, {
277
- handle: event.handle,
278
- component: event.component,
279
- version: event.version,
280
- mode: event.mode,
281
- state: "live",
282
- props: null,
283
- instance: null,
284
- element: null,
285
- });
286
- state.blocks.push({ kind: "ui", id: `ui:${event.handle}`, handle: event.handle, toolId: event.toolId });
272
+ // A revision of a surface on screen takes over its record in place:
273
+ // its component, element and state stay, its handle moves on, and its
274
+ // props stay until the new ones arrive, so the transcript keeps the
275
+ // same element rather than mounting afresh.
276
+ const old = event.replaces === undefined ? undefined : state.surfaces.get(event.replaces);
277
+ let surface;
278
+ if (old) {
279
+ state.surfaces.delete(old.handle);
280
+ Object.assign(old, { handle: event.handle, component: event.component, version: event.version, revising: true });
281
+ state.surfaces.set(event.handle, (surface = old));
282
+ const block = state.blocks.find((b) => b.kind === "ui" && b.handle === event.replaces);
283
+ if (block) block.handle = event.handle;
284
+ } else {
285
+ surface = {
286
+ handle: event.handle,
287
+ component: event.component,
288
+ version: event.version,
289
+ mode: event.mode,
290
+ state: "live",
291
+ props: null,
292
+ instance: null,
293
+ element: null,
294
+ };
295
+ state.surfaces.set(event.handle, surface);
296
+ state.blocks.push({ kind: "ui", id: `ui:${event.handle}`, handle: event.handle, toolId: event.toolId });
297
+ }
287
298
  // Notices come on a stream of their own, so one can arrive before the
288
299
  // surface it names: it went to the end then, and moves under it now.
289
300
  // Moved within the same array, which the transcript would otherwise
290
301
  // take for a new conversation's.
291
- for (const early of state.blocks.filter((b) => b.kind === "notice" && b.handle === event.handle)) {
292
- state.blocks.splice(state.blocks.indexOf(early), 1);
293
- state.blocks.push(early);
294
- }
302
+ const early = state.blocks.filter((b) => b.kind === "notice" && b.handle === event.handle);
303
+ for (const notice of early) state.blocks.splice(state.blocks.indexOf(notice), 1);
304
+ const at = state.blocks.findIndex((b) => b.kind === "ui" && b.handle === event.handle);
305
+ if (at >= 0) state.blocks.splice(afterNotices(at), 0, ...early);
306
+ else state.blocks.push(...early);
307
+ // Each surface keeps its own deadline: from when the request that
308
+ // rendered it was sent or, by its age, from when it was stored,
309
+ // whichever is earlier. A revision brings its own, and the one it
310
+ // replaced stops counting.
295
311
  if (typeof event.staleAfterMs === "number") {
296
- // From when the request that rendered it was sent, or, for a surface
297
- // replayed to a stream that joined a wake turn part way, from when
298
- // it was stored, by its age: whichever is earlier.
299
312
  const from = typeof event.ageMs === "number" ? Math.min(sentAt, Date.now() - event.ageMs) : sentAt;
300
- const at = from + event.staleAfterMs;
301
- if (state.expiresAt === null || at < state.expiresAt) {
302
- state.expiresAt = at;
303
- closingWindow = event.staleAfterMs;
304
- schedule();
305
- }
313
+ surface.deadline = from + event.staleAfterMs;
314
+ surface.window = event.staleAfterMs;
315
+ } else {
316
+ surface.deadline = null;
306
317
  }
318
+ refreshDeadline();
307
319
  break;
320
+ }
308
321
 
309
322
  case "ui_props": {
310
323
  const surface = state.surfaces.get(event.handle);
311
- if (surface) surface.props = event.props;
324
+ if (!surface) break;
325
+ surface.props = event.props;
326
+ if (surface.revising) {
327
+ // A revision's props reach the component already mounted: through
328
+ // its `update`, keeping its own state, or by mounting it again.
329
+ surface.revising = false;
330
+ const { instance, element } = surface;
331
+ if (instance && typeof instance.update === "function") attempt(() => instance.update(event.props));
332
+ else if (instance && element) mount(surface.handle, element);
333
+ }
312
334
  break;
313
335
  }
314
336
 
@@ -500,6 +522,29 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
500
522
  `${duration(closingWindow)}. Start a new conversation for current results.`,
501
523
  });
502
524
 
525
+ /**
526
+ * Where a notice for the surface at `at` goes: after the notices already
527
+ * beside it, which may name an earlier revision of it, so they keep the
528
+ * order they arrived in. A notice that names no surface sits at the end,
529
+ * not beside one, and isn't counted among them.
530
+ */
531
+ function afterNotices(at) {
532
+ let i = at + 1;
533
+ while (state.blocks[i]?.kind === "notice" && state.blocks[i].handle !== undefined) i++;
534
+ return i;
535
+ }
536
+
537
+ /** The earliest deadline among the surfaces showing, and which window set it. */
538
+ function refreshDeadline() {
539
+ let soonest = null;
540
+ for (const surface of state.surfaces.values()) {
541
+ if (surface.deadline != null && (soonest === null || surface.deadline < soonest.deadline)) soonest = surface;
542
+ }
543
+ state.expiresAt = soonest?.deadline ?? null;
544
+ if (soonest) closingWindow = soonest.window;
545
+ schedule();
546
+ }
547
+
503
548
  // One timer, for the earliest deadline. setTimeout fires at once for delays
504
549
  // past about 24.8 days, so a long wait is taken in steps.
505
550
  function schedule() {
@@ -546,13 +591,17 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
546
591
  element.replaceChildren();
547
592
  surface.element = element;
548
593
  surface.instance = definition.mount(element, surface.props, {
549
- handle,
594
+ // the surface as it is now, as `send` below: a revision moves it on
595
+ get handle() {
596
+ return surface.handle;
597
+ },
550
598
  mode: surface.mode,
551
599
  state: surface.state,
552
600
  selection: surface.selection,
553
601
  // The ONLY channel a component has to the server. It passes an action
554
602
  // name declared in the contract — never a tool, never a handler.
555
- send: (action, value) => interact(handle, action, value),
603
+ // and names the surface as it is now: a revision moves it on.
604
+ send: (action, value) => interact(surface.handle, action, value),
556
605
  });
557
606
  if (state.expired) seal(surface);
558
607
  return surface.instance;
package/src/transcript.js CHANGED
@@ -46,6 +46,9 @@ export function renderTranscript(root, chat) {
46
46
  el = renderBlock(block, chat);
47
47
  // an expanded tool row stays expanded when its status changes
48
48
  if (before?.el.open) el.open = true;
49
+ } else if (block.kind === "ui" && el.dataset.handle !== block.handle) {
50
+ // a revision moved this surface to a new handle; its element stays
51
+ el.dataset.handle = block.handle;
49
52
  } else if (block.progress !== before.progress) {
50
53
  // Progress changes the row in place. Rebuilt for every frame, a row a
51
54
  // keyboard user is on would take their focus up to ten times a second.