@haikit/client 0.3.1 → 0.5.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.5.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.d.ts CHANGED
@@ -17,7 +17,9 @@ export interface MountChatOptions {
17
17
 
18
18
  /**
19
19
  * Builds the shell, wires the composer, renders the transcript and mounts your
20
- * surfaces. The ten-line path to a running app.
20
+ * surfaces. The ten-line path to a running app. It starts the conversation as
21
+ * soon as it opens, and again after `reset()`, so a server with an init tool
22
+ * runs it before the user types.
21
23
  *
22
24
  * It is a convenience, not the API — everything it does is built on `createChat`
23
25
  * and `renderTranscript`. Dropping to those when this shell stops fitting is the
package/src/app.js CHANGED
@@ -83,11 +83,20 @@ 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
- function renderEmpty() {
99
+ function emptyState() {
91
100
  const empty = h("div", "hai-empty");
92
101
  if (emptyText) empty.append(h("p", null, emptyText));
93
102
  if (suggestions.length) {
@@ -99,23 +108,40 @@ export function mountChat({
99
108
  }
100
109
  empty.append(row);
101
110
  }
102
- transcript.replaceChildren(empty);
111
+ return empty;
103
112
  }
104
113
 
114
+ const renderEmpty = () => transcript.replaceChildren(emptyState());
115
+
105
116
  // ── wiring ─────────────────────────────────────────────────────────
106
- chat.subscribe((state) => {
117
+ chat.subscribe((state, event) => {
118
+ // A new conversation starts at once, so the server's init tool — if it
119
+ // has one — runs before anything is typed.
120
+ if (event.type === "reset") chat.start();
121
+
107
122
  if (state.blocks.length === 0) renderEmpty();
108
- else renderTranscript(transcript, chat);
123
+ else {
124
+ renderTranscript(transcript, chat);
125
+ // what the conversation opened with (an init tool's row, a welcome
126
+ // surface) doesn't replace the suggestions; the user engaging does —
127
+ // a first message, or an answer to something init showed
128
+ if (!state.blocks.some((b) => b.kind === "user" || b.kind === "interaction")) {
129
+ transcript.append(emptyState());
130
+ }
131
+ }
109
132
 
110
133
  modelEl.textContent = state.model;
111
- sendBtn.disabled = state.status === "streaming";
134
+ input.disabled = state.expired !== null;
135
+ sendBtn.disabled = state.status === "streaming" || state.expired !== null;
112
136
  statusEl.dataset.status = state.status;
113
137
  statusEl.textContent =
114
138
  state.status === "streaming" ? "thinking…"
115
139
  : state.status === "awaiting" ? "awaiting your selection in the component above"
116
140
  : "";
117
141
  input.placeholder =
118
- state.status === "awaiting" ? "pick an option above — or type to override" : placeholder;
142
+ state.expired ? "start a new conversation to continue"
143
+ : state.status === "awaiting" ? "pick an option above — or type to override"
144
+ : placeholder;
119
145
 
120
146
  if (inspectorBody) renderInspector(inspectorBody, state.context);
121
147
  });
@@ -129,6 +155,7 @@ export function mountChat({
129
155
  };
130
156
 
131
157
  renderEmpty();
158
+ chat.start();
132
159
  return chat;
133
160
  }
134
161
 
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,48 @@ 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>;
114
+ /**
115
+ * Start the conversation before the user types, so a server with an init
116
+ * tool runs it now and anything it shows is already there. Does nothing once
117
+ * this chat has a conversation, and a server without an init tool creates
118
+ * nothing. `mountChat` calls it when it opens and after `reset()`.
119
+ */
120
+ start(): Promise<boolean>;
85
121
  interact(handle: string, action: string, value: unknown): Promise<void>;
86
122
  /** Mounts the surface for `handle` into `element`. Null if props have not arrived. */
87
123
  mount(handle: string, element: HTMLElement): SurfaceInstance | null;
124
+ /**
125
+ * Start a new conversation on the next send. Anything queued for the old one
126
+ * is never sent, and a request still open is aborted — the new conversation
127
+ * never waits behind it. Subscribers are notified with `{ type: "reset" }`.
128
+ */
129
+ reset(): void;
88
130
  /** Returns an unsubscribe function. */
89
131
  subscribe(fn: (state: ChatState, event: WireEvent) => void): () => void;
90
132
  }
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,93 @@ 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 });
333
+ }
334
+
335
+ /**
336
+ * Start the conversation before the user types, so a server with an init
337
+ * tool runs it now and whatever it shows is there first. Does nothing once
338
+ * this chat has a conversation. A server without an init tool answers with
339
+ * an empty stream and creates nothing; the first message starts the
340
+ * conversation, as it always has. Resolves like send().
341
+ */
342
+ async function start() {
343
+ if (state.conversationId || closed()) return false;
344
+ return enqueue("/start", {});
206
345
  }
207
346
 
208
347
  // A click carries nothing the user authored, and stacking them is worse than
209
348
  // dropping them: a double-click on a picker row would resolve it and then
210
- // queue a second resolution into "component is frozen".
349
+ // queue a second resolution into "component is frozen". The deadline is
350
+ // checked first, so a click that cannot be sent still closes a conversation
351
+ // whose timer slept through its deadline.
211
352
  async function interact(handle, action, value) {
212
- if (busy) return;
353
+ if (closed() || busy) return;
213
354
  await enqueue("/interact", { handle, action, value });
214
355
  }
215
356
 
357
+ /**
358
+ * Start over. The next send begins a new conversation. Anything queued for
359
+ * the old one is never sent, and anything still open is aborted.
360
+ */
361
+ function reset() {
362
+ generation++;
363
+ aborter.abort();
364
+ aborter = new AbortController();
365
+ clearTimeout(timer);
366
+ Object.assign(state, {
367
+ conversationId: null,
368
+ status: "idle",
369
+ blocks: [],
370
+ context: { messages: [], modelTokens: 0, uiTokens: 0 },
371
+ expiresAt: null,
372
+ expired: null,
373
+ });
374
+ state.surfaces.clear();
375
+ notify({ type: "reset" });
376
+ }
377
+
216
378
  return {
217
379
  state,
380
+ start,
218
381
  send,
219
382
  interact,
220
383
  mount,
384
+ reset,
221
385
  subscribe(fn) {
222
386
  listeners.add(fn);
223
387
  return () => listeners.delete(fn);
224
388
  },
225
389
  };
226
390
  }
391
+
392
+ /** "2 hours", "15 minutes" — for a sentence a person reads. */
393
+ function duration(ms) {
394
+ const units = [
395
+ [86_400_000, "day"],
396
+ [3_600_000, "hour"],
397
+ [60_000, "minute"],
398
+ [1_000, "second"],
399
+ ];
400
+ for (const [size, name] of units) {
401
+ if (ms >= size) {
402
+ const n = Math.floor(ms / size);
403
+ return `${n} ${name}${n === 1 ? "" : "s"}`;
404
+ }
405
+ }
406
+ return "under a second";
407
+ }
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": {