@haikit/client 0.17.0 → 0.18.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
@@ -89,6 +89,9 @@ component holds, such as its scroll, sort or a row the user picked. Without
89
89
  it, the component is mounted again in place. Either way, `ctx.send` names the
90
90
  surface's current handle, so a click after a revision reaches the new one.
91
91
  Each surface keeps its own deadline, and a replaced surface's stops counting.
92
+ A revision app code makes from outside a turn (`hai.update`) arrives the same
93
+ way, on the events stream, and its deadline counts from when the server
94
+ stored it.
92
95
 
93
96
  `chat.close()` is for good: anything open is aborted, every surface is
94
97
  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.17.0",
3
+ "version": "0.18.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.js CHANGED
@@ -36,6 +36,11 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
36
36
  };
37
37
 
38
38
  const notify = (event) => listeners.forEach((fn) => fn(state, event));
39
+ /**
40
+ * handle -> the handle that replaced it, for every revision this browser has
41
+ * heard of, including ones it only heard named; followed by `current`.
42
+ */
43
+ const replacedBy = new Map();
39
44
 
40
45
  /**
41
46
  * Requests open or waiting to go out — which is not the same as
@@ -95,6 +100,12 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
95
100
  * a buffering proxy holds the stream back.
96
101
  */
97
102
  let sentAt = 0;
103
+ /**
104
+ * The last notice the events stream finished sending, by its id: where a
105
+ * reconnect resumes. Update notices arrive as surface frames, not notices,
106
+ * so `state.notices` alone would miss them.
107
+ */
108
+ let resumeAfter = 0;
98
109
 
99
110
  // ── transport: SSE over POST (EventSource cannot POST) ──────────────
100
111
  function enqueue(path, body) {
@@ -211,7 +222,11 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
211
222
  }
212
223
  }
213
224
 
