@haikit/client 0.11.0 → 0.13.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
@@ -86,6 +86,36 @@ unmounted, and subscribers hear `{ type: "closed" }`. `mountChat` then removes
86
86
  its shell. A page using `renderTranscript` directly calls `closeTranscript(root)`
87
87
  when it removes a transcript, so its resize observer and scroll listener let go.
88
88
 
89
+ ## Notices
90
+
91
+ When the server sends notices (`hai.notify`), its `hello` says so, and the chat
92
+ keeps `GET /hai/events` open for the conversation. A dropped connection
93
+ reconnects, backing off, and resumes after the last notice it saw, so none is
94
+ missed or shown twice. `reset()` and `close()` end it.
95
+
96
+ Each notice lands in `state.notices` and as a `notice` block: right after its
97
+ surface's block when it names one, otherwise at the end. Its component comes
98
+ from a `notices` registry, kept apart from the surface registry so the two
99
+ never clash on a name:
100
+
101
+ ```js
102
+ mountChat({
103
+ root, registry,
104
+ notices: {
105
+ hold_confirmed: {
106
+ mount(el, payload, { seq, handle }) {
107
+ el.textContent = `Fare held · ${payload.flightId} · ref ${payload.reference}`;
108
+ },
109
+ },
110
+ },
111
+ });
112
+ ```
113
+
114
+ A notice's component gets **no `send`**: nothing in it can reach the server. A
115
+ name the registry doesn't list renders an error card. Like a surface, `mount`
116
+ may return `{ unmount }`. With `createChat`, mount one yourself with
117
+ `chat.mountNotice(seq, element)`.
118
+
89
119
  ## Out-of-date conversations
90
120
 
91
121
  The server closes a conversation once any surface passes its freshness window.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haikit/client",
3
- "version": "0.11.0",
3
+ "version": "0.13.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
@@ -1,8 +1,10 @@
1
- import type { Chat, Registry } from "./index.js";
1
+ import type { Chat, NoticeRegistry, Registry } from "./index.js";
2
2
 
