@haikit/client 0.22.0 → 0.23.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
@@ -13,7 +13,7 @@ API — it is built from `createChat` + `renderTranscript`, and dropping to thos
13
13
  when the default shell stops fitting is the expected path.
14
14
 
15
15
  `hai.css` styles **only what the framework renders** — transcript blocks, tool
16
- rows, the surface container, shell, composer, debug drawer. It deliberately does
16
+ rows, the surface container, shell, composer, debug drawer, dock. It deliberately does
17
17
  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.
@@ -44,6 +44,13 @@ is parked on its surface, `resolved` once that surface has been answered or
44
44
  typed over, or how long it took. Expand it to see the input and the
45
45
  `tool_result`, which is everything the model received.
46
46
 
47
+ Every surface has a **Dock** button on its bar. Docked, it waits on a shelf
48
+ above the message box and opens from there as a window, one at a time; **Put
49
+ back** or **Remove from dock** returns it to its place. It's moved, not
50
+ mounted again, so your component keeps its state. A docked question stays
51
+ amber on the shelf, and when the shelf is full the oldest go into a pile.
52
+ `dock: false` leaves it out.
53
+
47
54
  It is light or dark with the system. To pin it, put `data-hai-theme="dark"` (or
48
55
  `"light"`) on the `<html>` element, or pass `theme: "dark"` to `mountChat`.
49
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haikit/client",
3
- "version": "0.22.0",
3
+ "version": "0.23.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
@@ -27,6 +27,14 @@ export interface MountChatOptions {
27
27
  * dark setting, or a `data-hai-theme` already in the page's markup.
28
28
  */
29
29
  theme?: "light" | "dark";
30
+ /**
31
+ * Let the reader dock surfaces. Default true. Each surface gets a bar with
32
+ * its name and a Dock button, which moves it out of the transcript onto a
33
+ * shelf above the message box; from there it opens as a window, one at a
34
+ * time, and Put back or Remove from dock returns it to its place. `false`
35
+ * shows surfaces as `renderTranscript` does without `onDock`.
36
+ */
37
+ dock?: boolean;
30
38
  /** Passed to `createChat`: how long the events stream may be silent before the chat polls. Default 8000 ms. */
31
39
  eventsFallbackMs?: number;
32
40
  /** Passed to `createChat`: how long each poll asks the server to hold. Default 15000 ms. */
package/src/app.js CHANGED
@@ -95,6 +95,7 @@ function dropFavicon() {
95
95
  * placeholder?: string,
96
96
  * emptyText?: string,
97
97
  * theme?: "light" | "dark",
98
+ * dock?: boolean,
98
99
  * eventsFallbackMs?: number,
99
100
  * pollHoldMs?: number,
100
101
  * }} options