214
- function apply(event) {
225
+ /**
226
+ * `from`: when a surface in this event was rendered, at the latest, by this
227
+ * browser's clock, for one that no request of its own asked for.
228
+ */
229
+ function apply(event, from = sentAt) {
215
230
  switch (event.type) {
216
231
  case "hello":
217
232
  state.conversationId = event.conversationId;
@@ -227,8 +242,10 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
227
242
  const { type, ...notice } = event;
228
243
  state.notices.push(notice);
229
244
  const block = { kind: "notice", id: `n:${event.seq}`, ...notice };
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.kind === "ui" && b.handle === event.handle);
245
+ // beside its surface — after any notices already there — or at the end;
246
+ // one naming an earlier handle of the surface goes beside it as it is now
247
+ const handle = event.handle === undefined ? undefined : current(event.handle);
248
+ const at = handle === undefined ? -1 : state.blocks.findIndex((b) => b.kind === "ui" && b.handle === handle);
232
249
  if (at < 0) state.blocks.push(block);
233
250
  else state.blocks.splice(afterNotices(at), 0, block);
234
251
  break;
@@ -269,17 +286,37 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
269
286
 
270
287
  case "ui_open": {
271
288
  if (state.surfaces.has(event.handle)) break; // shown already: a rejoin's catch-up
289
+ // Replaced already: a revision on the events stream overtook the
290
+ // request that showed the original, held back on its way here. The
291
+ // surface it became stands, and takes the original's tool row; the
292
+ // original isn't shown beside it, and its props go nowhere.
293
+ if (replacedBy.has(event.handle)) {
294
+ const now = state.blocks.find((b) => b.kind === "ui" && b.handle === current(event.handle));
295
+ if (now && !now.toolId) now.toolId = event.toolId;
296
+ break;
297
+ }
272
298
  // A revision of a surface on screen takes over its record in place:
273
299
  // its component, element and state stay, its handle moves on, and its
274
300
  // props stay until the new ones arrive, so the transcript keeps the
275
301
  // same element rather than mounting afresh.
276
- const old = event.replaces === undefined ? undefined : state.surfaces.get(event.replaces);
302
+ // `replaces`, or, for a revision replayed after several, an earlier
303
+ // handle of the same surface: whichever this browser last saw
304
+ const old = [event.replaces, ...(event.alsoReplaces ?? [])]
305
+ .filter((h) => h !== undefined)
306
+ .map((h) => state.surfaces.get(h))
307
+ .find(Boolean);
308
+ // every earlier handle now leads here, seen or not, so a notice naming
309
+ // one finds this surface
310
+ for (const h of [event.replaces, ...(event.alsoReplaces ?? [])]) {
311
+ if (h !== undefined && h !== event.handle) replacedBy.set(h, event.handle);
312
+ }
277
313
  let surface;
278
314
  if (old) {
279
- state.surfaces.delete(old.handle);
315
+ const was = old.handle;
316
+ state.surfaces.delete(was);
280
317
  Object.assign(old, { handle: event.handle, component: event.component, version: event.version, revising: true });
281
318
  state.surfaces.set(event.handle, (surface = old));
282
- const block = state.blocks.find((b) => b.kind === "ui" && b.handle === event.replaces);
319
+ const block = state.blocks.find((b) => b.kind === "ui" && b.handle === was);
283
320
  if (block) block.handle = event.handle;
284
321
  } else {
285
322
  surface = {
@@ -296,10 +333,14 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
296
333
  state.blocks.push({ kind: "ui", id: `ui:${event.handle}`, handle: event.handle, toolId: event.toolId });
297
334
  }
298
335
  // Notices come on a stream of their own, so one can arrive before the
299
- // surface it names: it went to the end then, and moves under it now.
300
- // Moved within the same array, which the transcript would otherwise
301
- // take for a new conversation's.
302
- const early = state.blocks.filter((b) => b.kind === "notice" && b.handle === event.handle);
336
+ // surface it names, or name a handle of it this browser hadn't heard
337
+ // of: it went to the end then, and moves under it now, among those
338
+ // already beside it in the order they were sent. Moved within the
339
+ // same array, which the transcript would otherwise take for a new
340
+ // conversation's.
341
+ const early = state.blocks
342
+ .filter((b) => b.kind === "notice" && b.handle !== undefined && current(b.handle) === event.handle)
343
+ .sort((x, y) => x.seq - y.seq);
303
344
  for (const notice of early) state.blocks.splice(state.blocks.indexOf(notice), 1);
304
345
  const at = state.blocks.findIndex((b) => b.kind === "ui" && b.handle === event.handle);
305
346
  if (at >= 0) state.blocks.splice(afterNotices(at), 0, ...early);
@@ -309,8 +350,8 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
309
350
  // whichever is earlier. A revision brings its own, and the one it
310
351
  // replaced stops counting.
311
352
  if (typeof event.staleAfterMs === "number") {
312
- const from = typeof event.ageMs === "number" ? Math.min(sentAt, Date.now() - event.ageMs) : sentAt;
313
- surface.deadline = from + event.staleAfterMs;
353
+ const stored = typeof event.ageMs === "number" ? Math.min(from, Date.now() - event.ageMs) : from;
354
+ surface.deadline = stored + event.staleAfterMs;
314
355
  surface.window = event.staleAfterMs;
315
356
  } else {
316
357
  surface.deadline = null;
@@ -394,7 +435,7 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
394
435
  const opened = Date.now();
395
436
  let delivered = false;
396
437
  try {
397
- const last = state.notices.at(-1)?.seq ?? 0;
438
+ const last = Math.max(resumeAfter, state.notices.at(-1)?.seq ?? 0);
398
439
  const res = await fetch(`${endpoint}/events?conversationId=${encodeURIComponent(conversationId)}`, {
399
440
  headers: last ? { "last-event-id": String(last) } : {},
400
441
  signal,
@@ -413,13 +454,21 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
413
454
  if (gen !== generation) return;
414
455
  const frame = buffer.slice(0, i);
415
456
  buffer = buffer.slice(i + 2);
416
- const line = frame.split("\n").find((l) => l.startsWith("data: "));
457
+ const lines = frame.split("\n");
458
+ const line = lines.find((l) => l.startsWith("data: "));
417
459
  if (line) {
418
460
  const event = JSON.parse(line.slice(6));
419
461
  followTurn(event);
420
- apply(event);
462
+ // A revision `hai.update` made comes outside any turn, even
463
+ // while one is running, so no request of this browser's
464
+ // dates it: only its age does. No tool call made it.
465
+ const outside = event.type === "ui_open" && event.toolId === "";
466
+ apply(event, remote && !outside ? sentAt : Date.now());
421
467
  delivered = true;
422
468
  }
469
+ // the notice this frame finished, revisions' included
470
+ const id = Number(lines.find((l) => l.startsWith("id: "))?.slice(4));
471
+ if (Number.isSafeInteger(id) && id > resumeAfter) resumeAfter = id;
423
472
  }
424
473
  }
425
474
  }
@@ -534,6 +583,12 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
534
583
  return i;
535
584
  }
536
585
 
586
+ /** The handle a surface has now, following every revision heard of since `handle`. */
587
+ function current(handle) {
588
+ while (replacedBy.has(handle)) handle = replacedBy.get(handle);
589
+ return handle;
590
+ }
591
+
537
592
  /** The earliest deadline among the surfaces showing, and which window set it. */
538
593
  function refreshDeadline() {
539
594
  let soonest = null;
@@ -676,6 +731,7 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
676
731
  aborter = new AbortController();
677
732
  stopListening();
678
733
  cutOff = false;
734
+ resumeAfter = 0;
679
735
  clearTimeout(timer);
680
736
  for (const surface of state.surfaces.values()) unmount(surface);
681
737
  for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
@@ -689,6 +745,7 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
689
745
  notices: [],
690
746
  });
691
747
  state.surfaces.clear();
748
+ replacedBy.clear();
692
749
  notify({ type: "reset" });
693
750
  }
694
751
 
package/src/transcript.js CHANGED
@@ -102,19 +102,20 @@ function toolStatus(block, chat) {
102
102
  if (block.status !== "awaiting") return block.status;
103
103
  const { blocks, surfaces, expired } = chat.state;
104
104
  // Found once per row and kept: every answered question keeps its row
105
- // `awaiting`, and this runs for each of them on every streamed event.
106
- let handle = questions.get(block);
107
- if (handle === undefined) {
108
- handle = blocks.find(
105
+ // `awaiting`, and this runs for each of them on every streamed event. The
106
+ // surface's block is kept, not its handle, which a revision moves on.
107
+ let question = questions.get(block);
108
+ if (question === undefined) {
109
+ question = blocks.find(
109
110
  (b) => b.kind === "ui" && b.toolId === block.id && surfaces.get(b.handle)?.mode === "elicit",
110
- )?.handle;
111
- if (handle !== undefined) questions.set(block, handle);
111
+ );
112
+ if (question !== undefined) questions.set(block, question);
112
113
  }
113
- if (surfaces.get(handle)?.state === "frozen") return "resolved";
114
+ if (surfaces.get(question?.handle)?.state === "frozen") return "resolved";
114
115
  return expired ? "expired" : "awaiting";
115
116
  }
116
117
 
117
- /** A tool row's block → the handle of the question it asked, once seen. */
118
+ /** A tool row's block → the block of the question it asked, once seen. */
118
119
  const questions = new WeakMap();
119
120
 
120
121
  const STATUS_TEXT = {