@haikit/client 0.13.0 → 0.15.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
@@ -111,6 +111,13 @@ mountChat({
111
111
  });
112
112
  ```
113
113
 
114
+ A `wake` notice's turn comes down the same stream: its reply appears under the
115
+ notice like any other. While it runs, `send` and `interact` wait for it to
116
+ finish rather than being refused, just as they wait behind this chat's own
117
+ requests. If the events connection drops mid-turn, the turn runs on without
118
+ a way to say when it ends, so the next request keeps trying, backing off, for
119
+ up to two minutes rather than giving up after one retry.
120
+
114
121
  A notice's component gets **no `send`**: nothing in it can reach the server. A
115
122
  name the registry doesn't list renders an error card. Like a surface, `mount`
116
123
  may return `{ unmount }`. With `createChat`, mount one yourself with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haikit/client",
3
- "version": "0.13.0",
3
+ "version": "0.15.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
@@ -72,6 +72,19 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
72
72
  /** The events stream's conversation while it is open, and how to stop it. */
73
73
  let listening = null;
74
74
  let listenAborter = null;
75
+ /**
76
+ * Settles when the turn a wake notice started, streaming on the events
77
+ * stream, is done. Until then nothing is posted: the server holds the
78
+ * conversation for that turn, and a message sent now would only be told 409.
79
+ */
80
+ let remote = null;
81
+ let endRemote = () => {};
82
+ /**
83
+ * Set when the events connection dropped while a wake turn ran. That turn
84
+ * still holds the conversation on the server, and its `released` is lost
85
+ * with the connection, so the next request is given longer to get in.
86
+ */
87
+ let cutOff = false;
75
88
  /** seq → the mounted notice's instance and element */
76
89
  const noticeMounts = new Map();
77
90
 
@@ -117,6 +130,11 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
117
130
  // The deadline is checked again here, not only when queued: a long turn
118
131
  // ahead of this request can run past it while it waits.
119
132
  if (gen !== generation || closed()) return;
133
+ // queued behind a wake turn, as behind one of this client's own
134
+ while (remote) {
135
+ await remote;
136
+ if (gen !== generation || closed()) return;
137
+ }
120
138
  sentAt = Date.now();
121
139
  let res = await post(path, body, signal);
122
140
 
@@ -128,8 +146,26 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
128
146
  await new Promise((r) => setTimeout(r, 150));
129
147
  // the deadline can pass during the wait, and reset() or close() come
130
148
  if (gen !== generation || closed()) return;
149
+ // A wake turn may be what holds it, and have said so meanwhile: wait it
150
+ // out, as if it had said so before this request went.
151
+ while (remote) {
152
+ await remote;
153
+ if (gen !== generation || closed()) return;
154
+ }
131
155
  res = await post(path, body, signal); // rejects at once if reset() aborted it meanwhile
132
156
  }
157
+ // A wake turn this client lost sight of holds the conversation until it
158
+ // ends, which may be a while: keep trying, backing off, for as long as a
159
+ // turn can hold it before its lease lapses.
160
+ if (res.status === 409 && cutOff) {
161
+ const giveUp = Date.now() + CUT_OFF_MS;
162
+ for (let wait = 250; res.status === 409 && Date.now() < giveUp; wait = Math.min(wait * 2, 2_000)) {
163
+ await new Promise((r) => setTimeout(r, wait));
164
+ if (gen !== generation || closed()) return;
165
+ res = await post(path, body, signal);
166
+ }
167
+ }
168
+ if (res.status !== 409) cutOff = false;
133
169
  // Anything but a second 409 means the server took the request up itself —
134
170
  // even an error — unless the stream says `expired` below. A 409 means it
135
171
  // turned the message away unread.
@@ -202,9 +238,15 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
202
238
  break;
203
239
  }
204
240
 
205
- case "block_start":
206
- state.blocks.push({ ...event.block });
241
+ // A stream that rejoins a wake turn is sent what the turn has shown so
242
+ // far: a block this client already has is brought up to date, not added
243
+ // again.
244
+ case "block_start": {
245
+ const known = state.blocks.find((b) => b.id === event.block.id);
246
+ if (known) Object.assign(known, event.block);
247
+ else state.blocks.push({ ...event.block });
207
248
  break;
249
+ }
208
250
 
209
251
  case "text_delta": {
210
252
  const block = state.blocks.find((b) => b.id === event.id);
@@ -230,6 +272,7 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
230
272
  }
231
273
 
232
274
  case "ui_open":
275
+ if (state.surfaces.has(event.handle)) break; // shown already: a rejoin's catch-up
233
276
  state.surfaces.set(event.handle, {
234
277
  handle: event.handle,
235
278
  component: event.component,
@@ -346,15 +389,21 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
346
389
  buffer = buffer.slice(i + 2);
347
390
  const line = frame.split("\n").find((l) => l.startsWith("data: "));
348
391
  if (line) {
349
- apply(JSON.parse(line.slice(6)));
392
+ const event = JSON.parse(line.slice(6));
393
+ followTurn(event);
394
+ apply(event);
350
395
  delivered = true;
351
396
  }
352
397
  }
353
398
  }
354
399
  }
355
400
  } catch {
356
- if (signal.aborted) return;
401
+ if (signal.aborted) return finishRemote();
357
402
  }
403
+ // A turn cut off with its connection runs on, but nothing will say when
404
+ // it ends: stop waiting for it, and give the next request longer instead.
405
+ if (remote) cutOff = true;
406
+ finishRemote();
358
407
  if (delivered || Date.now() - opened >= HEALTHY_MS) wait = 1_000;
359
408
  // Dropped, refused, or the network went away: try again, backing off.
360
409
  // reset() or close() ends the wait at once, timer and all.
@@ -371,7 +420,29 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
371
420
  }
372
421
  }
373
422
 
423
+ /**
424
+ * Track a wake turn on the events stream: it starts with a `streaming`
425
+ * status, and is done with `released`, which the server sends only once it
426
+ * has saved the conversation and let it go. Its `idle` status comes before
427
+ * that save, so a message sent on it would still find the conversation held.
428
+ */
429
+ function followTurn(event) {
430
+ if (event.type === "status" && event.status === "streaming") {
431
+ if (!remote) remote = new Promise((resolve) => (endRemote = resolve));
432
+ // surfaces it shows are stamped after this, as a request's are after it is sent
433
+ sentAt = Date.now();
434
+ } else if (event.type === "released") {
435
+ finishRemote();
436
+ }
437
+ }
438
+
439
+ function finishRemote() {
440
+ remote = null;
441
+ endRemote();
442
+ }
443
+
374
444
  function stopListening() {
445
+ finishRemote();
375
446
  listenAborter?.abort();
376
447
  listenAborter = null;
377
448
  listening = null;
@@ -551,6 +622,7 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
551
622
  aborter.abort();
552
623
  aborter = new AbortController();
553
624
  stopListening();
625
+ cutOff = false;
554
626
  clearTimeout(timer);
555
627
  for (const surface of state.surfaces.values()) unmount(surface);
556
628
  for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
@@ -607,6 +679,13 @@ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
607
679
  };
608
680
  }
609
681
 
682
+ /**
683
+ * How long a request keeps trying a conversation held by a wake turn whose
684
+ * events connection dropped: the default lease, the longest that turn can
685
+ * hold it.
686
+ */
687
+ const CUT_OFF_MS = 120_000;
688
+
610
689
  /** An events connection that stays up this long was healthy, whatever it delivered. */
611
690
  const HEALTHY_MS = 30_000;
612
691