@haikit/client 0.3.1 → 0.4.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
@@ -18,6 +18,15 @@ not style the inside of your surfaces; your components emit their own class
18
18
  names and your stylesheet owns them. Every colour is a `--hai-*` custom
19
19
  property, so override what you like.
20
20
 
21
+ ## Out-of-date conversations
22
+
23
+ The server closes a conversation once any surface passes its freshness window.
24
+ The client knows each window too, so a tab left open closes on time instead of
25
+ looking live until a click is refused: `state.expired` holds the notice,
26
+ `send` and `interact` stop sending, and each surface's element is made `inert`
27
+ with `data-expired` set. `chat.reset()` starts a new conversation; the default
28
+ transcript renders the notice with a button that calls it.
29
+
21
30
  ## Serving it
22
31
 
23
32
  This package ships source, not a bundle. With a bundler, import normally. With
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haikit/client",
3
- "version": "0.3.1",
3
+ "version": "0.4.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/app.js CHANGED
@@ -83,8 +83,17 @@ export function mountChat({
83
83
  const send = () => {
84
84
  const text = input.value.trim();
85
85
  if (!text) return;
86
+ const sending = chat.send(text);
87
+ // send() refuses an out-of-date conversation before its first await, so
88
+ // this already knows: keep the text for the new conversation instead
89
+ if (chat.state.expired) return;
86
90
  input.value = "";
87
- chat.send(text);
91
+ // A message queued behind a long turn can still be turned away later, if
92
+ // the conversation goes out of date before its turn comes. Give the text
93
+ // back then, unless something new has been typed since.
94
+ sending.then((sent) => {
95
+ if (!sent && !input.value) input.value = text;
96
+ });
88
97
  };
89
98
 
90
99
  function renderEmpty() {
@@ -108,14 +117,17 @@ export function mountChat({
108
117
  else renderTranscript(transcript, chat);
109
118
 
110
119
  modelEl.textContent = state.model;
111
- sendBtn.disabled = state.status === "streaming";
120
+ input.disabled = state.expired !== null;
121
+ sendBtn.disabled = state.status === "streaming" || state.expired !== null;
112
122
  statusEl.dataset.status = state.status;
113
123
  statusEl.textContent =
114
124
  state.status === "streaming" ? "thinking…"
115
125
  : state.status === "awaiting" ? "awaiting your selection in the component above"
116
126
  : "";
117
127
  input.placeholder =
118
- state.status === "awaiting" ? "pick an option above — or type to override" : placeholder;
128
+ state.expired ? "start a new conversation to continue"
129
+ : state.status === "awaiting" ? "pick an option above — or type to override"
130
+ : placeholder;
119
131
 
120
132
  if (inspectorBody) renderInspector(inspectorBody, state.context);
121
133
  });
package/src/hai.css CHANGED
@@ -141,6 +141,30 @@
141
141
  .hai-skeleton, .hai-surface-error { padding: 14px; font: 12px var(--hai-mono); color: var(--hai-dim); }
142
142
  .hai-surface-error { color: var(--hai-bad); }
143
143
 
144
+ /* out of date — the runtime has made the surface inert; this only shows it */
145
+ .hai-surface[data-expired] { opacity: .45; filter: grayscale(.7); }
146
+ .hai-expired {
147
+ display: flex;
148
+ flex-wrap: wrap;
149
+ align-items: center;
150
+ gap: 10px 14px;
151
+ padding: 10px 12px;
152
+ border: 1px solid var(--hai-warn);
153
+ border-radius: var(--hai-radius);
154
+ color: var(--hai-warn);
155
+ }
156
+ .hai-expired .hai-body { flex: 1 1 30ch; }
157
+ .hai-new {
158
+ background: var(--hai-warn);
159
+ color: var(--hai-bg);
160
+ border: none;
161
+ border-radius: 8px;
162
+ padding: 6px 14px;
163
+ font: inherit;
164
+ font-weight: 600;
165
+ cursor: pointer;
166
+ }
167
+
144
168
  /* ── status + composer ──────────────────────────────────────────────── */
145
169
  .hai-status { padding: 0 20px 4px; font-size: 12px; min-height: 18px; color: var(--hai-dim); }
146
170
  .hai-status[data-status="awaiting"] { color: var(--hai-warn); }
@@ -158,6 +182,7 @@
158
182
  max-height: 180px;
159
183
  }
160
184
  .hai-input:focus { outline: none; border-color: var(--hai-accent); }
185
+ .hai-input:disabled { opacity: .5; }
161
186
  .hai-send {
162
187
  background: var(--hai-accent);
163
188
  color: var(--hai-bg);
package/src/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export type ConversationStatus = "idle" | "streaming" | "awaiting";
12
12
  export type SurfaceMode = "display" | "elicit";
13
13
  export type SurfaceState = "live" | "frozen";
14
14
 
15
- /** Blocks the server emits, plus the two the client synthesises locally. */
15
+ /** Blocks the server emits, plus the three the client synthesises locally. */
16
16
  export type Block =
17
17
  | { kind: "user"; id: string; text: string }
18
18
  | { kind: "assistant"; id: string; text: string }
@@ -21,7 +21,9 @@ export type Block =
21
21
  /** synthesised from `ui_open` — the slot a surface mounts into */
22
22
  | { kind: "ui"; id: string; handle: string; toolId: string }
23
23
  /** synthesised from `error` */
24
- | { kind: "error"; id: string; message: string };
24
+ | { kind: "error"; id: string; message: string }
25
+ /** synthesised from `expired` — the conversation is closed; offer `reset()` */
26
+ | { kind: "expired"; id: string; message: string };
25
27
 
26
28
  /** The SSE frame shape. Narrow on `type` to get the specific payload. */
27
29
  export type WireEvent = { type: string } & Record<string, any>;
@@ -42,9 +44,14 @@ export interface MountCtx {
42
44
  * Returned by `mount`. `freeze` is called when the server marks the surface
43
45
  * frozen — a resolved elicit turn cannot be replayed, and the component should
44
46
  * reflect that.
47
+ *
48
+ * `expire` is called when the conversation goes out of date. By then the
49
+ * runtime has already made the element `inert` and set `data-expired` on it,
50
+ * so nothing inside can reach the server; this hook is only for looks.
45
51
  */
46
52
  export interface SurfaceInstance {
47
53
  freeze?(selection?: unknown): void;
54
+ expire?(): void;
48
55
  }
49
56
 
50
57
  /** Your component. `props` is whatever the surface's `props` schema produces. */
@@ -66,6 +73,8 @@ export interface SurfaceRecord {
66
73
  state: SurfaceState;
67
74
  props: unknown | null;
68
75
  instance: SurfaceInstance | null;
76
+ /** The element it was last mounted into. */
77
+ element: HTMLElement | null;
69
78
  }
70
79
 
71
80
  export interface ChatState {
@@ -76,15 +85,41 @@ export interface ChatState {
76
85
  /** handle → surface record */
77
86
  surfaces: Map<string, SurfaceRecord>;
78
87
  context: { messages: any[]; modelTokens: number; uiTokens: number };
88
+ /**
89
+ * When this conversation goes out of date, in this browser's clock (epoch
90
+ * ms): the earliest freshness window among its surfaces, each counted from
91
+ * when the request that rendered it was sent — so never later than the
92
+ * server's own deadline. Null while nothing it shows can go stale.
93
+ */
94
+ expiresAt: number | null;
95
+ /**
96
+ * The notice to show once the conversation is out of date, else null. From
97
+ * then on `send` and `interact` do nothing — the server would refuse them —
98
+ * until `reset()` starts a new conversation.
99
+ */
100
+ expired: string | null;
79
101
  }
80
102
 
81
103
  export interface Chat {
82
104
  /** Mutable — read it in a subscriber, do not hold references across events. */
83
105
  state: ChatState;
84
- send(text: string): Promise<void>;
106
+ /**
107
+ * Queue a message. Resolves `true` once the server has taken it, `false` if
108
+ * it never did: the conversation went out of date first (at the call, or
109
+ * while it waited behind a long turn), `reset()` started a new conversation
110
+ * before its turn, or the server was still busy after one retry. A UI that
111
+ * cleared its input can then put the text back.
112
+ */
113
+ send(text: string): Promise<boolean>;
85
114
  interact(handle: string, action: string, value: unknown): Promise<void>;
86
115
  /** Mounts the surface for `handle` into `element`. Null if props have not arrived. */
87
116
  mount(handle: string, element: HTMLElement): SurfaceInstance | null;
117
+ /**
118
+ * Start a new conversation on the next send. Anything queued for the old one
119
+ * is never sent, and a request still open is aborted — the new conversation
120
+ * never waits behind it. Subscribers are notified with `{ type: "reset" }`.
121
+ */
122
+ reset(): void;
88
123
  /** Returns an unsubscribe function. */
89
124
  subscribe(fn: (state: ChatState, event: WireEvent) => void): () => void;
90
125
  }
package/src/index.js CHANGED
@@ -19,9 +19,18 @@ export function createChat({ endpoint = "/hai", registry }) {
19
19
  model: "",
20
20
  status: "idle",
21
21
  blocks: [],
22
- /** handle -> { component, version, mode, props, state, instance } */
22
+ /** handle -> { component, version, mode, props, state, instance, element } */
23
23
  surfaces: new Map(),
24
24
  context: { messages: [], modelTokens: 0, uiTokens: 0 },
25
+ /**
26
+ * When this conversation goes out of date, by this browser's clock: the
27
+ * earliest freshness window among its surfaces, each counted from when the
28
+ * request that rendered it was sent — so never later than the server's own
29
+ * deadline. Null while nothing it shows can go stale.
30
+ */
31
+ expiresAt: null,
32
+ /** The notice, once the conversation is out of date. Final until `reset()`. */
33
+ expired: null,
25
34
  };
26
35
 
27
36
  const notify = (event) => listeners.forEach((fn) => fn(state, event));
@@ -43,25 +52,60 @@ export function createChat({ endpoint = "/hai", registry }) {
43
52
  let busy = 0;
44
53
  let chain = Promise.resolve();
45
54
 
55
+ /**
56
+ * Bumped by `reset()`. A request belongs to the conversation that was current
57
+ * when it was made, and carries that conversation's abort signal: reset()
58
+ * aborts it, so one still queued is refused by fetch before anything is sent,
59
+ * and one already open stops. Waiting instead would queue the new
60
+ * conversation's first request behind a stream that can stay open for a whole
61
+ * model turn. Events already read are dropped too, so a stray `hello` cannot
62
+ * pull this client back into the conversation it just left.
63
+ */
64
+ let generation = 0;
65
+ let aborter = new AbortController();
66
+
67
+ /**
68
+ * When the request now streaming was sent. The server stamps every surface
69
+ * it renders after that request arrived, so a freshness deadline counted from
70
+ * here is never later than the server's — however long the model takes, or
71
+ * a buffering proxy holds the stream back.
72
+ */
73
+ let sentAt = 0;
74
+
46
75
  // ── transport: SSE over POST (EventSource cannot POST) ──────────────
47
76
  function enqueue(path, body) {
77
+ const gen = generation;
78
+ const { signal } = aborter;
79
+ // set by pump once the server has answered this request itself
80
+ const outcome = { sent: false };
48
81
  busy++;
49
- const run = chain.then(() => pump(path, body)).finally(() => {
50
- busy--;
51
- });
82
+ const run = chain
83
+ .then(() => pump(path, body, gen, signal, outcome))
84
+ .catch((err) => {
85
+ // aborted by reset(): it belonged to a conversation the user has left
86
+ if (gen === generation) throw err;
87
+ })
88
+ .finally(() => {
89
+ busy--;
90
+ });
52
91
  chain = run.catch(() => {});
53
- return run;
92
+ return run.then(() => outcome.sent);
54
93
  }
55
94
 
56
- const post = (path, body) =>
95
+ const post = (path, body, signal) =>
57
96
  fetch(endpoint + path, {
58
97
  method: "POST",
59
98
  headers: { "content-type": "application/json" },
60
99
  body: JSON.stringify({ conversationId: state.conversationId, ...body }),
100
+ signal,
61
101
  });
62
102
 
63
- async function pump(path, body) {
64
- let res = await post(path, body);
103
+ async function pump(path, body, gen, signal, outcome) {
104
+ // Checked again here, not only when queued: a long turn ahead of this
105
+ // request can run past the deadline while it waits.
106
+ if (closed()) return;
107
+ sentAt = Date.now();
108
+ let res = await post(path, body, signal);
65
109
 
66
110
  // 409 means someone else holds this conversation's turn. Almost always that
67
111
  // is the previous request's own tail, milliseconds from finishing — but it
@@ -69,13 +113,21 @@ export function createChat({ endpoint = "/hai", registry }) {
69
113
  // retry covers the first and gives up honestly on the second.
70
114
  if (res.status === 409) {
71
115
  await new Promise((r) => setTimeout(r, 150));
72
- res = await post(path, body);
116
+ // the deadline can pass during the wait, like any other queued request
117
+ if (closed()) return;
118
+ res = await post(path, body, signal); // rejects at once if reset() aborted it meanwhile
73
119
  }
120
+ // Anything but a second 409 means the server took the request up itself —
121
+ // even an error — unless the stream says `expired` below. A 409 means it
122
+ // turned the message away unread.
123
+ outcome.sent = res.status !== 409;
74
124
 
75
125
  // A non-SSE response carries no `data:` frames, so parsing it as a stream
76
126
  // would fail silently and the UI would just sit there.
77
127
  if (!res.ok) {
78
128
  const detail = await res.json().catch(() => ({}));
129
+ // an abort during that read is swallowed by the catch, so check here
130
+ if (gen !== generation) return;
79
131
  apply({
80
132
  type: "error",
81
133
  message:
@@ -93,10 +145,19 @@ export function createChat({ endpoint = "/hai", registry }) {
93
145
  buffer += value;
94
146
  let i;
95
147
  while ((i = buffer.indexOf("\n\n")) >= 0) {
148
+ // Per frame, not per chunk: a subscriber may call reset() from inside
149
+ // apply(), and the rest of this chunk belongs to the old conversation.
150
+ // The abort stops later reads; it cannot recall what is already here.
151
+ if (gen !== generation) return;
96
152
  const frame = buffer.slice(0, i);
97
153
  buffer = buffer.slice(i + 2);
98
154
  const line = frame.split("\n").find((l) => l.startsWith("data: "));
99
- if (line) apply(JSON.parse(line.slice(6)));
155
+ if (!line) continue;
156
+ const event = JSON.parse(line.slice(6));
157
+ // refused inside a 200: the server recorded nothing, so it did not
158
+ // take the message, whatever the status said
159
+ if (event.type === "expired") outcome.sent = false;
160
+ apply(event);
100
161
  }
101
162
  }
102
163
  }
@@ -133,8 +194,17 @@ export function createChat({ endpoint = "/hai", registry }) {
133
194
  state: "live",
134
195
  props: null,
135
196
  instance: null,
197
+ element: null,
136
198
  });
137
199
  state.blocks.push({ kind: "ui", id: `ui:${event.handle}`, handle: event.handle, toolId: event.toolId });
200
+ if (typeof event.staleAfterMs === "number") {
201
+ const at = sentAt + event.staleAfterMs;
202
+ if (state.expiresAt === null || at < state.expiresAt) {
203
+ state.expiresAt = at;
204
+ closingWindow = event.staleAfterMs;
205
+ schedule();
206
+ }
207
+ }
138
208
  break;
139
209
 
140
210
  case "ui_props": {
@@ -164,10 +234,62 @@ export function createChat({ endpoint = "/hai", registry }) {
164
234
  state.blocks.push({ kind: "error", id: `e${state.blocks.length}`, message: event.message });
165
235
  state.status = "idle";
166
236
  break;
237
+
238
+ // The local timer and the server can both report this; once is enough.
239
+ case "expired":
240
+ if (state.expired) return;
241
+ state.expired = event.message;
242
+ state.status = "idle";
243
+ clearTimeout(timer);
244
+ state.blocks.push({ kind: "expired", id: `x${state.blocks.length}`, message: event.message });
245
+ for (const surface of state.surfaces.values()) seal(surface);
246
+ break;
167
247
  }
168
248
  notify(event);
169
249
  }
170
250
 
251
+ // ── expiry ─────────────────────────────────────────────────────────
252
+ // The server refuses every request on an out-of-date conversation. Knowing
253
+ // the deadline here too means a tab left open greys out on time, rather than
254
+ // looking live until a click comes back refused.
255
+
256
+ let timer = null;
257
+ let closingWindow = 0;
258
+
259
+ const outOfDate = () => state.expiresAt !== null && Date.now() >= state.expiresAt;
260
+
261
+ const expireHere = () =>
262
+ apply({
263
+ type: "expired",
264
+ message:
265
+ `This conversation is out of date — results shown here were only valid for ` +
266
+ `${duration(closingWindow)}. Start a new conversation for current results.`,
267
+ });
268
+
269
+ // One timer, for the earliest deadline. setTimeout fires at once for delays
270
+ // past about 24.8 days, so a long wait is taken in steps.
271
+ function schedule() {
272
+ clearTimeout(timer);
273
+ if (state.expiresAt === null || state.expired) return;
274
+ const wait = Math.min(Math.max(state.expiresAt - Date.now(), 0), 2_147_483_647);
275
+ timer = setTimeout(() => (outOfDate() ? expireHere() : schedule()), wait);
276
+ }
277
+
278
+ /** Checked against the clock as well as the flag: a laptop that slept
279
+ * through the deadline may not have run the timer yet. */
280
+ function closed() {
281
+ if (!state.expired && outOfDate()) expireHere();
282
+ return state.expired !== null;
283
+ }
284
+
285
+ /** Nothing inside an out-of-date surface may reach the server again. */
286
+ function seal(surface) {
287
+ if (!surface.element) return;
288
+ surface.element.inert = true;
289
+ surface.element.dataset.expired = "";
290
+ surface.instance?.expire?.();
291
+ }
292
+
171
293
  /**
172
294
  * Mount a surface into an element. Strict allowlist: an unregistered
173
295
  * component renders an error card, never an improvised UI.
@@ -184,6 +306,7 @@ export function createChat({ endpoint = "/hai", registry }) {
184
306
  }
185
307
 
186
308
  element.replaceChildren();
309
+ surface.element = element;
187
310
  surface.instance = definition.mount(element, surface.props, {
188
311
  handle,
189
312
  mode: surface.mode,
@@ -192,35 +315,80 @@ export function createChat({ endpoint = "/hai", registry }) {
192
315
  // name declared in the contract — never a tool, never a handler.
193
316
  send: (action, value) => interact(handle, action, value),
194
317
  });
318
+ if (state.expired) seal(surface);
195
319
  return surface.instance;
196
320
  }
197
321
 
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.
322
+ // A message is queued, never refused for being busy. It resolves to whether
323
+ // the server took it: false if the conversation went out of date first —
324
+ // at the call, or while it waited behind a long turn — if reset() started
325
+ // a new one before its turn, or if the server was still busy after the
326
+ // retry. A caller that cleared its input can then give the text back. The
327
+ // out-of-date refusal at the call happens before the first await, so such a
328
+ // caller can also check `state.expired` straight after calling and never
329
+ // clear the input at all.
203
330
  async function send(text) {
204
- if (!text.trim()) return;
205
- await enqueue("/chat", { message: text });
331
+ if (!text.trim() || closed()) return false;
332
+ return enqueue("/chat", { message: text });
206
333
  }
207
334
 
208
335
  // A click carries nothing the user authored, and stacking them is worse than
209
336
  // dropping them: a double-click on a picker row would resolve it and then
210
- // queue a second resolution into "component is frozen".
337
+ // queue a second resolution into "component is frozen". The deadline is
338
+ // checked first, so a click that cannot be sent still closes a conversation
339
+ // whose timer slept through its deadline.
211
340
  async function interact(handle, action, value) {
212
- if (busy) return;
341
+ if (closed() || busy) return;
213
342
  await enqueue("/interact", { handle, action, value });
214
343
  }
215
344
 
345
+ /**
346
+ * Start over. The next send begins a new conversation. Anything queued for
347
+ * the old one is never sent, and anything still open is aborted.
348
+ */
349
+ function reset() {
350
+ generation++;
351
+ aborter.abort();
352
+ aborter = new AbortController();
353
+ clearTimeout(timer);
354
+ Object.assign(state, {
355
+ conversationId: null,
356
+ status: "idle",
357
+ blocks: [],
358
+ context: { messages: [], modelTokens: 0, uiTokens: 0 },
359
+ expiresAt: null,
360
+ expired: null,
361
+ });
362
+ state.surfaces.clear();
363
+ notify({ type: "reset" });
364
+ }
365
+
216
366
  return {
217
367
  state,
218
368
  send,
219
369
  interact,
220
370
  mount,
371
+ reset,
221
372
  subscribe(fn) {
222
373
  listeners.add(fn);
223
374
  return () => listeners.delete(fn);
224
375
  },
225
376
  };
226
377
  }
378
+
379
+ /** "2 hours", "15 minutes" — for a sentence a person reads. */
380
+ function duration(ms) {
381
+ const units = [
382
+ [86_400_000, "day"],
383
+ [3_600_000, "hour"],
384
+ [60_000, "minute"],
385
+ [1_000, "second"],
386
+ ];
387
+ for (const [size, name] of units) {
388
+ if (ms >= size) {
389
+ const n = Math.floor(ms / size);
390
+ return `${n} ${name}${n === 1 ? "" : "s"}`;
391
+ }
392
+ }
393
+ return "under a second";
394
+ }
package/src/transcript.js CHANGED
@@ -46,6 +46,15 @@ function renderBlock(block, chat) {
46
46
  case "error":
47
47
  return h("div", "hai-block hai-error", `error: ${block.message}`);
48
48
 
49
+ // The conversation is closed, so the notice carries the only way forward.
50
+ case "expired": {
51
+ const el = h("div", "hai-block hai-expired");
52
+ const again = h("button", "hai-new", "Start a new conversation");
53
+ again.onclick = () => chat.reset();
54
+ el.append(h("div", "hai-body", block.message), again);
55
+ return el;
56
+ }
57
+
49
58
  // Provenance chrome. The tool row and its surface are one visual unit:
50
59
  // what produced this, with what arguments, and what the model got back.
51
60
  case "tool": {