@haikit/client 0.1.0 → 0.3.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +66 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haikit/client",
3
- "version": "0.1.0",
3
+ "version": "0.3.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
@@ -26,13 +26,65 @@ export function createChat({ endpoint = "/hai", registry }) {
26
26
 
27
27
  const notify = (event) => listeners.forEach((fn) => fn(state, event));
28
28
 
29
+ /**
30
+ * Requests open or waiting to go out — which is not the same as
31
+ * `state.status`.
32
+ *
33
+ * The server emits `awaiting` and `idle` from inside the turn, then releases
34
+ * its lease and saves after that frame has already reached us. Gating on
35
+ * status lets the next request go out during that tail, and the server
36
+ * answers 409. It matters because the composer is deliberately live while
37
+ * awaiting — "pick an option above, or type to override" — so an override
38
+ * there is the intended affordance, not a misuse.
39
+ *
40
+ * Requests are chained rather than refused: one never starts until the
41
+ * previous response has closed, so this client cannot collide with itself.
42
+ */
43
+ let busy = 0;
44
+ let chain = Promise.resolve();
45
+
29
46
  // ── transport: SSE over POST (EventSource cannot POST) ──────────────
30
- async function stream(path, body) {
31
- const res = await fetch(endpoint + path, {
47
+ function enqueue(path, body) {
48
+ busy++;
49
+ const run = chain.then(() => pump(path, body)).finally(() => {
50
+ busy--;
51
+ });
52
+ chain = run.catch(() => {});
53
+ return run;
54
+ }
55
+
56
+ const post = (path, body) =>
57
+ fetch(endpoint + path, {
32
58
  method: "POST",
33
59
  headers: { "content-type": "application/json" },
34
60
  body: JSON.stringify({ conversationId: state.conversationId, ...body }),
35
61
  });
62
+
63
+ async function pump(path, body) {
64
+ let res = await post(path, body);
65
+
66
+ // 409 means someone else holds this conversation's turn. Almost always that
67
+ // is the previous request's own tail, milliseconds from finishing — but it
68
+ // can also be a second tab, which no client-side guard can prevent. One
69
+ // retry covers the first and gives up honestly on the second.
70
+ if (res.status === 409) {
71
+ await new Promise((r) => setTimeout(r, 150));
72
+ res = await post(path, body);
73
+ }
74
+
75
+ // A non-SSE response carries no `data:` frames, so parsing it as a stream
76
+ // would fail silently and the UI would just sit there.
77
+ if (!res.ok) {
78
+ const detail = await res.json().catch(() => ({}));
79
+ apply({
80
+ type: "error",
81
+ message:
82
+ detail.error ??
83
+ (res.status === 409 ? "another turn is already in flight" : `request failed (${res.status})`),
84
+ });
85
+ return;
86
+ }
87
+
36
88
  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
37
89
  let buffer = "";
38
90
  for (;;) {
@@ -143,14 +195,22 @@ export function createChat({ endpoint = "/hai", registry }) {
143
195
  return surface.instance;
144
196
  }
145
197
 
198
+ // A message is queued, never dropped. mountChat has already cleared the
199
+ // textarea by the time this runs, so refusing a send here does not decline
200
+ // it — it deletes what the user typed. That was true under the old
201
+ // `status === "streaming"` gate too, via the Enter key, which ignores the
202
+ // disabled send button.
146
203
  async function send(text) {
147
- if (!text.trim() || state.status === "streaming") return;
148
- await stream("/chat", { message: text });
204
+ if (!text.trim()) return;
205
+ await enqueue("/chat", { message: text });
149
206
  }
150
207
 
208
+ // A click carries nothing the user authored, and stacking them is worse than
209
+ // dropping them: a double-click on a picker row would resolve it and then
210
+ // queue a second resolution into "component is frozen".
151
211
  async function interact(handle, action, value) {
152
- if (state.status === "streaming") return;
153
- await stream("/interact", { handle, action, value });
212
+ if (busy) return;
213
+ await enqueue("/interact", { handle, action, value });
154
214
  }
155
215
 
156
216
  return {