@haikit/client 0.15.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
@@ -129,7 +138,15 @@ The server closes a conversation once any surface passes its freshness window.
129
138
  The client knows each window too, so a tab left open closes on time instead of
130
139
  looking live until a click is refused: `state.expired` holds the notice,
131
140
  `send` and `interact` stop sending, and each surface's element is made `inert`
132
- with `data-expired` set. `chat.reset()` starts a new conversation; the default
141
+ with `data-expired` set.
142
+
143
+ The browser counts each window from when it sent the request that rendered
144
+ the surface, which is never later than the server's own stamp, so its
145
+ deadline is never later than the server's. Each surface also comes with
146
+ `ageMs`, how long ago the server stored it: the time its digest took, plus,
147
+ for one replayed to a stream that joined a wake turn part way, how long it
148
+ waited. The window is counted from whichever is earlier. That's a duration,
149
+ not a time, so the two clocks are never compared. `chat.reset()` starts a new conversation; the default
133
150
  transcript renders the notice with a button that calls it.
134
151
 
135
152
  ## Serving it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haikit/client",
3
- "version": "0.15.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,40 +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
- const at = sentAt + event.staleAfterMs;
297
- if (state.expiresAt === null || at < state.expiresAt) {
298
- state.expiresAt = at;
299
- closingWindow = event.staleAfterMs;
300
- schedule();
301
- }
312
+ const from = typeof event.ageMs === "number" ? Math.min(sentAt, Date.now() - event.ageMs) : sentAt;
313
+ surface.deadline = from + event.staleAfterMs;
314
+ surface.window = event.staleAfterMs;
315
+ } else {
316
+ surface.deadline = null;
302
317
  }
318
+ refreshDeadline();
303
319
  break;
320
+ }
304
321
 
305
322
  case "ui_props": {
306
323
  const surface = state.surfaces.get(event.handle);
307
- 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
+ }
308
334
  break;
309
335
  }
310
336
 
@@ -496,6 +522,29 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
496
522
  `${duration(closingWindow)}. Start a new conversation for current results.`,
497
523
  });
498
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
+
499
548
  // One timer, for the earliest deadline. setTimeout fires at once for delays
500
549
  // past about 24.8 days, so a long wait is taken in steps.
501
550
  function schedule() {
@@ -542,13 +591,17 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
542
591
  element.replaceChildren();
543
592
  surface.element = element;
544
593
  surface.instance = definition.mount(element, surface.props, {
545
- handle,
594
+ // the surface as it is now, as `send` below: a revision moves it on
595
+ get handle() {
596
+ return surface.handle;
597
+ },
546
598
  mode: surface.mode,
547
599
  state: surface.state,
548
600
  selection: surface.selection,
549
601
  // The ONLY channel a component has to the server. It passes an action
550
602
  // name declared in the contract — never a tool, never a handler.
551
- 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),
552
605
  });
553
606
  if (state.expired) seal(surface);
554
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.