3
3
  export interface MountChatOptions {
4
4
  root: HTMLElement;
5
5
  registry: Registry;
6
+ /** Components for the notices the server sends, by notice name. */
7
+ notices?: NoticeRegistry;
6
8
  /** Base path the server's two routes are mounted at. Default `/hai`. */
7
9
  endpoint?: string;
8
10
  title?: string;
package/src/app.js CHANGED
@@ -86,6 +86,7 @@ function dropFavicon() {
86
86
  * @param {{
87
87
  * root: HTMLElement,
88
88
  * registry: Record<string, { mount: Function }>,
89
+ * notices?: Record<string, { mount: Function }>,
89
90
  * endpoint?: string,
90
91
  * title?: string,
91
92
  * subtitle?: string,
@@ -99,6 +100,7 @@ function dropFavicon() {
99
100
  export function mountChat({
100
101
  root,
101
102
  registry,
103
+ notices,
102
104
  endpoint = "/hai",
103
105
  title = "HaiKIT",
104
106
  subtitle = "",
@@ -108,7 +110,7 @@ export function mountChat({
108
110
  emptyText = "",
109
111
  theme,
110
112
  }) {
111
- const chat = createChat({ endpoint, registry });
113
+ const chat = createChat({ endpoint, registry, notices });
112
114
  const id = `hai-${++shells}`;
113
115
 
114
116
  // Unset, the page follows the system. A pin goes on the root, where hai.css
package/src/hai.css CHANGED
@@ -327,7 +327,26 @@
327
327
  font: 12px var(--hai-mono);
328
328
  color: var(--hai-dim);
329
329
  }
330
- .hai-tstatus { color: var(--hai-dim); white-space: nowrap; font-variant-numeric: tabular-nums; }
330
+ .hai-tstatus {
331
+ max-width: 50%;
332
+ overflow: hidden;
333
+ text-overflow: ellipsis;
334
+ color: var(--hai-dim);
335
+ white-space: nowrap;
336
+ font-variant-numeric: tabular-nums;
337
+ }
338
+ /* a running tool's progress: the message gives way before the count does */
339
+ .hai-tprogress { display: flex; min-width: 0; white-space: pre; }
340
+ .hai-tmessage { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
341
+ .hai-tcount { flex: none; }
342
+ /* …and a bar along the bottom edge of its row */
343
+ .hai-tool summary:has(.hai-tbar) { position: relative; }
344
+ .hai-tbar {
345
+ position: absolute;
346
+ inset: auto 0 0 0;
347
+ height: 2px;
348
+ background: linear-gradient(to right, var(--hai-accent) var(--hai-progress, 0%), transparent 0);
349
+ }
331
350
  .hai-chevron { width: 14px; height: 14px; color: var(--hai-dim); transition: transform .15s; }
332
351
  .hai-tool[open] .hai-chevron { transform: rotate(90deg); }
333
352
 
@@ -396,6 +415,22 @@
396
415
  }
397
416
  .hai-shimmer:nth-child(3) { width: 82%; }
398
417
  .hai-shimmer:nth-child(4) { width: 60%; }
418
+ /* a notice: something the server said outside of any request */
419
+ .hai-notice {
420
+ margin-bottom: 18px;
421
+ overflow: hidden;
422
+ border: 1px solid var(--hai-line);
423
+ border-radius: var(--hai-radius);
424
+ background: var(--hai-panel);
425
+ }
426
+ .hai-notice-label {
427
+ padding: 6px 12px;
428
+ border-bottom: 1px solid var(--hai-line);
429
+ background: var(--hai-panel-2);
430
+ font: 11px var(--hai-mono);
431
+ color: var(--hai-dim);
432
+ }
433
+ .hai-notice > .hai-surface-error { margin: 0; border: none; border-radius: 0; }
399
434
  .hai-surface-error {
400
435
  margin-bottom: 18px;
401
436
  padding: 11px 14px;
package/src/index.d.ts CHANGED
@@ -12,18 +12,47 @@ 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 three the client synthesises locally. */
15
+ /** How far a running tool has got, as its `ctx.progress` calls said. */
16
+ export interface Progress {
17
+ message?: string;
18
+ done?: number;
19
+ total?: number;
20
+ }
21
+
22
+ /** A notice the server sent, as the events stream delivered it. */
23
+ export interface NoticeRecord {
24
+ seq: number;
25
+ name: string;
26
+ version: number;
27
+ payload: unknown;
28
+ /** The surface it is shown beside. */
29
+ handle?: string;
30
+ }
31
+
32
+ /** Blocks the server emits, plus the four the client synthesises locally. */
16
33
  export type Block =
17
34
  | { kind: "user"; id: string; text: string }
18
35
  | { kind: "assistant"; id: string; text: string }
19
- | { kind: "tool"; id: string; name: string; input: unknown; status: string; ms?: number; result?: string }
36
+ | {
37
+ kind: "tool";
38
+ id: string;
39
+ name: string;
40
+ input: unknown;
41
+ status: string;
42
+ ms?: number;
43
+ result?: string;
44
+ /** What the tool's `ctx.progress` calls have said so far, merged. */
45
+ progress?: Progress;
46
+ }
20
47
  | { kind: "interaction"; id: string; handle: string; label: string }
21
48
  /** synthesised from `ui_open` — the slot a surface mounts into */
22
49
  | { kind: "ui"; id: string; handle: string; toolId: string }
23
50
  /** synthesised from `error` */
24
51
  | { kind: "error"; id: string; message: string }
25
52
  /** synthesised from `expired` — the conversation is closed; offer `reset()` */
26
- | { kind: "expired"; id: string; message: string };
53
+ | { kind: "expired"; id: string; message: string }
54
+ /** synthesised from `notice` — after its surface's block when it names one, else at the end */
55
+ | ({ kind: "notice"; id: string } & NoticeRecord);
27
56
 
28
57
  /** The SSE frame shape. Narrow on `type` to get the specific payload. */
29
58
  export type WireEvent = { type: string } & Record<string, any>;
@@ -73,6 +102,27 @@ export interface ComponentDef<P = any> {
73
102
  */
74
103
  export type Registry = Record<string, ComponentDef<any>>;
75
104
 
105
+ /** What `mountNotice()` hands your notice component. There is no `send`. */
106
+ export interface NoticeMountCtx {
107
+ seq: number;
108
+ version: number;
109
+ /** The surface it is shown beside, if any. */
110
+ handle?: string;
111
+ }
112
+
113
+ /** Returned by a notice's `mount`. `unmount` is called when it goes away. */
114
+ export interface NoticeInstance {
115
+ unmount?(): void;
116
+ }
117
+
118
+ /** A notice component. `payload` is whatever the notice's `payload` schema produces. */
119
+ export interface NoticeDef<P = any> {
120
+ mount(element: HTMLElement, payload: P, ctx: NoticeMountCtx): NoticeInstance | null | void;
121
+ }
122
+
123
+ /** The notices allowlist, by notice name. Separate from `Registry`, so names never clash. */
124
+ export type NoticeRegistry = Record<string, NoticeDef<any>>;
125
+
76
126
  export interface SurfaceRecord {
77
127
  handle: string;
78
128
  component: string;
@@ -108,6 +158,8 @@ export interface ChatState {
108
158
  * until `reset()` starts a new conversation.
109
159
  */
110
160
  expired: string | null;
161
+ /** Notices the server has sent this conversation, in order. */
162
+ notices: NoticeRecord[];
111
163
  }
112
164
 
113
165
  export interface Chat {
@@ -136,6 +188,12 @@ export interface Chat {
136
188
  * instance it had before.
137
189
  */
138
190
  mount(handle: string, element: HTMLElement): SurfaceInstance | null;
191
+ /**
192
+ * Mounts the notice numbered `seq` into `element`, from the `notices`
193
+ * registry. Null if there is no such notice, or the chat is closed. An
194
+ * unregistered name renders an error card.
195
+ */
196
+ mountNotice(seq: number, element: HTMLElement): NoticeInstance | null;
139
197
  /**
140
198
  * Start a new conversation on the next send. Anything queued for the old one
141
199
  * is never sent, a request still open is aborted — the new conversation
@@ -155,4 +213,9 @@ export interface Chat {
155
213
  subscribe(fn: (state: ChatState, event: WireEvent) => void): () => void;
156
214
  }
157
215
 
158
- export function createChat(options: { endpoint?: string; registry: Registry }): Chat;
216
+ /**
217
+ * When the server sends notices (its `hello` says so), the chat also keeps
218
+ * `GET {endpoint}/events` open for the conversation, resuming after the last
219
+ * notice it saw if the connection drops, until `reset()` or `close()`.
220
+ */
221
+ export function createChat(options: { endpoint?: string; registry: Registry; notices?: NoticeRegistry }): Chat;
package/src/index.js CHANGED
@@ -10,9 +10,9 @@
10
10
  */
11
11
 
12
12
  /**
13
- * @param {{endpoint?: string, registry: Record<string, {mount: Function}>}} options
13
+ * @param {{endpoint?: string, registry: Record<string, {mount: Function}>, notices?: Record<string, {mount: Function}>}} options
14
14
  */
15
- export function createChat({ endpoint = "/hai", registry }) {
15
+ export function createChat({ endpoint = "/hai", registry, notices = {} }) {
16
16
  const listeners = new Set();
17
17
  const state = {
18
18
  conversationId: null,
@@ -31,6 +31,8 @@ export function createChat({ endpoint = "/hai", registry }) {
31
31
  expiresAt: null,
32
32
  /** The notice, once the conversation is out of date. Final until `reset()`. */
33
33
  expired: null,
34
+ /** Notices the server has sent this conversation, in the order they were sent. */
35
+ notices: [],
34
36
  };
35
37
 
36
38
  const notify = (event) => listeners.forEach((fn) => fn(state, event));
@@ -67,6 +69,12 @@ export function createChat({ endpoint = "/hai", registry }) {
67
69
  /** Set by close(): this chat is done for good, and does nothing more. */
68
70
  let shutDown = false;
69
71
 
72
+ /** The events stream's conversation while it is open, and how to stop it. */
73
+ let listening = null;
74
+ let listenAborter = null;
75
+ /** seq → the mounted notice's instance and element */
76
+ const noticeMounts = new Map();
77
+
70
78
  /**
71
79
  * When the request now streaming was sent. The server stamps every surface
72
80
  * it renders after that request arrived, so a freshness deadline counted from
@@ -172,8 +180,28 @@ export function createChat({ endpoint = "/hai", registry }) {
172
180
  case "hello":
173
181
  state.conversationId = event.conversationId;
174
182
  state.model = event.model;
183
+ if (event.events) listen(event.conversationId);
175
184
  break;
176
185
 
186
+ // From the events stream, in seq order. A reconnect resumes after the
187
+ // last one seen, so anything not past it is one already shown, and is
188
+ // dropped: one comparison, however long the backlog.
189
+ case "notice": {
190
+ if (event.seq <= (state.notices.at(-1)?.seq ?? 0)) return;
191
+ const { type, ...notice } = event;
192
+ state.notices.push(notice);
193
+ const block = { kind: "notice", id: `n:${event.seq}`, ...notice };
194
+ // beside its surface — after any notices already there — or at the end
195
+ const at = event.handle === undefined ? -1 : state.blocks.findIndex((b) => b.id === `ui:${event.handle}`);
196
+ if (at < 0) state.blocks.push(block);
197
+ else {
198
+ let i = at + 1;
199
+ while (state.blocks[i]?.kind === "notice" && state.blocks[i].handle === event.handle) i++;
200
+ state.blocks.splice(i, 0, block);
201
+ }
202
+ break;
203
+ }
204
+
177
205
  case "block_start":
178
206
  state.blocks.push({ ...event.block });
179
207
  break;
@@ -190,6 +218,17 @@ export function createChat({ endpoint = "/hai", registry }) {
190
218
  break;
191
219
  }
192
220
 
221
+ // A frame carries only the fields its call named, so it is merged: a
222
+ // tool says what it is doing once, then counts.
223
+ case "progress": {
224
+ const block = state.blocks.find((b) => b.id === event.toolId);
225
+ if (block?.kind === "tool") {
226
+ const { type, toolId, ...fields } = event;
227
+ block.progress = { ...block.progress, ...fields };
228
+ }
229
+ break;
230
+ }
231
+
193
232
  case "ui_open":
194
233
  state.surfaces.set(event.handle, {
195
234
  handle: event.handle,
@@ -202,6 +241,14 @@ export function createChat({ endpoint = "/hai", registry }) {
202
241
  element: null,
203
242
  });
204
243
  state.blocks.push({ kind: "ui", id: `ui:${event.handle}`, handle: event.handle, toolId: event.toolId });
244
+ // Notices come on a stream of their own, so one can arrive before the
245
+ // surface it names: it went to the end then, and moves under it now.
246
+ // Moved within the same array, which the transcript would otherwise
247
+ // take for a new conversation's.
248
+ for (const early of state.blocks.filter((b) => b.kind === "notice" && b.handle === event.handle)) {
249
+ state.blocks.splice(state.blocks.indexOf(early), 1);
250
+ state.blocks.push(early);
251
+ }
205
252
  if (typeof event.staleAfterMs === "number") {
206
253
  const at = sentAt + event.staleAfterMs;
207
254
  if (state.expiresAt === null || at < state.expiresAt) {
@@ -255,6 +302,111 @@ export function createChat({ endpoint = "/hai", registry }) {
255
302
  notify(event);
256
303
  }
257
304
 
305
+ // ── notices ────────────────────────────────────────────────────────
306
+ // A GET that stays open, read the same way as a turn's stream. It ends when
307
+ // the conversation does — reset() or close() — and otherwise reconnects,
308
+ // resuming after the last notice it saw. An out-of-date conversation keeps
309
+ // it: a confirmation is still true once fares have gone stale.
310
+
311
+ function listen(conversationId) {
312
+ if (listening === conversationId || shutDown) return;
313
+ listenAborter?.abort();
314
+ listenAborter = new AbortController();
315
+ listening = conversationId;
316
+ void follow(conversationId, generation, listenAborter.signal);
317
+ }
318
+
319
+ async function follow(conversationId, gen, signal) {
320
+ let wait = 1_000;
321
+ while (gen === generation && !signal.aborted) {
322
+ // A connection that delivered a notice, or stayed up a while, was
323
+ // healthy, and the next drop starts the backoff over. One that drops
324
+ // straight after it opens was not, however often the server says 200.
325
+ const opened = Date.now();
326
+ let delivered = false;
327
+ try {
328
+ const last = state.notices.at(-1)?.seq ?? 0;
329
+ const res = await fetch(`${endpoint}/events?conversationId=${encodeURIComponent(conversationId)}`, {
330
+ headers: last ? { "last-event-id": String(last) } : {},
331
+ signal,
332
+ });
333
+ // the server has no such conversation, or sends no notices: stop
334
+ if (res.status === 404) return;
335
+ if (res.ok) {
336
+ const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
337
+ let buffer = "";
338
+ for (;;) {
339
+ const { value, done } = await reader.read();
340
+ if (done) break;
341
+ buffer += value;
342
+ let i;
343
+ while ((i = buffer.indexOf("\n\n")) >= 0) {
344
+ if (gen !== generation) return;
345
+ const frame = buffer.slice(0, i);
346
+ buffer = buffer.slice(i + 2);
347
+ const line = frame.split("\n").find((l) => l.startsWith("data: "));
348
+ if (line) {
349
+ apply(JSON.parse(line.slice(6)));
350
+ delivered = true;
351
+ }
352
+ }
353
+ }
354
+ }
355
+ } catch {
356
+ if (signal.aborted) return;
357
+ }
358
+ if (delivered || Date.now() - opened >= HEALTHY_MS) wait = 1_000;
359
+ // Dropped, refused, or the network went away: try again, backing off.
360
+ // reset() or close() ends the wait at once, timer and all.
361
+ await new Promise((resolve) => {
362
+ const done = () => {
363
+ clearTimeout(timer);
364
+ signal.removeEventListener("abort", done);
365
+ resolve();
366
+ };
367
+ const timer = setTimeout(done, wait);
368
+ signal.addEventListener("abort", done);
369
+ });
370
+ wait = Math.min(wait * 2, 30_000);
371
+ }
372
+ }
373
+
374
+ function stopListening() {
375
+ listenAborter?.abort();
376
+ listenAborter = null;
377
+ listening = null;
378
+ }
379
+
380
+ /**
381
+ * Mount a notice into an element, from the `notices` registry. As with
382
+ * surfaces, a name the registry doesn't list renders an error card, never
383
+ * an improvised UI. A notice has no `send`: it cannot reach the server.
384
+ */
385
+ function mountNotice(seq, element) {
386
+ const notice = state.notices.find((n) => n.seq === seq);
387
+ if (shutDown || !notice) return null;
388
+ unmountNotice(seq);
389
+
390
+ const definition = notices[notice.name];
391
+ if (!definition) {
392
+ element.textContent = `unknown notice: ${notice.name}`;
393
+ element.className = "hai-surface-error";
394
+ return null;
395
+ }
396
+ element.replaceChildren();
397
+ // a component may return nothing; the promise is an instance or null
398
+ const instance =
399
+ definition.mount(element, notice.payload, { seq, version: notice.version, handle: notice.handle }) ?? null;
400
+ noticeMounts.set(seq, instance);
401
+ return instance;
402
+ }
403
+
404
+ function unmountNotice(seq) {
405
+ const instance = noticeMounts.get(seq);
406
+ noticeMounts.delete(seq);
407
+ attempt(() => instance?.unmount?.());
408
+ }
409
+
258
410
  // ── expiry ─────────────────────────────────────────────────────────
259
411
  // The server refuses every request on an out-of-date conversation. Knowing
260
412
  // the deadline here too means a tab left open greys out on time, rather than
@@ -398,8 +550,10 @@ export function createChat({ endpoint = "/hai", registry }) {
398
550
  generation++;
399
551
  aborter.abort();
400
552
  aborter = new AbortController();
553
+ stopListening();
401
554
  clearTimeout(timer);
402
555
  for (const surface of state.surfaces.values()) unmount(surface);
556
+ for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
403
557
  Object.assign(state, {
404
558
  conversationId: null,
405
559
  status: "idle",
@@ -407,6 +561,7 @@ export function createChat({ endpoint = "/hai", registry }) {
407
561
  context: { messages: [], modelTokens: 0, uiTokens: 0 },
408
562
  expiresAt: null,
409
563
  expired: null,
564
+ notices: [],
410
565
  });
411
566
  state.surfaces.clear();
412
567
  notify({ type: "reset" });
@@ -423,8 +578,10 @@ export function createChat({ endpoint = "/hai", registry }) {
423
578
  shutDown = true;
424
579
  generation++;
425
580
  aborter.abort();
581
+ stopListening();
426
582
  clearTimeout(timer);
427
583
  for (const surface of state.surfaces.values()) unmount(surface);
584
+ for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
428
585
  // Every subscriber hears it, even after one that throws, and none is held
429
586
  // on to: they are let go of before they are told.
430
587
  const subscribers = [...listeners];
@@ -438,6 +595,7 @@ export function createChat({ endpoint = "/hai", registry }) {
438
595
  send,
439
596
  interact,
440
597
  mount,
598
+ mountNotice,
441
599
  reset,
442
600
  close,
443
601
  subscribe(fn) {
@@ -449,6 +607,9 @@ export function createChat({ endpoint = "/hai", registry }) {
449
607
  };
450
608
  }
451
609
 
610
+ /** An events connection that stays up this long was healthy, whatever it delivered. */
611
+ const HEALTHY_MS = 30_000;
612
+
452
613
  /**
453
614
  * Run `fn`, reporting what it throws without letting it stop the caller: the
454
615
  * error surfaces as an uncaught one would, after the caller has finished.
package/src/transcript.js CHANGED
@@ -46,8 +46,13 @@ export function renderTranscript(root, chat) {
46
46
  el = renderBlock(block, chat);
47
47
  // an expanded tool row stays expanded when its status changes
48
48
  if (before?.el.open) el.open = true;
49
+ } else if (block.progress !== before.progress) {
50
+ // Progress changes the row in place. Rebuilt for every frame, a row a
51
+ // keyboard user is on would take their focus up to ten times a second.
52
+ const parts = rowParts.get(el);
53
+ if (parts) showStatus(parts, block, toolStatus(block, chat));
49
54
  }
50
- shown.set(block.id, { el, look });
55
+ shown.set(block.id, { el, look, progress: block.progress });
51
56
  return el;
52
57
  });
53
58
  follow.shown = shown;
@@ -77,7 +82,8 @@ export function closeTranscript(root) {
77
82
  function appearance(block, chat) {
78
83
  // a surface shows a skeleton until its props arrive, and then itself for good
79
84
  if (block.kind === "ui") return chat.state.surfaces.get(block.handle)?.props ? "mounted" : "loading";
80
- if (block.kind === "tool") return JSON.stringify({ ...block, status: toolStatus(block, chat) });
85
+ // progress is left out: it changes the row in place (see renderTranscript)
86
+ if (block.kind === "tool") return JSON.stringify({ ...block, progress: undefined, status: toolStatus(block, chat) });
81
87
  return JSON.stringify(block);
82
88
  }
83
89
 
@@ -116,6 +122,46 @@ const STATUS_TEXT = {
116
122
  error: (ms) => `failed · ${ms} ms`,
117
123
  };
118
124
 
125
+ /** "2/4", or "2" when the tool hasn't said how many in all. */
126
+ function progressCount({ done, total }) {
127
+ return total !== undefined ? `${done ?? 0}/${total}` : done !== undefined ? String(done) : "";
128
+ }
129
+
130
+ /** A tool row's element → its status text and progress bar, which progress changes in place. */
131
+ const rowParts = new WeakMap();
132
+
133
+ /**
134
+ * Fill in what a tool row says of its call. While it runs, that is its
135
+ * progress once it has reported some: the message gives way first on a narrow
136
+ * row, so the count stays readable, and the bar fills once there is a total.
137
+ */
138
+ function showStatus({ status: text, bar }, block, status) {
139
+ const progress = status === "running" ? block.progress : undefined;
140
+ const message = progress?.message ?? "";
141
+ const count = progress ? progressCount(progress) : "";
142
+ if (message || count) {
143
+ text.className = "hai-tstatus hai-tprogress";
144
+ const parts = [];
145
+ if (message) parts.push(h("span", "hai-tmessage", message));
146
+ if (message && count) parts.push(h("span", null, " · "));
147
+ if (count) parts.push(h("span", "hai-tcount", count));
148
+ text.replaceChildren(...parts);
149
+ } else {
150
+ text.className = "hai-tstatus";
151
+ text.textContent = (STATUS_TEXT[status] ?? ((ms) => `${ms} ms`))(block.ms);
152
+ }
153
+
154
+ if (!bar) return;
155
+ const total = progress?.total;
156
+ bar.hidden = !total;
157
+ if (!total) return;
158
+ const done = Math.min(progress.done ?? 0, total);
159
+ bar.setAttribute("aria-valuemax", String(total));
160
+ bar.setAttribute("aria-valuenow", String(done));
161
+ bar.setAttribute("aria-valuetext", [message, `${done} of ${total}`].filter(Boolean).join(" · "));
162
+ bar.style.setProperty("--hai-progress", `${(done / total) * 100}%`);
163
+ }
164
+
119
165
  function statusMark(status) {
120
166
  const mark = h("span", "hai-ticon");
121
167
  if (status === "running") mark.append(h("span", "hai-spinner"));
@@ -283,13 +329,24 @@ function renderBlock(block, chat) {
283
329
  const status = toolStatus(block, chat);
284
330
  const el = h("details", `hai-block hai-tool hai-status-${status}`);
285
331
  const summary = h("summary");
332
+ const parts = { status: h("span", "hai-tstatus"), bar: null };
286
333
  summary.append(
287
334
  statusMark(status),
288
335
  h("span", "hai-tname", block.name),
289
336
  h("span", "hai-targs", JSON.stringify(block.input)),
290
- h("span", "hai-tstatus", (STATUS_TEXT[status] ?? ((ms) => `${ms} ms`))(block.ms)),
337
+ parts.status,
291
338
  icon("chevron", "hai-icon hai-chevron"),
292
339
  );
340
+ // a thin bar along the row's bottom edge, for progress with a total
341
+ if (status === "running") {
342
+ parts.bar = h("span", "hai-tbar");
343
+ parts.bar.setAttribute("role", "progressbar");
344
+ parts.bar.setAttribute("aria-label", `${block.name} progress`);
345
+ parts.bar.setAttribute("aria-valuemin", "0");
346
+ summary.append(parts.bar);
347
+ }
348
+ rowParts.set(el, parts);
349
+ showStatus(parts, block, status);
293
350
  el.append(summary);
294
351
  const detail = h("div", "hai-tool-detail");
295
352
  detail.append(
@@ -319,6 +376,20 @@ function renderBlock(block, chat) {
319
376
  return el;
320
377
  }
321
378
 
379
+ // Something the server told this conversation outside of any request. The
380
+ // label says what kind; the app's component, from the notices registry,
381
+ // draws the rest. A status region, so a screen reader announces it.
382
+ case "notice": {
383
+ const el = h("div", "hai-notice");
384
+ el.setAttribute("role", "status");
385
+ el.dataset.notice = block.name;
386
+ el.append(h("div", "hai-notice-label", `notice · ${block.name}`));
387
+ const body = h("div", "hai-notice-body");
388
+ el.append(body);
389
+ queueMicrotask(() => chat.mountNotice(block.seq, body));
390
+ return el;
391
+ }
392
+
322
393
  default:
323
394
  return h("div", "hai-block", JSON.stringify(block));
324
395
  }