@@ -112,6 +113,7 @@ export function mountChat({
112
113
  placeholder = "message…",
113
114
  emptyText = "",
114
115
  theme,
116
+ dock = true,
115
117
  eventsFallbackMs,
116
118
  pollHoldMs,
117
119
  }) {
@@ -165,6 +167,17 @@ export function mountChat({
165
167
  const main = h("div", `hai-main${inspector ? " has-inspector" : ""}`);
166
168
  main.append(chatPane);
167
169
 
170
+ const wideQuery = typeof matchMedia === "function" ? matchMedia(WIDE) : null;
171
+ const wide = () => !wideQuery || wideQuery.matches;
172
+
173
+ // Before the drawer, so the dock hears Escape first: it closes what the
174
+ // reader is in, and the drawer what's left.
175
+ const docked = dock
176
+ ? createDock({ chat, id, shell, chatPane, transcript, composer, statusEl, wide, wideQuery,
177
+ drawerOpen: () => main.classList.contains("inspector-open"),
178
+ onChange: () => showWaiting() })
179
+ : null;
180
+
168
181
  let inspectorBody = null;
169
182
  let ctxCount = null;
170
183
  let releaseDrawer = () => {};
@@ -195,8 +208,6 @@ export function mountChat({
195
208
  toggle.setAttribute("aria-controls", aside.id);
196
209
  actions.append(toggle);
197
210
 
198
- const wideQuery = typeof matchMedia === "function" ? matchMedia(WIDE) : null;
199
- const wide = () => !wideQuery || wideQuery.matches;
200
211
  const isOpen = () => main.classList.contains("inspector-open");
201
212
  // Over the chat, on a narrow screen, the drawer is all there is to reach:
202
213
  // the chat behind it is inert until it closes or the screen widens.
@@ -322,17 +333,20 @@ export function mountChat({
322
333
  // init tool's row, a welcome surface) doesn't replace them.
323
334
  const engaged = () => chat.state.blocks.some((b) => b.kind === "user" || b.kind === "interaction");
324
335
  const render = () => {
325
- renderTranscript(transcript, chat);
336
+ renderTranscript(transcript, chat, docked ? { onDock: docked.onDock } : {});
326
337
  if (!engaged()) transcript.append(emptyState());
338
+ docked?.sync();
327
339
  };
328
340
 
329
341
  // ── status ─────────────────────────────────────────────────────────
330
- // A live region: it changes only when the status does, so a screen reader
331
- // hears "Thinking" once a turn, not once per streamed word.
342
+ // A live region: it changes only when the status does, or where the
343
+ // question it waits on is, so a screen reader hears "Thinking" once a turn,
344
+ // not once per streamed word.
332
345
  let shownStatus = null;
333
346
  function showStatus(status) {
334
- if (status === shownStatus) return;
335
- shownStatus = status;
347
+ const where = status === "awaiting" && docked?.waiting() ? "in the dock" : "above";
348
+ if (`${status} ${where}` === shownStatus) return;
349
+ shownStatus = `${status} ${where}`;
336
350
  statusEl.dataset.status = status;
337
351
  statusEl.replaceChildren();
338
352
  if (status === "streaming") {
@@ -341,15 +355,27 @@ export function mountChat({
341
355
  dots.append(h("i"), h("i"), h("i"));
342
356
  statusEl.append(dots, "Thinking");
343
357
  } else if (status === "awaiting") {
344
- statusEl.append(h("span", "hai-pulse"), "Waiting on your selection above");
358
+ statusEl.append(h("span", "hai-pulse"), `Waiting on your selection ${where}`);
345
359
  }
346
360
  }
347
361
 
362
+ // The status line and the message box say where the question waiting on the
363
+ // reader is: above, or in the dock.
364
+ function showWaiting() {
365
+ const { state } = chat;
366
+ showStatus(state.expired ? "idle" : state.status);
367
+ input.placeholder =
368
+ state.expired ? "start a new conversation to continue"
369
+ : state.status === "awaiting" ? `pick an option ${docked?.waiting() ? "in the dock" : "above"} — or type to override`
370
+ : placeholder;
371
+ }
372
+
348
373
  // ── wiring ─────────────────────────────────────────────────────────
349
374
  chat.subscribe((state, event) => {
350
375
  // chat.close(): the surfaces are unmounted and the chat lets go of this
351
376
  // listener; the shell goes too, and nothing of it stays observed.
352
377
  if (event.type === "closed") {
378
+ docked?.release();
353
379
  closeTranscript(transcript);
354
380
  shell.remove();
355
381
  releaseDrawer();
@@ -359,8 +385,12 @@ export function mountChat({
359
385
  }
360
386
 
361
387
  // A new conversation starts at once, so the server's init tool — if it
362
- // has one — runs before anything is typed.
363
- if (event.type === "reset") start();
388
+ // has one — runs before anything is typed. The last one's surfaces are
389
+ // gone, docked ones too.
390
+ if (event.type === "reset") {
391
+ docked?.clear();
392
+ start();
393
+ }
364
394
 
365
395
  render();
366
396
 
@@ -371,11 +401,7 @@ export function mountChat({
371
401
  newBtn.disabled = !engaged() && !state.expired && !state.conversationId;
372
402
  input.disabled = state.expired !== null;
373
403
  sendBtn.disabled = state.status === "streaming" || state.expired !== null;
374
- showStatus(state.expired ? "idle" : state.status);
375
- input.placeholder =
376
- state.expired ? "start a new conversation to continue"
377
- : state.status === "awaiting" ? "pick an option above — or type to override"
378
- : placeholder;
404
+ showWaiting();
379
405
 
380
406
  if (inspectorBody) {
381
407
  renderInspector(inspectorBody, state.context);
@@ -400,6 +426,414 @@ export function mountChat({
400
426
  return chat;
401
427
  }
402
428
 
429
+ /**
430
+ * The dock: a shelf on top of the composer that a surface goes to when its
431
+ * Dock button is pressed, and a window above it where one docked surface at a
432
+ * time opens. Docking moves the surface's element, never mounts it again, so
433
+ * its component keeps its state; an iframe or video inside starts over, as
434
+ * it would anywhere a page moves one. The surface's card stays in the
435
+ * transcript, where Put back returns it.
436
+ *
437
+ * When the shelf's row is full, the oldest go into a pile at its end, which
438
+ * lists them. A surface waiting on the reader, or open, stays in the row.
439
+ */
440
+ function createDock({ chat, id, shell, chatPane, transcript, composer, statusEl, wide, wideQuery, drawerOpen, onChange }) {
441
+ /** Docked surfaces, oldest first. */
442
+ const entries = [];
443
+ /** The one open in the window. */
444
+ let open = null;
445
+
446
+ // ── the shelf ────────────────────────────────────────────────────────
447
+ const shelf = h("div", "hai-dock");
448
+ shelf.hidden = true;
449
+ shelf.setAttribute("role", "toolbar");
450
+ shelf.setAttribute("aria-label", "Docked surfaces");
451
+ const count = h("span", "hai-dock-count");
452
+ count.setAttribute("aria-hidden", "true");
453
+ const row = h("div", "hai-dock-row");
454
+ const pile = h("span", "hai-dock-pile");
455
+ pile.hidden = true;
456
+ const pileBtn = h("button", "hai-dock-item");
457
+ pileBtn.type = "button";
458
+ pileBtn.setAttribute("aria-expanded", "false");
459
+ pileBtn.setAttribute("aria-controls", `${id}-pile`);
460
+ const pileText = h("span");
461
+ const pileMark = changedMark();
462
+ pileBtn.append(icon("stack"), pileText, pileMark);
463
+ const list = h("div", "hai-pile-list");
464
+ list.id = `${id}-pile`;
465
+ list.hidden = true;
466
+ pile.append(pileBtn, list);
467
+ shelf.append(count, row, pile);
468
+ composer.prepend(shelf);
469
+
470
+ // ── the window ───────────────────────────────────────────────────────
471
+ const win = h("section", "hai-window");
472
+ win.id = `${id}-window`;
473
+ win.hidden = true;
474
+ win.tabIndex = -1;
475
+ win.setAttribute("role", "dialog");
476
+ win.setAttribute("aria-labelledby", `${id}-window-name`);
477
+ const winName = h("span", "hai-window-name");
478
+ winName.id = `${id}-window-name`;
479
+ const winTool = h("span", "hai-window-tool");
480
+ const winBadge = h("span", "hai-tstatus hai-window-badge");
481
+ const remove = h("button", "hai-bar-btn hai-bar-icon");
482
+ remove.type = "button";
483
+ remove.title = "Remove from dock";
484
+ remove.append(icon("undock"));
485
+ const minimise = h("button", "hai-bar-btn hai-bar-icon");
486
+ minimise.type = "button";
487
+ minimise.title = "Dock";
488
+ minimise.append(icon("dock"));
489
+ const winBar = h("div", "hai-window-bar");
490
+ winBar.append(icon("window"), winName, winTool, winBadge, remove, minimise);
491
+ const winBody = h("div", "hai-window-body");
492
+ win.append(winBar, winBody);
493
+
494
+ // what docking did, for a screen reader: the button pressed has gone
495
+ const live = h("div", "hai-dock-live");
496
+ live.setAttribute("role", "status");
497
+ chatPane.append(win, live);
498
+
499
+ // ── each surface, as it is now ───────────────────────────────────────
500
+ const surfaceOf = (entry) => chat.state.surfaces.get(entry.block.handle);
501
+ const nameOf = (entry) => surfaceOf(entry)?.component ?? "surface";
502
+ const toolOf = (entry) => chat.state.blocks.find((b) => b.kind === "tool" && b.id === entry.block.toolId)?.name;
503
+ // a question still open waits on the reader, as its tool row says
504
+ const statusOf = (entry) => {
505
+ if (chat.state.expired) return "expired";
506
+ const surface = surfaceOf(entry);
507
+ return surface?.mode === "elicit" && surface.state !== "frozen" ? "awaiting" : "live";
508
+ };
509
+
510
+ function onDock({ handle, card, element }) {
511
+ if (entries.some((e) => e.card === card)) return;
512
+ const block = chat.state.blocks.find((b) => b.kind === "ui" && b.handle === handle);
513
+ if (!block) return;
514
+ const entry = { block, card, element, bar: card.firstElementChild, props: surfaceOf({ block })?.props, changed: false, stacked: false };
515
+
516
+ // what stays in the transcript, in the surface's place
517
+ entry.stubText = h("span", "hai-docked-text");
518
+ entry.back = h("button", "hai-bar-btn hai-bar-icon");
519
+ entry.back.type = "button";
520
+ entry.back.title = "Put back";
521
+ entry.back.append(icon("undock"));
522
+ entry.back.onclick = () => undock(entry, true);
523
+ const stub = h("div", "hai-docked");
524
+ stub.append(entry.stubText, entry.back);
525
+
526
+ // and its button on the shelf
527
+ entry.item = h("button", "hai-dock-item");
528
+ entry.item.type = "button";
529
+ entry.item.setAttribute("aria-controls", win.id);
530
+ entry.itemName = h("span");
531
+ entry.dot = h("span", "hai-dock-dot");
532
+ entry.dot.setAttribute("aria-hidden", "true");
533
+ entry.mark = changedMark();
534
+ entry.item.append(icon("window"), entry.itemName, entry.dot, entry.mark);
535
+ entry.item.onclick = () => (open === entry ? close(true) : show(entry));
536
+ row.append(entry.item);
537
+
538
+ element.hidden = true;
539
+ winBody.append(element);
540
+ card.replaceChildren(stub);
541
+ card.dataset.docked = "";
542
+ entries.push(entry);
543
+ sync();
544
+ // the reader stays where they were, on the button that undoes it
545
+ entry.back.focus();
546
+ live.textContent = `${nameOf(entry)} docked`;
547
+ }
548
+
549
+ function undock(entry, focus) {
550
+ if (open === entry) close(false);
551
+ entries.splice(entries.indexOf(entry), 1);
552
+ entry.item.remove();
553
+ entry.element.hidden = false;
554
+ entry.card.replaceChildren(entry.bar, entry.element);
555
+ delete entry.card.dataset.docked;
556
+ sync();
557
+ if (focus) {
558
+ entry.bar.querySelector("button")?.focus();
559
+ entry.card.scrollIntoView?.({ block: "nearest" });
560
+ }
561
+ live.textContent = `${nameOf(entry)} is back in the chat`;
562
+ }
563
+
564
+ function show(entry) {
565
+ if (open && open !== entry) open.element.hidden = true;
566
+ // one opened from the pile comes into the row, as the newest
567
+ if (entry.stacked) {
568
+ entries.push(...entries.splice(entries.indexOf(entry), 1));
569
+ row.append(entry.item);
570
+ }
571
+ open = entry;
572
+ entry.changed = false;
573
+ entry.element.hidden = false;
574
+ measure();
575
+ win.hidden = false;
576
+ closePile(false);
577
+ sync();
578
+ win.focus();
579
+ }
580
+
581
+ function close(focus) {
582
+ if (!open) return;
583
+ const entry = open;
584
+ open = null;
585
+ entry.element.hidden = true;
586
+ win.hidden = true;
587
+ sync();
588
+ if (focus) (entry.stacked ? pileBtn : entry.item).focus();
589
+ }
590
+
591
+ remove.onclick = () => open && undock(open, true);
592
+ minimise.onclick = () => close(true);
593
+
594
+ // ── keeping up ───────────────────────────────────────────────────────
595
+ // After every event the chat renders, and after every change here.
596
+ function sync() {
597
+ for (const entry of entries) {
598
+ // new props while docked and closed: the reader hasn't seen them
599
+ const props = surfaceOf(entry)?.props;
600
+ if (props !== entry.props) {
601
+ entry.changed ||= entry !== open;
602
+ entry.props = props;
603
+ }
604
+ const name = nameOf(entry);
605
+ const status = statusOf(entry);
606
+ const isOpen = open === entry;
607
+ setText(entry.itemName, name);
608
+ setAttr(entry.item, "data-status", status);
609
+ setAttr(entry.item, "aria-expanded", String(isOpen));
610
+ setAttr(
611
+ entry.item,
612
+ "aria-label",
613
+ [name, status === "awaiting" && "awaiting you", status === "expired" && "out of date", entry.changed && "changed"]
614
+ .filter(Boolean)
615
+ .join(", "),
616
+ );
617
+ setHidden(entry.dot, status !== "awaiting");
618
+ setHidden(entry.mark, !entry.changed);
619
+ const text = `${name} ${isOpen ? "is open in a window" : "is in the dock"}`;
620
+ if (entry.stubText.textContent !== text) entry.stubText.replaceChildren(icon("dock"), text);
621
+ setAttr(entry.back, "aria-label", `Put ${name} back`);
622
+ }
623
+ setHidden(shelf, entries.length === 0);
624
+ setText(count, `docked · ${entries.length}`);
625
+
626
+ if (open) {
627
+ const name = nameOf(open);
628
+ const tool = toolOf(open);
629
+ const status = statusOf(open);
630
+ setAttr(win, "data-status", status);
631
+ setText(winName, name);
632
+ setText(winTool, tool ? `· ${tool}` : "");
633
+ setHidden(winBadge, status === "live");
634
+ setText(winBadge, status === "awaiting" ? "awaiting you" : "out of date");
635
+ setAttr(remove, "aria-label", `Remove ${name} from the dock`);
636
+ setAttr(minimise, "aria-label", `Dock ${name}`);
637
+ }
638
+ chatPane.classList.toggle("window-open", open !== null);
639
+ // over the chat, on a narrow screen, the window is all there is to reach
640
+ const inert = open !== null && !wide();
641
+ if (transcript.inert !== inert) transcript.inert = inert;
642
+ stack();
643
+ onChange();
644
+ }
645
+
646
+ // Every streamed word reaches sync, so it writes only what has changed: a
647
+ // dock that stays the same costs no style or layout work.
648
+ function setText(el, text) {
649
+ if (el.textContent !== text) el.textContent = text;
650
+ }
651
+ function setAttr(el, name, value) {
652
+ if (el.getAttribute(name) !== value) el.setAttribute(name, value);
653
+ }
654
+ function setHidden(el, hidden) {
655
+ if (el.hidden !== hidden) el.hidden = hidden;
656
+ }
657
+
658
+ // The window sits above the status line and the composer, or on a narrow
659
+ // screen over everything down to the composer: their heights, as they are.
660
+ function measure() {
661
+ chatPane.style.setProperty("--hai-composer-height", `${composer.offsetHeight}px`);
662
+ chatPane.style.setProperty("--hai-foot", `${composer.offsetHeight + statusEl.offsetHeight}px`);
663
+ }
664
+
665
+ // Into the pile, oldest first, until the row fits. Measured, so it does
666
+ // nothing where nothing is laid out.
667
+ let listed = "";
668
+ /** Each piled surface's row in the list, as last built. */
669
+ const rows = new Map();
670
+ // Measured again only when what decides it changes: which surfaces are
671
+ // docked, in what order, their names, their dots, which is open; or the
672
+ // room, when the observer below says so.
673
+ let measured = null;
674
+ function stack(force = false) {
675
+ const focused = document.activeElement;
676
+ const inputs = entries.map((e) => `${e.block.id}:${nameOf(e)}:${statusOf(e) === "awaiting"}:${e === open}`).join(" ");
677
+ if (force || inputs !== measured) {
678
+ measured = inputs;
679
+ for (const entry of entries) entry.item.hidden = false;
680
+ pile.hidden = true;
681
+ const stackable = entries.filter((e) => e !== open && statusOf(e) !== "awaiting");
682
+ let piled = 0;
683
+ while (row.scrollWidth > row.clientWidth && piled < stackable.length) {
684
+ stackable[piled++].item.hidden = true;
685
+ pile.hidden = false;
686
+ pileText.textContent = `${piled} more`;
687
+ }
688
+ for (const entry of entries) entry.stacked = stackable.indexOf(entry) > -1 && stackable.indexOf(entry) < piled;
689
+ // an item stacked under the reader's focus hands it to the pile, and a
690
+ // pile that goes hands it to the item now at the row's end, where it was
691
+ if (entries.some((e) => e.stacked && e.item === focused)) pileBtn.focus();
692
+ else if (focused === pileBtn && pile.hidden) entries.filter((e) => !e.item.hidden).at(-1)?.item.focus();
693
+ }
694
+ const stacked = entries.filter((e) => e.stacked);
695
+ const changed = stacked.filter((e) => e.changed).length;
696
+ setHidden(pileMark, changed === 0);
697
+ setAttr(pileBtn, "aria-label", `${stacked.length} more docked surfaces${changed ? `, ${changed} changed` : ""}`);
698
+ if (!stacked.length) closePile(false);
699
+
700
+ // The list, rebuilt only when what it shows changes: which surfaces, not
701
+ // just their names, since two can share a component. A reader may be in
702
+ // it, and keeps their place on the same surface's row.
703
+ const key = stacked.map((e) => `${e.block.id}:${nameOf(e)}:${e.changed}`).join(" ");
704
+ if (key === listed) return;
705
+ listed = key;
706
+ const focusedEntry = [...rows].find(([, row]) => row === focused)?.[0];
707
+ rows.clear();
708
+ list.replaceChildren(h("div", "hai-pile-head", `stacked · ${stacked.length} · oldest first`));
709
+ for (const entry of stacked) {
710
+ const b = h("button", "hai-pile-row");
711
+ b.type = "button";
712
+ b.append(icon("window"), h("span", "hai-pile-name", nameOf(entry)));
713
+ if (entry.changed) {
714
+ const mark = changedMark();
715
+ mark.hidden = false;
716
+ b.append(mark);
717
+ }
718
+ b.setAttribute("aria-label", `${nameOf(entry)}${entry.changed ? ", changed" : ""}`);
719
+ b.onclick = () => show(entry);
720
+ rows.set(entry, b);
721
+ list.append(b);
722
+ }
723
+ // the reader stays on the same surface: its new row, or its item on the
724
+ // shelf if it has left the pile
725
+ if (focusedEntry) (rows.get(focusedEntry) ?? (focusedEntry.stacked ? null : focusedEntry.item))?.focus();
726
+ }
727
+
728
+ function openPile() {
729
+ // no taller than the room above the shelf, in the chat: it scrolls
730
+ const room = pile.getBoundingClientRect().top - chatPane.getBoundingClientRect().top - 30;
731
+ list.style.maxHeight = room > 0 ? `${room}px` : "";
732
+ list.hidden = false;
733
+ pileBtn.setAttribute("aria-expanded", "true");
734
+ list.querySelector("button")?.focus();
735
+ }
736
+
737
+ function closePile(focus) {
738
+ if (list.hidden) return;
739
+ list.hidden = true;
740
+ pileBtn.setAttribute("aria-expanded", "false");
741
+ if (focus) pileBtn.focus();
742
+ }
743
+
744
+ pileBtn.onclick = () => (list.hidden ? openPile() : closePile(true));
745
+
746
+ // ── keys and clicks ──────────────────────────────────────────────────
747
+ // Arrows move along the shelf, and up and down the pile's list.
748
+ shelf.addEventListener("keydown", (e) => {
749
+ const inList = list.contains(e.target);
750
+ const keys = inList ? ["ArrowUp", "ArrowDown", "Home", "End"] : ["ArrowLeft", "ArrowRight", "Home", "End"];
751
+ if (!keys.includes(e.key)) return;
752
+ const buttons = inList
753
+ ? [...list.querySelectorAll("button")]
754
+ : [...shelf.querySelectorAll(".hai-dock-item")].filter((b) => !b.hidden && !b.closest("[hidden]"));
755
+ const at = buttons.indexOf(e.target);
756
+ if (at < 0) return;
757
+ e.preventDefault();
758
+ const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : -1;
759
+ const next = e.key === "Home" ? 0 : e.key === "End" ? buttons.length - 1 : (at + step + buttons.length) % buttons.length;
760
+ buttons[next].focus();
761
+ });
762
+
763
+ // Escape closes the pile's list, then the window: from inside the dock, or
764
+ // from anywhere while the drawer is closed. With the drawer open, Escape
765
+ // from outside the dock is the drawer's, even with the list open behind it.
766
+ // Unless something there used the key first, such as a surface closing a
767
+ // menu of its own.
768
+ const onKey = (e) => {
769
+ if (e.key !== "Escape" || e.defaultPrevented) return;
770
+ const inside = win.contains(document.activeElement) || shelf.contains(document.activeElement);
771
+ if (!inside && drawerOpen()) return;
772
+ if (!list.hidden) {
773
+ e.preventDefault();
774
+ closePile(inside);
775
+ return;
776
+ }
777
+ if (!open) return;
778
+ e.preventDefault();
779
+ close(inside);
780
+ };
781
+ shell.addEventListener("keydown", onKey);
782
+
783
+ // a click away from the pile's list closes it, and on a narrow screen a tap
784
+ // on the chat the window covers docks it again, as a tap outside a sheet does
785
+ shell.addEventListener("pointerdown", (e) => {
786
+ if (!list.hidden && !pile.contains(e.target)) closePile(false);
787
+ });
788
+ chatPane.addEventListener("click", (e) => {
789
+ if (e.target === chatPane && transcript.inert) close(true);
790
+ });
791
+
792
+ // Across the breakpoint the shelf's count comes or goes, so the row's room
793
+ // changes though the composer's width may not, and no resize says so.
794
+ const onResize = () => {
795
+ measured = null;
796
+ sync();
797
+ };
798
+ wideQuery?.addEventListener("change", onResize);
799
+ // the room changed: the window's place, and what fits on the shelf
800
+ const resized = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => (measure(), stack(true)));
801
+ resized?.observe(composer);
802
+ resized?.observe(statusEl);
803
+
804
+ return {
805
+ onDock,
806
+ sync,
807
+ /** Whether a surface waiting on the reader is in the dock. */
808
+ waiting: () => entries.some((e) => statusOf(e) === "awaiting"),
809
+ /** A new conversation: the last one's surfaces are gone, docked ones too. */
810
+ clear() {
811
+ closePile(false);
812
+ open = null;
813
+ win.hidden = true;
814
+ for (const entry of entries) {
815
+ entry.item.remove();
816
+ entry.element.remove();
817
+ }
818
+ entries.length = 0;
819
+ sync();
820
+ },
821
+ release() {
822
+ shell.removeEventListener("keydown", onKey);
823
+ wideQuery?.removeEventListener("change", onResize);
824
+ resized?.disconnect();
825
+ },
826
+ };
827
+ }
828
+
829
+ /** The accent square a docked surface wears while it has changed unseen. */
830
+ function changedMark() {
831
+ const mark = h("span", "hai-dock-changed");
832
+ mark.setAttribute("aria-hidden", "true");
833
+ mark.hidden = true;
834
+ return mark;
835
+ }
836
+
403
837
  /** A header button: an icon, and a label that narrow screens keep for screen
404
838
  * readers only. */
405
839
  function button(className, iconName, label) {
package/src/hai.css CHANGED
@@ -205,6 +205,7 @@
205
205
  /* the conversation is a centred column; the scrollbar stays at the edge */
206
206
  .hai-chat {
207
207
  --hai-gutter: max(16px, calc((100% - var(--hai-column)) / 2));
208
+ position: relative;
208
209
  display: flex;
209
210
  flex-direction: column;
210
211
  min-width: 0;
@@ -312,9 +313,11 @@
312
313
  background: var(--hai-panel-2);
313
314
  overflow: hidden;
314
315
  }
315
- .hai-tool:has(+ .hai-surface) { margin-bottom: 0; border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
316
+ .hai-tool:has(+ .hai-surface),
317
+ .hai-tool:has(+ .hai-dockable) { margin-bottom: 0; border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
316
318
  /* a row and its live surface are one card, under one shadow */
317
- .hai-tool:has(+ .hai-surface:not([data-expired])) { box-shadow: var(--hai-shadow); }
319
+ .hai-tool:has(+ .hai-surface:not([data-expired])),
320
+ .hai-tool:has(+ .hai-dockable:not([data-expired]):not([data-docked])) { box-shadow: var(--hai-shadow); }
318
321
  .hai-tool summary {
319
322
  display: flex;
320
323
  align-items: center;
@@ -383,9 +386,11 @@
383
386
  .hai-status-running .hai-ticon { background: var(--hai-accent); animation: hai-blink 1.2s infinite both; }
384
387
  /* the component is the question: the turn is parked on it */
385
388
  .hai-tool.hai-status-awaiting { background: var(--hai-warn); box-shadow: var(--hai-shadow); }
386
- /* amber is as light in either scheme, so what's written on it is ink */
389
+ /* amber is as light in either scheme, so what's written on it is ink. The
390
+ arguments are too, in full, and set apart from the bold name by weight:
391
+ dimmed, they'd lose contrast on a darker amber with white text */
387
392
  .hai-status-awaiting summary {
388
- --hai-dim: color-mix(in srgb, var(--hai-on-accent) 70%, transparent);
393
+ --hai-dim: var(--hai-on-accent);
389
394
  color: var(--hai-on-accent);
390
395
  }
391
396
  .hai-status-awaiting .hai-ticon { background: var(--hai-accent); }
@@ -484,6 +489,81 @@
484
489
 
485
490
  /* out of date — the runtime has made the surface inert; this only shows it */
486
491
  .hai-surface[data-expired] { opacity: .45; filter: grayscale(.7); box-shadow: none; }
492
+
493
+ /* ── the dock: a surface's card, its bar, and what stays when it docks ─ */
494
+ /* With the dock on, a surface mounts inside a card of its own: a bar with its
495
+ name and Dock, then the surface. The card is what the transcript keeps; the
496
+ surface itself moves to the dock and back. */
497
+ .hai-dockable {
498
+ margin-bottom: 18px;
499
+ overflow: hidden;
500
+ border: 3px solid var(--hai-line);
501
+ border-radius: var(--hai-radius);
502
+ background: var(--hai-panel);
503
+ box-shadow: var(--hai-shadow);
504
+ }
505
+ .hai-tool + .hai-dockable { border-top: none; border-top-left-radius: 0; border-top-right-radius: 0; }
506
+ .hai-dockable > .hai-surface,
507
+ .hai-dockable > .hai-surface-error,
508
+ .hai-window-body > .hai-surface,
509
+ .hai-window-body > .hai-surface-error { margin: 0; border: none; border-radius: 0; box-shadow: none; }
510
+ .hai-dockable[data-expired] { box-shadow: none; }
511
+ /* docked: a flat stub in the surface's place */
512
+ .hai-dockable[data-docked] { background: var(--hai-panel-2); box-shadow: none; }
513
+
514
+ .hai-surface-bar {
515
+ display: flex;
516
+ align-items: stretch;
517
+ min-height: 34px;
518
+ border-bottom: 2px solid var(--hai-line);
519
+ background: var(--hai-panel-2);
520
+ color: var(--hai-fg);
521
+ }
522
+ .hai-surface-name {
523
+ flex: 1;
524
+ min-width: 0;
525
+ align-self: center;
526
+ padding: 0 14px;
527
+ overflow: hidden;
528
+ text-overflow: ellipsis;
529
+ white-space: nowrap;
530
+ font: 700 11.5px var(--hai-mono);
531
+ color: var(--hai-dim);
532
+ }
533
+ /* a button that is a bar's cell, an icon named for screen readers and in a
534
+ tooltip: Dock, Put back, and the window's Remove from dock and Dock */
535
+ .hai-bar-btn {
536
+ flex: none;
537
+ display: inline-flex;
538
+ align-items: center;
539
+ justify-content: center;
540
+ gap: 6px;
541
+ padding: 0 12px;
542
+ border: none;
543
+ border-left: 2px solid;
544
+ border-radius: 0;
545
+ background: none;
546
+ color: inherit;
547
+ font: 700 12px var(--hai-mono);
548
+ cursor: pointer;
549
+ }
550
+ .hai-bar-btn .hai-icon { width: 14px; height: 14px; stroke-width: 2.5; }
551
+ .hai-bar-btn:hover { background: var(--hai-hover); }
552
+ .hai-bar-icon { width: 40px; padding: 0; }
553
+ .hai-bar-icon .hai-icon { width: 16px; height: 16px; }
554
+
555
+ .hai-docked { display: flex; align-items: stretch; min-height: 40px; color: var(--hai-fg); }
556
+ .hai-docked-text {
557
+ flex: 1;
558
+ min-width: 0;
559
+ display: flex;
560
+ align-items: center;
561
+ gap: 8px;
562
+ padding: 0 14px;
563
+ font: 12px var(--hai-mono);
564
+ color: var(--hai-dim);
565
+ }
566
+ .hai-docked-text .hai-icon { width: 14px; height: 14px; stroke-width: 2.5; }
487
567
  .hai-expired {
488
568
  flex-wrap: wrap;
489
569
  align-items: center;
@@ -575,6 +655,248 @@
575
655
  .hai-send .hai-icon { width: 18px; height: 18px; stroke-width: 2.5; }
576
656
  .hai-send:disabled { opacity: .35; cursor: default; }
577
657
 
658
+ /* ── the dock's shelf, on top of the composer ───────────────────────── */
659
+ /* The composer card's top row while anything is docked: one card, one
660
+ shadow. Not positioned, so the box below covers the shelf's own shadow. */
661
+ .hai-dock {
662
+ display: flex;
663
+ align-items: center;
664
+ gap: 10px;
665
+ padding: 0 14px 0 12px;
666
+ border: 3px solid var(--hai-line);
667
+ border-radius: var(--hai-radius) var(--hai-radius) 0 0;
668
+ background: var(--hai-panel-2);
669
+ box-shadow: var(--hai-shadow);
670
+ transition: box-shadow .15s;
671
+ }
672
+ .hai-dock[hidden] { display: none; }
673
+ .hai-dock:not([hidden]) + .hai-composer-box { border-top: none; border-top-left-radius: 0; border-top-right-radius: 0; }
674
+ .hai-composer:has(.hai-composer-box:focus-within) > .hai-dock { box-shadow: 4px 4px 0 var(--hai-accent); }
675
+ .hai-dock-count { flex: none; font: 700 11px var(--hai-mono); color: var(--hai-dim); }
676
+ /* room above and to the right for an item's corner mark, below for its shadow */
677
+ .hai-dock-row {
678
+ flex: 1;
679
+ min-width: 0;
680
+ display: flex;
681
+ align-items: center;
682
+ gap: 10px;
683
+ padding: 8px 8px 8px 0;
684
+ overflow-x: auto;
685
+ scrollbar-width: none;
686
+ }
687
+
688
+ /* a docked surface: open is pressed and inked; amber waits on you */
689
+ .hai-dock-item {
690
+ position: relative;
691
+ flex: none;
692
+ display: inline-flex;
693
+ align-items: center;
694
+ gap: 8px;
695
+ height: 36px;
696
+ padding: 0 12px;
697
+ box-sizing: border-box;
698
+ border: 2px solid var(--hai-line);
699
+ border-radius: var(--hai-radius);
700
+ background: var(--hai-panel);
701
+ box-shadow: 3px 3px 0 var(--hai-line);
702
+ color: var(--hai-fg);
703
+ font: 700 12px var(--hai-mono);
704
+ white-space: nowrap;
705
+ cursor: pointer;
706
+ }
707
+ .hai-dock-item[hidden] { display: none; }
708
+ .hai-dock-item .hai-icon { width: 14px; height: 14px; stroke-width: 2.5; }
709
+ .hai-dock-item:hover { background-image: linear-gradient(var(--hai-hover) 0 0); }
710
+ .hai-dock-item[data-status="awaiting"] { background-color: var(--hai-warn); color: var(--hai-on-accent); }
711
+ .hai-dock-item[aria-expanded="true"] {
712
+ background: var(--hai-fg);
713
+ color: var(--hai-panel);
714
+ box-shadow: none;
715
+ transform: translate(3px, 3px);
716
+ }
717
+ .hai-dock-item[data-status="expired"] {
718
+ border-style: dashed;
719
+ border-color: var(--hai-dim);
720
+ background: transparent;
721
+ box-shadow: none;
722
+ color: var(--hai-dim);
723
+ }
724
+ .hai-dock-dot {
725
+ width: 10px;
726
+ height: 10px;
727
+ box-sizing: border-box;
728
+ border: 2px solid;
729
+ border-radius: 50%;
730
+ background: var(--hai-accent);
731
+ }
732
+ .hai-dock-dot[hidden] { display: none; }
733
+ .hai-dock-item[aria-expanded="true"] .hai-dock-dot { background: var(--hai-warn); }
734
+ .hai-dock-changed {
735
+ position: absolute;
736
+ top: -7px;
737
+ right: -7px;
738
+ width: 12px;
739
+ height: 12px;
740
+ box-sizing: border-box;
741
+ border: 2px solid var(--hai-line);
742
+ background: var(--hai-accent);
743
+ }
744
+ .hai-dock-changed[hidden] { display: none; }
745
+
746
+ /* the pile the oldest go into when the row is full: two edges behind it */
747
+ .hai-dock-pile {
748
+ position: relative;
749
+ /* above an open window, which its list may overlap */
750
+ z-index: 6;
751
+ isolation: isolate;
752
+ flex: none;
753
+ display: inline-flex;
754
+ margin: 8px 8px 8px 0;
755
+ }
756
+ .hai-dock-pile[hidden] { display: none; }
757
+ .hai-dock-pile::before,
758
+ .hai-dock-pile::after {
759
+ content: "";
760
+ position: absolute;
761
+ inset: 0;
762
+ z-index: -1;
763
+ box-sizing: border-box;
764
+ border: 2px solid var(--hai-line);
765
+ background: var(--hai-panel);
766
+ transform: translate(8px, -8px);
767
+ }
768
+ .hai-dock-pile::after { transform: translate(4px, -4px); }
769
+ /* what's in it, oldest first, above the shelf */
770
+ .hai-pile-list {
771
+ position: absolute;
772
+ right: -8px;
773
+ bottom: calc(100% + 18px);
774
+ z-index: 6;
775
+ width: 280px;
776
+ max-width: calc(100vw - 32px);
777
+ /* the room above the shelf, measured as it opens; this until then */
778
+ max-height: 60vh;
779
+ overflow-y: auto;
780
+ scrollbar-width: thin;
781
+ scrollbar-color: var(--hai-line) transparent;
782
+ border: 3px solid var(--hai-line);
783
+ border-radius: var(--hai-radius);
784
+ background: var(--hai-panel);
785
+ box-shadow: var(--hai-shadow);
786
+ }
787
+ .hai-pile-list[hidden] { display: none; }
788
+ .hai-pile-head {
789
+ position: sticky;
790
+ top: 0;
791
+ padding: 6px 12px;
792
+ border-bottom: 2px solid var(--hai-line);
793
+ background: var(--hai-panel-2);
794
+ font: 700 11px var(--hai-mono);
795
+ color: var(--hai-dim);
796
+ }
797
+ .hai-pile-row {
798
+ display: flex;
799
+ align-items: center;
800
+ gap: 10px;
801
+ width: 100%;
802
+ min-height: 44px;
803
+ padding: 0 12px;
804
+ border: none;
805
+ border-bottom: 2px solid var(--hai-line-soft);
806
+ background: none;
807
+ color: var(--hai-fg);
808
+ font: 700 12px var(--hai-mono);
809
+ text-align: left;
810
+ cursor: pointer;
811
+ }
812
+ .hai-pile-row:last-child { border-bottom: none; }
813
+ .hai-pile-row:hover { background: var(--hai-hover); }
814
+ .hai-pile-row .hai-icon { width: 14px; height: 14px; stroke-width: 2.5; }
815
+ .hai-pile-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
816
+ .hai-pile-row .hai-dock-changed { position: static; flex: none; }
817
+
818
+ /* ── a docked surface, open as a window ─────────────────────────────── */
819
+ /* Above the status line and the composer, in the column; raised higher than a
820
+ card. Its title bar is inked like its pressed item, amber while it waits. */
821
+ .hai-window {
822
+ position: absolute;
823
+ z-index: 5;
824
+ left: var(--hai-gutter);
825
+ bottom: calc(var(--hai-foot, 140px) + 8px);
826
+ display: flex;
827
+ flex-direction: column;
828
+ width: min(560px, 100% - 2 * var(--hai-gutter));
829
+ max-height: calc(100% - var(--hai-foot, 140px) - 32px);
830
+ box-sizing: border-box;
831
+ border: 3px solid var(--hai-line);
832
+ border-radius: var(--hai-radius);
833
+ background: var(--hai-panel);
834
+ box-shadow: 8px 8px 0 var(--hai-line);
835
+ color: var(--hai-fg);
836
+ }
837
+ .hai-window[hidden] { display: none; }
838
+ .hai-window:focus { outline: none; }
839
+ .hai-window:focus-visible { outline: 3px solid var(--hai-fg); outline-offset: 2px; }
840
+ .hai-window-bar {
841
+ flex: none;
842
+ display: flex;
843
+ align-items: stretch;
844
+ gap: 10px;
845
+ min-height: 40px;
846
+ padding-left: 12px;
847
+ border-bottom: 3px solid var(--hai-line);
848
+ background: var(--hai-fg);
849
+ color: var(--hai-panel);
850
+ }
851
+ .hai-window-bar > .hai-icon { align-self: center; width: 14px; height: 14px; stroke-width: 2.5; }
852
+ /* a long name gives way, after the tool's, so the bar's buttons stay in reach */
853
+ .hai-window-name {
854
+ flex: 0 1 auto;
855
+ min-width: 0;
856
+ align-self: center;
857
+ overflow: hidden;
858
+ text-overflow: ellipsis;
859
+ white-space: nowrap;
860
+ font: 700 12.5px var(--hai-mono);
861
+ }
862
+ .hai-window-tool {
863
+ flex: 1;
864
+ min-width: 0;
865
+ align-self: center;
866
+ overflow: hidden;
867
+ text-overflow: ellipsis;
868
+ white-space: nowrap;
869
+ font: 12px var(--hai-mono);
870
+ }
871
+ .hai-window-badge { flex: none; align-self: center; }
872
+ .hai-window-badge[hidden] { display: none; }
873
+ .hai-window-bar > .hai-bar-btn + .hai-bar-btn { margin-left: -10px; }
874
+ .hai-window[data-status="awaiting"] .hai-window-bar { background: var(--hai-warn); color: var(--hai-on-accent); }
875
+ .hai-window[data-status="expired"] { border-style: dashed; border-color: var(--hai-dim); box-shadow: none; }
876
+ .hai-window[data-status="expired"] .hai-window-bar {
877
+ border-bottom-style: dashed;
878
+ border-color: var(--hai-dim);
879
+ background: color-mix(in srgb, var(--hai-dim) 10%, var(--hai-panel-2));
880
+ color: var(--hai-fg);
881
+ }
882
+ .hai-window[data-status="expired"] .hai-window-badge { background: none; }
883
+ .hai-window-body {
884
+ min-height: 0;
885
+ overflow-y: auto;
886
+ scrollbar-width: thin;
887
+ scrollbar-color: var(--hai-line) transparent;
888
+ }
889
+
890
+ /* for a screen reader only: what docking just did */
891
+ .hai-dock-live {
892
+ position: absolute;
893
+ width: 1px;
894
+ height: 1px;
895
+ overflow: hidden;
896
+ clip-path: inset(50%);
897
+ white-space: nowrap;
898
+ }
899
+
578
900
  /* ── model context debug drawer ─────────────────────────────────────── */
579
901
  /* Closed, it waits off the right edge, out of the tab order and the
580
902
  accessibility tree; open, it slides in, over the chat on a narrow screen. */
@@ -650,6 +972,31 @@
650
972
  clip-path: inset(50%);
651
973
  white-space: nowrap;
652
974
  }
975
+ /* the window is a sheet over the chat, down to the composer; the chat
976
+ behind it dims, out of reach until it docks again */
977
+ .hai-window {
978
+ left: 0;
979
+ right: 0;
980
+ top: 12%;
981
+ bottom: var(--hai-composer-height, 120px);
982
+ width: auto;
983
+ max-height: none;
984
+ border-left: none;
985
+ border-right: none;
986
+ border-radius: 0;
987
+ box-shadow: none;
988
+ }
989
+ .hai-chat.window-open > .hai-transcript,
990
+ .hai-chat.window-open > .hai-status { opacity: .4; }
991
+ .hai-dock-count { display: none; }
992
+ }
993
+ /* a finger's worth, wherever the pointer is coarse */
994
+ @media (pointer: coarse) {
995
+ .hai-dock-item { height: 44px; }
996
+ .hai-surface-bar { min-height: 44px; }
997
+ .hai-docked { min-height: 44px; }
998
+ .hai-window-bar { min-height: 44px; }
999
+ .hai-bar-icon { width: 44px; }
653
1000
  }
654
1001
  @media (max-width: 599.98px) {
655
1002
  .hai-model { display: none; }
@@ -665,6 +1012,11 @@
665
1012
  .hai-new:focus-visible { outline: 3px solid var(--hai-fg); outline-offset: 2px; }
666
1013
  /* inside the row's border, clear of it, in the row's text colour: ink on amber too */
667
1014
  .hai-tool summary:focus-visible { outline: 2px solid currentColor; outline-offset: -5px; }
1015
+ /* the dock's items and its pile take the buttons' ring; a bar's cells and the
1016
+ pile's rows, where a card would clip it, a ring inside */
1017
+ .hai-dock-item:focus-visible { outline: 3px solid var(--hai-fg); outline-offset: 2px; }
1018
+ .hai-bar-btn:focus-visible,
1019
+ .hai-pile-row:focus-visible { outline: 2px solid currentColor; outline-offset: -6px; }
668
1020
 
669
1021
  @keyframes hai-pulse {
670
1022
  0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--hai-warn) 55%, transparent); }
@@ -680,6 +1032,7 @@
680
1032
  .hai-shimmer { animation: none; }
681
1033
  .hai-chevron,
682
1034
  .hai-composer-box,
1035
+ .hai-dock,
683
1036
  .hai-chat,
684
1037
  .hai-inspector,
685
1038
  .hai-main.inspector-open > .hai-inspector { transition: none; }
package/src/icons.js CHANGED
@@ -16,6 +16,12 @@ const PATHS = {
16
16
  alert: "M4 4h16v16H4zM12 8v5M12 16.5v.01",
17
17
  clock: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 7v5l3.5 2",
18
18
  reply: "M5 4v7a4 4 0 0 0 4 4h10M15 11l4 4-4 4",
19
+ // the dock: a surface as a window, down onto the shelf, up back into the
20
+ // chat, and the pile the oldest go into when the shelf is full
21
+ window: "M4 5h16v14H4zM4 9h16",
22
+ dock: "M12 4v10M7.5 9.5 12 14l4.5-4.5M4 20h16",
23
+ undock: "M12 16V4M7.5 8.5 12 4l4.5 4.5M4 20h16",
24
+ stack: "M3 8h13v13H3zM8 8V3h13v13h-5",
19
25
  };
20
26
 
21
27
  const SVG = "http://www.w3.org/2000/svg";
@@ -11,6 +11,26 @@ export function h<K extends keyof HTMLElementTagNameMap>(
11
11
  text?: string | null,
12
12
  ): HTMLElementTagNameMap[K];
13
13
 
14
+ /** A surface whose Dock button was pressed, as `renderTranscript` hands it to `onDock`. */
15
+ export interface DockRequest {
16
+ /** The surface's handle, as it is now. */
17
+ handle: string;
18
+ /** Its card, `div.hai-dockable`: stays in the transcript, in the surface's place. */
19
+ card: HTMLElement;
20
+ /** The element the surface is mounted in, `div.hai-surface`, inside the card. */
21
+ element: HTMLElement;
22
+ }
23
+
24
+ export interface TranscriptOptions {
25
+ /**
26
+ * Makes surfaces dockable. Each one is drawn in a card, `div.hai-dockable`:
27
+ * a bar with its component's name and, once mounted, a Dock button that
28
+ * calls this. Moving `element` elsewhere keeps its component mounted; the
29
+ * transcript leaves the card's contents to you. `mountChat` passes its dock.
30
+ */
31
+ onDock?: (request: DockRequest) => void;
32
+ }
33
+
14
34
  /**
15
35
  * The default transcript renderer. Call it on every event: a block keeps its
16
36
  * element while what it shows is unchanged, so each surface mounts once and
@@ -19,7 +39,7 @@ export function h<K extends keyof HTMLElementTagNameMap>(
19
39
  * Once the reader scrolls up, it keeps their place until they scroll back
20
40
  * down. Deliberately plain — this is the part you are expected to replace.
21
41
  */
22
- export function renderTranscript(root: HTMLElement, chat: Chat): void;
42
+ export function renderTranscript(root: HTMLElement, chat: Chat, options?: TranscriptOptions): void;
23
43
 
24
44
  /**
25
45
  * Stop following a transcript that is going away, so its resize observer and
package/src/transcript.js CHANGED
@@ -15,7 +15,7 @@ export const h = (tag, className, text) => {
15
15
  return node;
16
16
  };
17
17
 
18
- export function renderTranscript(root, chat) {
18
+ export function renderTranscript(root, chat, { onDock } = {}) {
19
19
  const { state } = chat;
20
20
  const follow = follower(root);
21
21
 
@@ -43,18 +43,24 @@ export function renderTranscript(root, chat) {
43
43
  const before = follow.shown.get(block.id);
44
44
  let el = before?.el;
45
45
  if (before?.look !== look) {
46
- el = renderBlock(block, chat);
46
+ el = renderBlock(block, chat, onDock);
47
47
  // an expanded tool row stays expanded when its status changes
48
48
  if (before?.el.open) el.open = true;
49
49
  } else if (block.kind === "ui" && el.dataset.handle !== block.handle) {
50
- // a revision moved this surface to a new handle; its element stays
50
+ // a revision moved this surface to a new handle; its element stays,
51
+ // and so does its card's surface, wherever the dock has put it
51
52
  el.dataset.handle = block.handle;
53
+ const surface = cards.get(el);
54
+ if (surface) surface.dataset.handle = block.handle;
52
55
  } else if (block.progress !== before.progress) {
53
56
  // Progress changes the row in place. Rebuilt for every frame, a row a
54
57
  // keyboard user is on would take their focus up to ten times a second.
55
58
  const parts = rowParts.get(el);
56
59
  if (parts) showStatus(parts, block, toolStatus(block, chat));
57
60
  }
61
+ // a card is out of date when its surface is, docked or not
62
+ const surface = cards.get(el);
63
+ if (surface) el.toggleAttribute("data-expired", surface.hasAttribute("data-expired"));
58
64
  shown.set(block.id, { el, look, progress: block.progress });
59
65
  return el;
60
66
  });
@@ -286,7 +292,36 @@ function follower(root) {
286
292
  return follow;
287
293
  }
288
294
 
289
- function renderBlock(block, chat) {
295
+ /** A dockable card's element → the element its surface mounts into, wherever that is now. */
296
+ const cards = new WeakMap();
297
+
298
+ /**
299
+ * A surface in a card of its own: a bar with its component's name and, once it
300
+ * has mounted, a Dock button that hands it to `onDock`. The card stays in the
301
+ * transcript, in the surface's place; what happens to the surface is the
302
+ * dock's to decide.
303
+ */
304
+ function dockable(block, el, surface, onDock) {
305
+ const card = h("div", "hai-dockable");
306
+ card.dataset.handle = block.handle;
307
+ const name = surface?.component ?? "surface";
308
+ const bar = h("div", "hai-surface-bar");
309
+ bar.append(h("span", "hai-surface-name", name));
310
+ if (surface?.props) {
311
+ const dock = h("button", "hai-bar-btn hai-bar-icon");
312
+ dock.type = "button";
313
+ dock.title = "Dock";
314
+ dock.setAttribute("aria-label", `Dock ${name}`);
315
+ dock.append(icon("dock"));
316
+ dock.onclick = () => onDock({ handle: card.dataset.handle, card, element: el });
317
+ bar.append(dock);
318
+ }
319
+ card.append(bar, el);
320
+ cards.set(card, el);
321
+ return card;
322
+ }
323
+
324
+ function renderBlock(block, chat, onDock) {
290
325
  switch (block.kind) {
291
326
  case "user": {
292
327
  const el = h("div", "hai-block hai-user");
@@ -377,7 +412,7 @@ function renderBlock(block, chat) {
377
412
  for (let i = 0; i < 3; i++) skeleton.append(h("div", "hai-shimmer"));
378
413
  el.append(skeleton);
379
414
  }
380
- return el;
415
+ return onDock ? dockable(block, el, surface, onDock) : el;
381
416
  }
382
417
 
383
418
  // Something the server told this conversation outside of any request. The