@haikit/client 0.12.0 → 0.14.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,43 @@ 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 `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
+
121
+ A notice's component gets **no `send`**: nothing in it can reach the server. A
122
+ name the registry doesn't list renders an error card. Like a surface, `mount`
123
+ may return `{ unmount }`. With `createChat`, mount one yourself with
124
+ `chat.mountNotice(seq, element)`.
125
+
89
126
  ## Out-of-date conversations
90
127
 
91
128
  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.12.0",
3
+ "version": "0.14.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
@@ -415,6 +415,22 @@
415
415
  }
416
416
  .hai-shimmer:nth-child(3) { width: 82%; }
417
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; }
418
434
  .hai-surface-error {
419
435
  margin-bottom: 18px;
420
436
  padding: 11px 14px;
package/src/index.d.ts CHANGED
@@ -19,7 +19,17 @@ export interface Progress {
19
19
  total?: number;
20
20
  }
21
21
 
22
- /** Blocks the server emits, plus the three the client synthesises locally. */
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. */
23
33
  export type Block =
24
34
  | { kind: "user"; id: string; text: string }
25
35
  | { kind: "assistant"; id: string; text: string }
@@ -40,7 +50,9 @@ export type Block =
40
50
  /** synthesised from `error` */
41
51
  | { kind: "error"; id: string; message: string }
42
52
  /** synthesised from `expired` — the conversation is closed; offer `reset()` */
43
- | { 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);
44
56
 
45
57
  /** The SSE frame shape. Narrow on `type` to get the specific payload. */
46
58
  export type WireEvent = { type: string } & Record<string, any>;
@@ -90,6 +102,27 @@ export interface ComponentDef<P = any> {
90
102
  */
91
103
  export type Registry = Record<string, ComponentDef<any>>;
92
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
+
93
126
  export interface SurfaceRecord {
94
127
  handle: string;
95
128
  component: string;
@@ -125,6 +158,8 @@ export interface ChatState {
125
158
  * until `reset()` starts a new conversation.
126
159
  */
127
160
  expired: string | null;
161
+ /** Notices the server has sent this conversation, in order. */
162
+ notices: NoticeRecord[];
128
163
  }
129
164
 
130
165
  export interface Chat {
@@ -153,6 +188,12 @@ export interface Chat {
153
188
  * instance it had before.
154
189
  */
155
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;
156
197
  /**
157
198
  * Start a new conversation on the next send. Anything queued for the old one
158
199
  * is never sent, a request still open is aborted — the new conversation
@@ -172,4 +213,9 @@ export interface Chat {
172
213
  subscribe(fn: (state: ChatState, event: WireEvent) => void): () => void;
173
214
  }
174
215
 
175
- 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,25 @@ 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
+ /**
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;
88
+ /** seq → the mounted notice's instance and element */
89
+ const noticeMounts = new Map();
90
+
70
91
  /**
71
92
  * When the request now streaming was sent. The server stamps every surface
72
93
  * it renders after that request arrived, so a freshness deadline counted from
@@ -109,6 +130,11 @@ export function createChat({ endpoint = "/hai", registry }) {
109
130
  // The deadline is checked again here, not only when queued: a long turn
110
131
  // ahead of this request can run past it while it waits.
111
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
+ }
112
138
  sentAt = Date.now();
113
139
  let res = await post(path, body, signal);
114
140
 
@@ -120,8 +146,26 @@ export function createChat({ endpoint = "/hai", registry }) {
120
146
  await new Promise((r) => setTimeout(r, 150));
121
147
  // the deadline can pass during the wait, and reset() or close() come
122
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
+ }
123
155
  res = await post(path, body, signal); // rejects at once if reset() aborted it meanwhile
124
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;
125
169
  // Anything but a second 409 means the server took the request up itself —
126
170
  // even an error — unless the stream says `expired` below. A 409 means it
127
171
  // turned the message away unread.
@@ -172,11 +216,37 @@ export function createChat({ endpoint = "/hai", registry }) {
172
216
  case "hello":
173
217
  state.conversationId = event.conversationId;
174
218
  state.model = event.model;
219
+ if (event.events) listen(event.conversationId);
220
+ break;
221
+
222
+ // From the events stream, in seq order. A reconnect resumes after the
223
+ // last one seen, so anything not past it is one already shown, and is
224
+ // dropped: one comparison, however long the backlog.
225
+ case "notice": {
226
+ if (event.seq <= (state.notices.at(-1)?.seq ?? 0)) return;
227
+ const { type, ...notice } = event;
228
+ state.notices.push(notice);
229
+ const block = { kind: "notice", id: `n:${event.seq}`, ...notice };
230
+ // beside its surface — after any notices already there — or at the end
231
+ const at = event.handle === undefined ? -1 : state.blocks.findIndex((b) => b.id === `ui:${event.handle}`);
232
+ if (at < 0) state.blocks.push(block);
233
+ else {
234
+ let i = at + 1;
235
+ while (state.blocks[i]?.kind === "notice" && state.blocks[i].handle === event.handle) i++;
236
+ state.blocks.splice(i, 0, block);
237
+ }
175
238
  break;
239
+ }
176
240
 
177
- case "block_start":
178
- 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 });
179
248
  break;
249
+ }
180
250
 
181
251
  case "text_delta": {
182
252
  const block = state.blocks.find((b) => b.id === event.id);
@@ -202,6 +272,7 @@ export function createChat({ endpoint = "/hai", registry }) {
202
272
  }
203
273
 
204
274
  case "ui_open":
275
+ if (state.surfaces.has(event.handle)) break; // shown already: a rejoin's catch-up
205
276
  state.surfaces.set(event.handle, {
206
277
  handle: event.handle,
207
278
  component: event.component,
@@ -213,6 +284,14 @@ export function createChat({ endpoint = "/hai", registry }) {
213
284
  element: null,
214
285
  });
215
286
  state.blocks.push({ kind: "ui", id: `ui:${event.handle}`, handle: event.handle, toolId: event.toolId });
287
+ // Notices come on a stream of their own, so one can arrive before the
288
+ // surface it names: it went to the end then, and moves under it now.
289
+ // Moved within the same array, which the transcript would otherwise
290
+ // take for a new conversation's.
291
+ for (const early of state.blocks.filter((b) => b.kind === "notice" && b.handle === event.handle)) {
292
+ state.blocks.splice(state.blocks.indexOf(early), 1);
293
+ state.blocks.push(early);
294
+ }
216
295
  if (typeof event.staleAfterMs === "number") {
217
296
  const at = sentAt + event.staleAfterMs;
218
297
  if (state.expiresAt === null || at < state.expiresAt) {
@@ -266,6 +345,139 @@ export function createChat({ endpoint = "/hai", registry }) {
266
345
  notify(event);
267
346
  }
268
347
 
348
+ // ── notices ────────────────────────────────────────────────────────
349
+ // A GET that stays open, read the same way as a turn's stream. It ends when
350
+ // the conversation does — reset() or close() — and otherwise reconnects,
351
+ // resuming after the last notice it saw. An out-of-date conversation keeps
352
+ // it: a confirmation is still true once fares have gone stale.
353
+
354
+ function listen(conversationId) {
355
+ if (listening === conversationId || shutDown) return;
356
+ listenAborter?.abort();
357
+ listenAborter = new AbortController();
358
+ listening = conversationId;
359
+ void follow(conversationId, generation, listenAborter.signal);
360
+ }
361
+
362
+ async function follow(conversationId, gen, signal) {
363
+ let wait = 1_000;
364
+ while (gen === generation && !signal.aborted) {
365
+ // A connection that delivered a notice, or stayed up a while, was
366
+ // healthy, and the next drop starts the backoff over. One that drops
367
+ // straight after it opens was not, however often the server says 200.
368
+ const opened = Date.now();
369
+ let delivered = false;
370
+ try {
371
+ const last = state.notices.at(-1)?.seq ?? 0;
372
+ const res = await fetch(`${endpoint}/events?conversationId=${encodeURIComponent(conversationId)}`, {
373
+ headers: last ? { "last-event-id": String(last) } : {},
374
+ signal,
375
+ });
376
+ // the server has no such conversation, or sends no notices: stop
377
+ if (res.status === 404) return;
378
+ if (res.ok) {
379
+ const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
380
+ let buffer = "";
381
+ for (;;) {
382
+ const { value, done } = await reader.read();
383
+ if (done) break;
384
+ buffer += value;
385
+ let i;
386
+ while ((i = buffer.indexOf("\n\n")) >= 0) {
387
+ if (gen !== generation) return;
388
+ const frame = buffer.slice(0, i);
389
+ buffer = buffer.slice(i + 2);
390
+ const line = frame.split("\n").find((l) => l.startsWith("data: "));
391
+ if (line) {
392
+ const event = JSON.parse(line.slice(6));
393
+ followTurn(event);
394
+ apply(event);
395
+ delivered = true;
396
+ }
397
+ }
398
+ }
399
+ }
400
+ } catch {
401
+ if (signal.aborted) return finishRemote();
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();
407
+ if (delivered || Date.now() - opened >= HEALTHY_MS) wait = 1_000;
408
+ // Dropped, refused, or the network went away: try again, backing off.
409
+ // reset() or close() ends the wait at once, timer and all.
410
+ await new Promise((resolve) => {
411
+ const done = () => {
412
+ clearTimeout(timer);
413
+ signal.removeEventListener("abort", done);
414
+ resolve();
415
+ };
416
+ const timer = setTimeout(done, wait);
417
+ signal.addEventListener("abort", done);
418
+ });
419
+ wait = Math.min(wait * 2, 30_000);
420
+ }
421
+ }
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
+
444
+ function stopListening() {
445
+ finishRemote();
446
+ listenAborter?.abort();
447
+ listenAborter = null;
448
+ listening = null;
449
+ }
450
+
451
+ /**
452
+ * Mount a notice into an element, from the `notices` registry. As with
453
+ * surfaces, a name the registry doesn't list renders an error card, never
454
+ * an improvised UI. A notice has no `send`: it cannot reach the server.
455
+ */
456
+ function mountNotice(seq, element) {
457
+ const notice = state.notices.find((n) => n.seq === seq);
458
+ if (shutDown || !notice) return null;
459
+ unmountNotice(seq);
460
+
461
+ const definition = notices[notice.name];
462
+ if (!definition) {
463
+ element.textContent = `unknown notice: ${notice.name}`;
464
+ element.className = "hai-surface-error";
465
+ return null;
466
+ }
467
+ element.replaceChildren();
468
+ // a component may return nothing; the promise is an instance or null
469
+ const instance =
470
+ definition.mount(element, notice.payload, { seq, version: notice.version, handle: notice.handle }) ?? null;
471
+ noticeMounts.set(seq, instance);
472
+ return instance;
473
+ }
474
+
475
+ function unmountNotice(seq) {
476
+ const instance = noticeMounts.get(seq);
477
+ noticeMounts.delete(seq);
478
+ attempt(() => instance?.unmount?.());
479
+ }
480
+
269
481
  // ── expiry ─────────────────────────────────────────────────────────
270
482
  // The server refuses every request on an out-of-date conversation. Knowing
271
483
  // the deadline here too means a tab left open greys out on time, rather than
@@ -409,8 +621,11 @@ export function createChat({ endpoint = "/hai", registry }) {
409
621
  generation++;
410
622
  aborter.abort();
411
623
  aborter = new AbortController();
624
+ stopListening();
625
+ cutOff = false;
412
626
  clearTimeout(timer);
413
627
  for (const surface of state.surfaces.values()) unmount(surface);
628
+ for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
414
629
  Object.assign(state, {
415
630
  conversationId: null,
416
631
  status: "idle",
@@ -418,6 +633,7 @@ export function createChat({ endpoint = "/hai", registry }) {
418
633
  context: { messages: [], modelTokens: 0, uiTokens: 0 },
419
634
  expiresAt: null,
420
635
  expired: null,
636
+ notices: [],
421
637
  });
422
638
  state.surfaces.clear();
423
639
  notify({ type: "reset" });
@@ -434,8 +650,10 @@ export function createChat({ endpoint = "/hai", registry }) {
434
650
  shutDown = true;
435
651
  generation++;
436
652
  aborter.abort();
653
+ stopListening();
437
654
  clearTimeout(timer);
438
655
  for (const surface of state.surfaces.values()) unmount(surface);
656
+ for (const seq of [...noticeMounts.keys()]) unmountNotice(seq);
439
657
  // Every subscriber hears it, even after one that throws, and none is held
440
658
  // on to: they are let go of before they are told.
441
659
  const subscribers = [...listeners];
@@ -449,6 +667,7 @@ export function createChat({ endpoint = "/hai", registry }) {
449
667
  send,
450
668
  interact,
451
669
  mount,
670
+ mountNotice,
452
671
  reset,
453
672
  close,
454
673
  subscribe(fn) {
@@ -460,6 +679,16 @@ export function createChat({ endpoint = "/hai", registry }) {
460
679
  };
461
680
  }
462
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
+
689
+ /** An events connection that stays up this long was healthy, whatever it delivered. */
690
+ const HEALTHY_MS = 30_000;
691
+
463
692
  /**
464
693
  * Run `fn`, reporting what it throws without letting it stop the caller: the
465
694
  * error surfaces as an uncaught one would, after the caller has finished.
package/src/transcript.js CHANGED
@@ -376,6 +376,20 @@ function renderBlock(block, chat) {
376
376
  return el;
377
377
  }
378
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
+
379
393
  default:
380
394
  return h("div", "hai-block", JSON.stringify(block));
381
395
  }