@haikit/client 0.22.1 → 0.24.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 +9 -1
- package/package.json +1 -1
- package/src/app.d.ts +8 -0
- package/src/app.js +469 -15
- package/src/hai.css +376 -2
- package/src/icons.js +7 -0
- package/src/transcript.d.ts +21 -1
- package/src/transcript.js +40 -5
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,14 @@ 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, when the shelf is full the oldest go into a pile, and
|
|
52
|
+
**Remove all** at the shelf's end puts every docked surface back at once.
|
|
53
|
+
`dock: false` leaves it out.
|
|
54
|
+
|
|
47
55
|
It is light or dark with the system. To pin it, put `data-hai-theme="dark"` (or
|
|
48
56
|
`"light"`) on the `<html>` element, or pass `theme: "dark"` to `mountChat`.
|
|
49
57
|
|
package/package.json
CHANGED
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,
|
|
331
|
-
// hears "Thinking" once a turn,
|
|
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
|
-
|
|
335
|
-
shownStatus
|
|
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"),
|
|
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
|
-
|
|
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
|
-
|
|
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,434 @@ 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
|
+
* Remove all, at the shelf's end, puts every docked surface back at once.
|
|
440
|
+
*/
|
|
441
|
+
function createDock({ chat, id, shell, chatPane, transcript, composer, statusEl, wide, wideQuery, drawerOpen, onChange }) {
|
|
442
|
+
/** Docked surfaces, oldest first. */
|
|
443
|
+
const entries = [];
|
|
444
|
+
/** The one open in the window. */
|
|
445
|
+
let open = null;
|
|
446
|
+
|
|
447
|
+
// ── the shelf ────────────────────────────────────────────────────────
|
|
448
|
+
const shelf = h("div", "hai-dock");
|
|
449
|
+
shelf.hidden = true;
|
|
450
|
+
shelf.setAttribute("role", "toolbar");
|
|
451
|
+
shelf.setAttribute("aria-label", "Docked surfaces");
|
|
452
|
+
const count = h("span", "hai-dock-count");
|
|
453
|
+
count.setAttribute("aria-hidden", "true");
|
|
454
|
+
const row = h("div", "hai-dock-row");
|
|
455
|
+
const pile = h("span", "hai-dock-pile");
|
|
456
|
+
pile.hidden = true;
|
|
457
|
+
const pileBtn = h("button", "hai-dock-item");
|
|
458
|
+
pileBtn.type = "button";
|
|
459
|
+
pileBtn.setAttribute("aria-expanded", "false");
|
|
460
|
+
pileBtn.setAttribute("aria-controls", `${id}-pile`);
|
|
461
|
+
const pileText = h("span");
|
|
462
|
+
const pileMark = changedMark();
|
|
463
|
+
pileBtn.append(icon("stack"), pileText, pileMark);
|
|
464
|
+
const list = h("div", "hai-pile-list");
|
|
465
|
+
list.id = `${id}-pile`;
|
|
466
|
+
list.hidden = true;
|
|
467
|
+
pile.append(pileBtn, list);
|
|
468
|
+
// Remove all, at the shelf's end: every docked surface back in the chat
|
|
469
|
+
const clear = h("button", "hai-dock-clear");
|
|
470
|
+
clear.type = "button";
|
|
471
|
+
clear.title = "Remove all from dock";
|
|
472
|
+
clear.setAttribute("aria-label", "Remove all from the dock");
|
|
473
|
+
clear.append(icon("undockAll"));
|
|
474
|
+
shelf.append(count, row, pile, clear);
|
|
475
|
+
composer.prepend(shelf);
|
|
476
|
+
|
|
477
|
+
// ── the window ───────────────────────────────────────────────────────
|
|
478
|
+
const win = h("section", "hai-window");
|
|
479
|
+
win.id = `${id}-window`;
|
|
480
|
+
win.hidden = true;
|
|
481
|
+
win.tabIndex = -1;
|
|
482
|
+
win.setAttribute("role", "dialog");
|
|
483
|
+
win.setAttribute("aria-labelledby", `${id}-window-name`);
|
|
484
|
+
const winName = h("span", "hai-window-name");
|
|
485
|
+
winName.id = `${id}-window-name`;
|
|
486
|
+
const winTool = h("span", "hai-window-tool");
|
|
487
|
+
const winBadge = h("span", "hai-tstatus hai-window-badge");
|
|
488
|
+
const remove = h("button", "hai-bar-btn hai-bar-icon");
|
|
489
|
+
remove.type = "button";
|
|
490
|
+
remove.title = "Remove from dock";
|
|
491
|
+
remove.append(icon("undock"));
|
|
492
|
+
const minimise = h("button", "hai-bar-btn hai-bar-icon");
|
|
493
|
+
minimise.type = "button";
|
|
494
|
+
minimise.title = "Dock";
|
|
495
|
+
minimise.append(icon("dock"));
|
|
496
|
+
const winBar = h("div", "hai-window-bar");
|
|
497
|
+
winBar.append(icon("window"), winName, winTool, winBadge, remove, minimise);
|
|
498
|
+
const winBody = h("div", "hai-window-body");
|
|
499
|
+
win.append(winBar, winBody);
|
|
500
|
+
|
|
501
|
+
// what docking did, for a screen reader: the button pressed has gone
|
|
502
|
+
const live = h("div", "hai-dock-live");
|
|
503
|
+
live.setAttribute("role", "status");
|
|
504
|
+
chatPane.append(win, live);
|
|
505
|
+
|
|
506
|
+
// ── each surface, as it is now ───────────────────────────────────────
|
|
507
|
+
const surfaceOf = (entry) => chat.state.surfaces.get(entry.block.handle);
|
|
508
|
+
const nameOf = (entry) => surfaceOf(entry)?.component ?? "surface";
|
|
509
|
+
const toolOf = (entry) => chat.state.blocks.find((b) => b.kind === "tool" && b.id === entry.block.toolId)?.name;
|
|
510
|
+
// a question still open waits on the reader, as its tool row says
|
|
511
|
+
const statusOf = (entry) => {
|
|
512
|
+
if (chat.state.expired) return "expired";
|
|
513
|
+
const surface = surfaceOf(entry);
|
|
514
|
+
return surface?.mode === "elicit" && surface.state !== "frozen" ? "awaiting" : "live";
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
function onDock({ handle, card, element }) {
|
|
518
|
+
if (entries.some((e) => e.card === card)) return;
|
|
519
|
+
const block = chat.state.blocks.find((b) => b.kind === "ui" && b.handle === handle);
|
|
520
|
+
if (!block) return;
|
|
521
|
+
const entry = { block, card, element, bar: card.firstElementChild, props: surfaceOf({ block })?.props, changed: false, stacked: false };
|
|
522
|
+
|
|
523
|
+
// what stays in the transcript, in the surface's place
|
|
524
|
+
entry.stubText = h("span", "hai-docked-text");
|
|
525
|
+
entry.back = h("button", "hai-bar-btn hai-bar-icon");
|
|
526
|
+
entry.back.type = "button";
|
|
527
|
+
entry.back.title = "Put back";
|
|
528
|
+
entry.back.append(icon("undock"));
|
|
529
|
+
entry.back.onclick = () => undock(entry, true);
|
|
530
|
+
const stub = h("div", "hai-docked");
|
|
531
|
+
stub.append(entry.stubText, entry.back);
|
|
532
|
+
|
|
533
|
+
// and its button on the shelf
|
|
534
|
+
entry.item = h("button", "hai-dock-item");
|
|
535
|
+
entry.item.type = "button";
|
|
536
|
+
entry.item.setAttribute("aria-controls", win.id);
|
|
537
|
+
entry.itemName = h("span");
|
|
538
|
+
entry.dot = h("span", "hai-dock-dot");
|
|
539
|
+
entry.dot.setAttribute("aria-hidden", "true");
|
|
540
|
+
entry.mark = changedMark();
|
|
541
|
+
entry.item.append(icon("window"), entry.itemName, entry.dot, entry.mark);
|
|
542
|
+
entry.item.onclick = () => (open === entry ? close(true) : show(entry));
|
|
543
|
+
row.append(entry.item);
|
|
544
|
+
|
|
545
|
+
element.hidden = true;
|
|
546
|
+
winBody.append(element);
|
|
547
|
+
card.replaceChildren(stub);
|
|
548
|
+
card.dataset.docked = "";
|
|
549
|
+
entries.push(entry);
|
|
550
|
+
sync();
|
|
551
|
+
// the reader stays where they were, on the button that undoes it
|
|
552
|
+
entry.back.focus();
|
|
553
|
+
live.textContent = `${nameOf(entry)} docked`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function undock(entry, focus) {
|
|
557
|
+
if (open === entry) close(false);
|
|
558
|
+
entries.splice(entries.indexOf(entry), 1);
|
|
559
|
+
entry.item.remove();
|
|
560
|
+
entry.element.hidden = false;
|
|
561
|
+
entry.card.replaceChildren(entry.bar, entry.element);
|
|
562
|
+
delete entry.card.dataset.docked;
|
|
563
|
+
sync();
|
|
564
|
+
if (focus) {
|
|
565
|
+
entry.bar.querySelector("button")?.focus();
|
|
566
|
+
entry.card.scrollIntoView?.({ block: "nearest" });
|
|
567
|
+
}
|
|
568
|
+
live.textContent = `${nameOf(entry)} is back in the chat`;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function show(entry) {
|
|
572
|
+
if (open && open !== entry) open.element.hidden = true;
|
|
573
|
+
// one opened from the pile comes into the row, as the newest
|
|
574
|
+
if (entry.stacked) {
|
|
575
|
+
entries.push(...entries.splice(entries.indexOf(entry), 1));
|
|
576
|
+
row.append(entry.item);
|
|
577
|
+
}
|
|
578
|
+
open = entry;
|
|
579
|
+
entry.changed = false;
|
|
580
|
+
entry.element.hidden = false;
|
|
581
|
+
measure();
|
|
582
|
+
win.hidden = false;
|
|
583
|
+
closePile(false);
|
|
584
|
+
sync();
|
|
585
|
+
win.focus();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function close(focus) {
|
|
589
|
+
if (!open) return;
|
|
590
|
+
const entry = open;
|
|
591
|
+
open = null;
|
|
592
|
+
entry.element.hidden = true;
|
|
593
|
+
win.hidden = true;
|
|
594
|
+
sync();
|
|
595
|
+
if (focus) (entry.stacked ? pileBtn : entry.item).focus();
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
remove.onclick = () => open && undock(open, true);
|
|
599
|
+
minimise.onclick = () => close(true);
|
|
600
|
+
|
|
601
|
+
// Every docked surface back in its card, oldest first. The shelf goes with
|
|
602
|
+
// them, so the reader goes to the message box under it, or, once the
|
|
603
|
+
// conversation has expired and the box is disabled, to the button that
|
|
604
|
+
// starts a new one.
|
|
605
|
+
clear.onclick = () => {
|
|
606
|
+
const n = entries.length;
|
|
607
|
+
for (const entry of [...entries]) undock(entry, false);
|
|
608
|
+
const input = composer.querySelector(".hai-input");
|
|
609
|
+
if (input && !input.disabled) input.focus();
|
|
610
|
+
else transcript.querySelector(".hai-expired .hai-new")?.focus();
|
|
611
|
+
live.textContent = n === 1 ? "1 surface is back in the chat" : `${n} surfaces are back in the chat`;
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
// ── keeping up ───────────────────────────────────────────────────────
|
|
615
|
+
// After every event the chat renders, and after every change here.
|
|
616
|
+
function sync() {
|
|
617
|
+
for (const entry of entries) {
|
|
618
|
+
// new props while docked and closed: the reader hasn't seen them
|
|
619
|
+
const props = surfaceOf(entry)?.props;
|
|
620
|
+
if (props !== entry.props) {
|
|
621
|
+
entry.changed ||= entry !== open;
|
|
622
|
+
entry.props = props;
|
|
623
|
+
}
|
|
624
|
+
const name = nameOf(entry);
|
|
625
|
+
const status = statusOf(entry);
|
|
626
|
+
const isOpen = open === entry;
|
|
627
|
+
setText(entry.itemName, name);
|
|
628
|
+
setAttr(entry.item, "data-status", status);
|
|
629
|
+
setAttr(entry.item, "aria-expanded", String(isOpen));
|
|
630
|
+
setAttr(
|
|
631
|
+
entry.item,
|
|
632
|
+
"aria-label",
|
|
633
|
+
[name, status === "awaiting" && "awaiting you", status === "expired" && "out of date", entry.changed && "changed"]
|
|
634
|
+
.filter(Boolean)
|
|
635
|
+
.join(", "),
|
|
636
|
+
);
|
|
637
|
+
setHidden(entry.dot, status !== "awaiting");
|
|
638
|
+
setHidden(entry.mark, !entry.changed);
|
|
639
|
+
const text = `${name} ${isOpen ? "is open in a window" : "is in the dock"}`;
|
|
640
|
+
if (entry.stubText.textContent !== text) entry.stubText.replaceChildren(icon("dock"), text);
|
|
641
|
+
setAttr(entry.back, "aria-label", `Put ${name} back`);
|
|
642
|
+
}
|
|
643
|
+
setHidden(shelf, entries.length === 0);
|
|
644
|
+
setText(count, `docked · ${entries.length}`);
|
|
645
|
+
|
|
646
|
+
if (open) {
|
|
647
|
+
const name = nameOf(open);
|
|
648
|
+
const tool = toolOf(open);
|
|
649
|
+
const status = statusOf(open);
|
|
650
|
+
setAttr(win, "data-status", status);
|
|
651
|
+
setText(winName, name);
|
|
652
|
+
setText(winTool, tool ? `· ${tool}` : "");
|
|
653
|
+
setHidden(winBadge, status === "live");
|
|
654
|
+
setText(winBadge, status === "awaiting" ? "awaiting you" : "out of date");
|
|
655
|
+
setAttr(remove, "aria-label", `Remove ${name} from the dock`);
|
|
656
|
+
setAttr(minimise, "aria-label", `Dock ${name}`);
|
|
657
|
+
}
|
|
658
|
+
chatPane.classList.toggle("window-open", open !== null);
|
|
659
|
+
// over the chat, on a narrow screen, the window is all there is to reach
|
|
660
|
+
const inert = open !== null && !wide();
|
|
661
|
+
if (transcript.inert !== inert) transcript.inert = inert;
|
|
662
|
+
stack();
|
|
663
|
+
onChange();
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Every streamed word reaches sync, so it writes only what has changed: a
|
|
667
|
+
// dock that stays the same costs no style or layout work.
|
|
668
|
+
function setText(el, text) {
|
|
669
|
+
if (el.textContent !== text) el.textContent = text;
|
|
670
|
+
}
|
|
671
|
+
function setAttr(el, name, value) {
|
|
672
|
+
if (el.getAttribute(name) !== value) el.setAttribute(name, value);
|
|
673
|
+
}
|
|
674
|
+
function setHidden(el, hidden) {
|
|
675
|
+
if (el.hidden !== hidden) el.hidden = hidden;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// The window sits above the status line and the composer, or on a narrow
|
|
679
|
+
// screen over everything down to the composer: their heights, as they are.
|
|
680
|
+
function measure() {
|
|
681
|
+
chatPane.style.setProperty("--hai-composer-height", `${composer.offsetHeight}px`);
|
|
682
|
+
chatPane.style.setProperty("--hai-foot", `${composer.offsetHeight + statusEl.offsetHeight}px`);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Into the pile, oldest first, until the row fits. Measured, so it does
|
|
686
|
+
// nothing where nothing is laid out.
|
|
687
|
+
let listed = "";
|
|
688
|
+
/** Each piled surface's row in the list, as last built. */
|
|
689
|
+
const rows = new Map();
|
|
690
|
+
// Measured again only when what decides it changes: which surfaces are
|
|
691
|
+
// docked, in what order, their names, their dots, which is open; or the
|
|
692
|
+
// room, when the observer below says so.
|
|
693
|
+
let measured = null;
|
|
694
|
+
function stack(force = false) {
|
|
695
|
+
const focused = document.activeElement;
|
|
696
|
+
const inputs = entries.map((e) => `${e.block.id}:${nameOf(e)}:${statusOf(e) === "awaiting"}:${e === open}`).join(" ");
|
|
697
|
+
if (force || inputs !== measured) {
|
|
698
|
+
measured = inputs;
|
|
699
|
+
for (const entry of entries) entry.item.hidden = false;
|
|
700
|
+
pile.hidden = true;
|
|
701
|
+
const stackable = entries.filter((e) => e !== open && statusOf(e) !== "awaiting");
|
|
702
|
+
let piled = 0;
|
|
703
|
+
while (row.scrollWidth > row.clientWidth && piled < stackable.length) {
|
|
704
|
+
stackable[piled++].item.hidden = true;
|
|
705
|
+
pile.hidden = false;
|
|
706
|
+
pileText.textContent = `${piled} more`;
|
|
707
|
+
}
|
|
708
|
+
for (const entry of entries) entry.stacked = stackable.indexOf(entry) > -1 && stackable.indexOf(entry) < piled;
|
|
709
|
+
// an item stacked under the reader's focus hands it to the pile, and a
|
|
710
|
+
// pile that goes hands it to the item now at the row's end, where it was
|
|
711
|
+
if (entries.some((e) => e.stacked && e.item === focused)) pileBtn.focus();
|
|
712
|
+
else if (focused === pileBtn && pile.hidden) entries.filter((e) => !e.item.hidden).at(-1)?.item.focus();
|
|
713
|
+
}
|
|
714
|
+
const stacked = entries.filter((e) => e.stacked);
|
|
715
|
+
const changed = stacked.filter((e) => e.changed).length;
|
|
716
|
+
setHidden(pileMark, changed === 0);
|
|
717
|
+
setAttr(pileBtn, "aria-label", `${stacked.length} more docked surfaces${changed ? `, ${changed} changed` : ""}`);
|
|
718
|
+
if (!stacked.length) closePile(false);
|
|
719
|
+
|
|
720
|
+
// The list, rebuilt only when what it shows changes: which surfaces, not
|
|
721
|
+
// just their names, since two can share a component. A reader may be in
|
|
722
|
+
// it, and keeps their place on the same surface's row.
|
|
723
|
+
const key = stacked.map((e) => `${e.block.id}:${nameOf(e)}:${e.changed}`).join(" ");
|
|
724
|
+
if (key === listed) return;
|
|
725
|
+
listed = key;
|
|
726
|
+
const focusedEntry = [...rows].find(([, row]) => row === focused)?.[0];
|
|
727
|
+
rows.clear();
|
|
728
|
+
list.replaceChildren(h("div", "hai-pile-head", `stacked · ${stacked.length} · oldest first`));
|
|
729
|
+
for (const entry of stacked) {
|
|
730
|
+
const b = h("button", "hai-pile-row");
|
|
731
|
+
b.type = "button";
|
|
732
|
+
b.append(icon("window"), h("span", "hai-pile-name", nameOf(entry)));
|
|
733
|
+
if (entry.changed) {
|
|
734
|
+
const mark = changedMark();
|
|
735
|
+
mark.hidden = false;
|
|
736
|
+
b.append(mark);
|
|
737
|
+
}
|
|
738
|
+
b.setAttribute("aria-label", `${nameOf(entry)}${entry.changed ? ", changed" : ""}`);
|
|
739
|
+
b.onclick = () => show(entry);
|
|
740
|
+
rows.set(entry, b);
|
|
741
|
+
list.append(b);
|
|
742
|
+
}
|
|
743
|
+
// the reader stays on the same surface: its new row, or its item on the
|
|
744
|
+
// shelf if it has left the pile
|
|
745
|
+
if (focusedEntry) (rows.get(focusedEntry) ?? (focusedEntry.stacked ? null : focusedEntry.item))?.focus();
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function openPile() {
|
|
749
|
+
// no taller than the room above the shelf, in the chat: it scrolls
|
|
750
|
+
const room = pile.getBoundingClientRect().top - chatPane.getBoundingClientRect().top - 30;
|
|
751
|
+
list.style.maxHeight = room > 0 ? `${room}px` : "";
|
|
752
|
+
list.hidden = false;
|
|
753
|
+
pileBtn.setAttribute("aria-expanded", "true");
|
|
754
|
+
list.querySelector("button")?.focus();
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function closePile(focus) {
|
|
758
|
+
if (list.hidden) return;
|
|
759
|
+
list.hidden = true;
|
|
760
|
+
pileBtn.setAttribute("aria-expanded", "false");
|
|
761
|
+
if (focus) pileBtn.focus();
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
pileBtn.onclick = () => (list.hidden ? openPile() : closePile(true));
|
|
765
|
+
|
|
766
|
+
// ── keys and clicks ──────────────────────────────────────────────────
|
|
767
|
+
// Arrows move along the shelf, and up and down the pile's list.
|
|
768
|
+
shelf.addEventListener("keydown", (e) => {
|
|
769
|
+
const inList = list.contains(e.target);
|
|
770
|
+
const keys = inList ? ["ArrowUp", "ArrowDown", "Home", "End"] : ["ArrowLeft", "ArrowRight", "Home", "End"];
|
|
771
|
+
if (!keys.includes(e.key)) return;
|
|
772
|
+
const buttons = inList
|
|
773
|
+
? [...list.querySelectorAll("button")]
|
|
774
|
+
: [...shelf.querySelectorAll(".hai-dock-item, .hai-dock-clear")].filter((b) => !b.hidden && !b.closest("[hidden]"));
|
|
775
|
+
const at = buttons.indexOf(e.target);
|
|
776
|
+
if (at < 0) return;
|
|
777
|
+
e.preventDefault();
|
|
778
|
+
const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : -1;
|
|
779
|
+
const next = e.key === "Home" ? 0 : e.key === "End" ? buttons.length - 1 : (at + step + buttons.length) % buttons.length;
|
|
780
|
+
buttons[next].focus();
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
// Escape closes the pile's list, then the window: from inside the dock, or
|
|
784
|
+
// from anywhere while the drawer is closed. With the drawer open, Escape
|
|
785
|
+
// from outside the dock is the drawer's, even with the list open behind it.
|
|
786
|
+
// Unless something there used the key first, such as a surface closing a
|
|
787
|
+
// menu of its own.
|
|
788
|
+
const onKey = (e) => {
|
|
789
|
+
if (e.key !== "Escape" || e.defaultPrevented) return;
|
|
790
|
+
const inside = win.contains(document.activeElement) || shelf.contains(document.activeElement);
|
|
791
|
+
if (!inside && drawerOpen()) return;
|
|
792
|
+
if (!list.hidden) {
|
|
793
|
+
e.preventDefault();
|
|
794
|
+
closePile(inside);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (!open) return;
|
|
798
|
+
e.preventDefault();
|
|
799
|
+
close(inside);
|
|
800
|
+
};
|
|
801
|
+
shell.addEventListener("keydown", onKey);
|
|
802
|
+
|
|
803
|
+
// a click away from the pile's list closes it, and on a narrow screen a tap
|
|
804
|
+
// on the chat the window covers docks it again, as a tap outside a sheet does
|
|
805
|
+
shell.addEventListener("pointerdown", (e) => {
|
|
806
|
+
if (!list.hidden && !pile.contains(e.target)) closePile(false);
|
|
807
|
+
});
|
|
808
|
+
chatPane.addEventListener("click", (e) => {
|
|
809
|
+
if (e.target === chatPane && transcript.inert) close(true);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
// Across the breakpoint the shelf's count comes or goes, so the row's room
|
|
813
|
+
// changes though the composer's width may not, and no resize says so.
|
|
814
|
+
const onResize = () => {
|
|
815
|
+
measured = null;
|
|
816
|
+
sync();
|
|
817
|
+
};
|
|
818
|
+
wideQuery?.addEventListener("change", onResize);
|
|
819
|
+
// the room changed: the window's place, and what fits on the shelf
|
|
820
|
+
const resized = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => (measure(), stack(true)));
|
|
821
|
+
resized?.observe(composer);
|
|
822
|
+
resized?.observe(statusEl);
|
|
823
|
+
|
|
824
|
+
return {
|
|
825
|
+
onDock,
|
|
826
|
+
sync,
|
|
827
|
+
/** Whether a surface waiting on the reader is in the dock. */
|
|
828
|
+
waiting: () => entries.some((e) => statusOf(e) === "awaiting"),
|
|
829
|
+
/** A new conversation: the last one's surfaces are gone, docked ones too. */
|
|
830
|
+
clear() {
|
|
831
|
+
closePile(false);
|
|
832
|
+
open = null;
|
|
833
|
+
win.hidden = true;
|
|
834
|
+
for (const entry of entries) {
|
|
835
|
+
entry.item.remove();
|
|
836
|
+
entry.element.remove();
|
|
837
|
+
}
|
|
838
|
+
entries.length = 0;
|
|
839
|
+
sync();
|
|
840
|
+
},
|
|
841
|
+
release() {
|
|
842
|
+
shell.removeEventListener("keydown", onKey);
|
|
843
|
+
wideQuery?.removeEventListener("change", onResize);
|
|
844
|
+
resized?.disconnect();
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** The accent square a docked surface wears while it has changed unseen. */
|
|
850
|
+
function changedMark() {
|
|
851
|
+
const mark = h("span", "hai-dock-changed");
|
|
852
|
+
mark.setAttribute("aria-hidden", "true");
|
|
853
|
+
mark.hidden = true;
|
|
854
|
+
return mark;
|
|
855
|
+
}
|
|
856
|
+
|
|
403
857
|
/** A header button: an icon, and a label that narrow screens keep for screen
|
|
404
858
|
* readers only. */
|
|
405
859
|
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)
|
|
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]))
|
|
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;
|
|
@@ -486,6 +489,81 @@
|
|
|
486
489
|
|
|
487
490
|
/* out of date — the runtime has made the surface inert; this only shows it */
|
|
488
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; }
|
|
489
567
|
.hai-expired {
|
|
490
568
|
flex-wrap: wrap;
|
|
491
569
|
align-items: center;
|
|
@@ -577,6 +655,269 @@
|
|
|
577
655
|
.hai-send .hai-icon { width: 18px; height: 18px; stroke-width: 2.5; }
|
|
578
656
|
.hai-send:disabled { opacity: .35; cursor: default; }
|
|
579
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
|
+
/* Remove all's size: the pile's list lines up past it, with the shelf's end */
|
|
663
|
+
--hai-dock-clear: 36px;
|
|
664
|
+
display: flex;
|
|
665
|
+
align-items: center;
|
|
666
|
+
gap: 10px;
|
|
667
|
+
padding: 0 14px 0 12px;
|
|
668
|
+
border: 3px solid var(--hai-line);
|
|
669
|
+
border-radius: var(--hai-radius) var(--hai-radius) 0 0;
|
|
670
|
+
background: var(--hai-panel-2);
|
|
671
|
+
box-shadow: var(--hai-shadow);
|
|
672
|
+
transition: box-shadow .15s;
|
|
673
|
+
}
|
|
674
|
+
.hai-dock[hidden] { display: none; }
|
|
675
|
+
.hai-dock:not([hidden]) + .hai-composer-box { border-top: none; border-top-left-radius: 0; border-top-right-radius: 0; }
|
|
676
|
+
.hai-composer:has(.hai-composer-box:focus-within) > .hai-dock { box-shadow: 4px 4px 0 var(--hai-accent); }
|
|
677
|
+
.hai-dock-count { flex: none; font: 700 11px var(--hai-mono); color: var(--hai-dim); }
|
|
678
|
+
/* room above and to the right for an item's corner mark, below for its shadow */
|
|
679
|
+
.hai-dock-row {
|
|
680
|
+
flex: 1;
|
|
681
|
+
min-width: 0;
|
|
682
|
+
display: flex;
|
|
683
|
+
align-items: center;
|
|
684
|
+
gap: 10px;
|
|
685
|
+
padding: 8px 8px 8px 0;
|
|
686
|
+
overflow-x: auto;
|
|
687
|
+
scrollbar-width: none;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/* a docked surface: open is pressed and inked; amber waits on you */
|
|
691
|
+
.hai-dock-item {
|
|
692
|
+
position: relative;
|
|
693
|
+
flex: none;
|
|
694
|
+
display: inline-flex;
|
|
695
|
+
align-items: center;
|
|
696
|
+
gap: 8px;
|
|
697
|
+
height: 36px;
|
|
698
|
+
padding: 0 12px;
|
|
699
|
+
box-sizing: border-box;
|
|
700
|
+
border: 2px solid var(--hai-line);
|
|
701
|
+
border-radius: var(--hai-radius);
|
|
702
|
+
background: var(--hai-panel);
|
|
703
|
+
box-shadow: 3px 3px 0 var(--hai-line);
|
|
704
|
+
color: var(--hai-fg);
|
|
705
|
+
font: 700 12px var(--hai-mono);
|
|
706
|
+
white-space: nowrap;
|
|
707
|
+
cursor: pointer;
|
|
708
|
+
}
|
|
709
|
+
.hai-dock-item[hidden] { display: none; }
|
|
710
|
+
.hai-dock-item .hai-icon { width: 14px; height: 14px; stroke-width: 2.5; }
|
|
711
|
+
.hai-dock-item:hover { background-image: linear-gradient(var(--hai-hover) 0 0); }
|
|
712
|
+
.hai-dock-item[data-status="awaiting"] { background-color: var(--hai-warn); color: var(--hai-on-accent); }
|
|
713
|
+
.hai-dock-item[aria-expanded="true"] {
|
|
714
|
+
background: var(--hai-fg);
|
|
715
|
+
color: var(--hai-panel);
|
|
716
|
+
box-shadow: none;
|
|
717
|
+
transform: translate(3px, 3px);
|
|
718
|
+
}
|
|
719
|
+
.hai-dock-item[data-status="expired"] {
|
|
720
|
+
border-style: dashed;
|
|
721
|
+
border-color: var(--hai-dim);
|
|
722
|
+
background: transparent;
|
|
723
|
+
box-shadow: none;
|
|
724
|
+
color: var(--hai-dim);
|
|
725
|
+
}
|
|
726
|
+
.hai-dock-dot {
|
|
727
|
+
width: 10px;
|
|
728
|
+
height: 10px;
|
|
729
|
+
box-sizing: border-box;
|
|
730
|
+
border: 2px solid;
|
|
731
|
+
border-radius: 50%;
|
|
732
|
+
background: var(--hai-accent);
|
|
733
|
+
}
|
|
734
|
+
.hai-dock-dot[hidden] { display: none; }
|
|
735
|
+
.hai-dock-item[aria-expanded="true"] .hai-dock-dot { background: var(--hai-warn); }
|
|
736
|
+
.hai-dock-changed {
|
|
737
|
+
position: absolute;
|
|
738
|
+
top: -7px;
|
|
739
|
+
right: -7px;
|
|
740
|
+
width: 12px;
|
|
741
|
+
height: 12px;
|
|
742
|
+
box-sizing: border-box;
|
|
743
|
+
border: 2px solid var(--hai-line);
|
|
744
|
+
background: var(--hai-accent);
|
|
745
|
+
}
|
|
746
|
+
.hai-dock-changed[hidden] { display: none; }
|
|
747
|
+
|
|
748
|
+
/* the pile the oldest go into when the row is full: two edges behind it */
|
|
749
|
+
.hai-dock-pile {
|
|
750
|
+
position: relative;
|
|
751
|
+
/* above an open window, which its list may overlap */
|
|
752
|
+
z-index: 6;
|
|
753
|
+
isolation: isolate;
|
|
754
|
+
flex: none;
|
|
755
|
+
display: inline-flex;
|
|
756
|
+
margin: 8px 8px 8px 0;
|
|
757
|
+
}
|
|
758
|
+
.hai-dock-pile[hidden] { display: none; }
|
|
759
|
+
.hai-dock-pile::before,
|
|
760
|
+
.hai-dock-pile::after {
|
|
761
|
+
content: "";
|
|
762
|
+
position: absolute;
|
|
763
|
+
inset: 0;
|
|
764
|
+
z-index: -1;
|
|
765
|
+
box-sizing: border-box;
|
|
766
|
+
border: 2px solid var(--hai-line);
|
|
767
|
+
background: var(--hai-panel);
|
|
768
|
+
transform: translate(8px, -8px);
|
|
769
|
+
}
|
|
770
|
+
.hai-dock-pile::after { transform: translate(4px, -4px); }
|
|
771
|
+
/* Remove all, at the shelf's end: a quiet icon until the pointer's on it */
|
|
772
|
+
.hai-dock-clear {
|
|
773
|
+
flex: none;
|
|
774
|
+
display: grid;
|
|
775
|
+
place-items: center;
|
|
776
|
+
width: var(--hai-dock-clear);
|
|
777
|
+
height: var(--hai-dock-clear);
|
|
778
|
+
padding: 0;
|
|
779
|
+
border: 2px solid transparent;
|
|
780
|
+
border-radius: var(--hai-radius);
|
|
781
|
+
background: none;
|
|
782
|
+
color: var(--hai-dim);
|
|
783
|
+
cursor: pointer;
|
|
784
|
+
}
|
|
785
|
+
.hai-dock-clear .hai-icon { width: 16px; height: 16px; stroke-width: 2.5; }
|
|
786
|
+
.hai-dock-clear:hover { border-color: var(--hai-line); background: var(--hai-hover); color: var(--hai-fg); }
|
|
787
|
+
/* what's in it, oldest first, above the shelf */
|
|
788
|
+
/* Its right edge lines up with the shelf's: past the pile's margin, the gap,
|
|
789
|
+
Remove all, the shelf's padding and its border. So it opens leftward from
|
|
790
|
+
the shelf's end, and fits whatever the screen fits. */
|
|
791
|
+
.hai-pile-list {
|
|
792
|
+
position: absolute;
|
|
793
|
+
right: calc(-1 * (8px + 10px + var(--hai-dock-clear, 36px) + 14px + 3px));
|
|
794
|
+
bottom: calc(100% + 18px);
|
|
795
|
+
z-index: 6;
|
|
796
|
+
width: 280px;
|
|
797
|
+
max-width: calc(100vw - 32px);
|
|
798
|
+
/* the room above the shelf, measured as it opens; this until then */
|
|
799
|
+
max-height: 60vh;
|
|
800
|
+
overflow-y: auto;
|
|
801
|
+
scrollbar-width: thin;
|
|
802
|
+
scrollbar-color: var(--hai-line) transparent;
|
|
803
|
+
border: 3px solid var(--hai-line);
|
|
804
|
+
border-radius: var(--hai-radius);
|
|
805
|
+
background: var(--hai-panel);
|
|
806
|
+
box-shadow: var(--hai-shadow);
|
|
807
|
+
}
|
|
808
|
+
.hai-pile-list[hidden] { display: none; }
|
|
809
|
+
.hai-pile-head {
|
|
810
|
+
position: sticky;
|
|
811
|
+
top: 0;
|
|
812
|
+
padding: 6px 12px;
|
|
813
|
+
border-bottom: 2px solid var(--hai-line);
|
|
814
|
+
background: var(--hai-panel-2);
|
|
815
|
+
font: 700 11px var(--hai-mono);
|
|
816
|
+
color: var(--hai-dim);
|
|
817
|
+
}
|
|
818
|
+
.hai-pile-row {
|
|
819
|
+
display: flex;
|
|
820
|
+
align-items: center;
|
|
821
|
+
gap: 10px;
|
|
822
|
+
width: 100%;
|
|
823
|
+
min-height: 44px;
|
|
824
|
+
padding: 0 12px;
|
|
825
|
+
border: none;
|
|
826
|
+
border-bottom: 2px solid var(--hai-line-soft);
|
|
827
|
+
background: none;
|
|
828
|
+
color: var(--hai-fg);
|
|
829
|
+
font: 700 12px var(--hai-mono);
|
|
830
|
+
text-align: left;
|
|
831
|
+
cursor: pointer;
|
|
832
|
+
}
|
|
833
|
+
.hai-pile-row:last-child { border-bottom: none; }
|
|
834
|
+
.hai-pile-row:hover { background: var(--hai-hover); }
|
|
835
|
+
.hai-pile-row .hai-icon { width: 14px; height: 14px; stroke-width: 2.5; }
|
|
836
|
+
.hai-pile-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
837
|
+
.hai-pile-row .hai-dock-changed { position: static; flex: none; }
|
|
838
|
+
|
|
839
|
+
/* ── a docked surface, open as a window ─────────────────────────────── */
|
|
840
|
+
/* Above the status line and the composer, in the column; raised higher than a
|
|
841
|
+
card. Its title bar is inked like its pressed item, amber while it waits. */
|
|
842
|
+
.hai-window {
|
|
843
|
+
position: absolute;
|
|
844
|
+
z-index: 5;
|
|
845
|
+
left: var(--hai-gutter);
|
|
846
|
+
bottom: calc(var(--hai-foot, 140px) + 8px);
|
|
847
|
+
display: flex;
|
|
848
|
+
flex-direction: column;
|
|
849
|
+
width: min(560px, 100% - 2 * var(--hai-gutter));
|
|
850
|
+
max-height: calc(100% - var(--hai-foot, 140px) - 32px);
|
|
851
|
+
box-sizing: border-box;
|
|
852
|
+
border: 3px solid var(--hai-line);
|
|
853
|
+
border-radius: var(--hai-radius);
|
|
854
|
+
background: var(--hai-panel);
|
|
855
|
+
box-shadow: 8px 8px 0 var(--hai-line);
|
|
856
|
+
color: var(--hai-fg);
|
|
857
|
+
}
|
|
858
|
+
.hai-window[hidden] { display: none; }
|
|
859
|
+
.hai-window:focus { outline: none; }
|
|
860
|
+
.hai-window:focus-visible { outline: 3px solid var(--hai-fg); outline-offset: 2px; }
|
|
861
|
+
.hai-window-bar {
|
|
862
|
+
flex: none;
|
|
863
|
+
display: flex;
|
|
864
|
+
align-items: stretch;
|
|
865
|
+
gap: 10px;
|
|
866
|
+
min-height: 40px;
|
|
867
|
+
padding-left: 12px;
|
|
868
|
+
border-bottom: 3px solid var(--hai-line);
|
|
869
|
+
background: var(--hai-fg);
|
|
870
|
+
color: var(--hai-panel);
|
|
871
|
+
}
|
|
872
|
+
.hai-window-bar > .hai-icon { align-self: center; width: 14px; height: 14px; stroke-width: 2.5; }
|
|
873
|
+
/* a long name gives way, after the tool's, so the bar's buttons stay in reach */
|
|
874
|
+
.hai-window-name {
|
|
875
|
+
flex: 0 1 auto;
|
|
876
|
+
min-width: 0;
|
|
877
|
+
align-self: center;
|
|
878
|
+
overflow: hidden;
|
|
879
|
+
text-overflow: ellipsis;
|
|
880
|
+
white-space: nowrap;
|
|
881
|
+
font: 700 12.5px var(--hai-mono);
|
|
882
|
+
}
|
|
883
|
+
.hai-window-tool {
|
|
884
|
+
flex: 1;
|
|
885
|
+
min-width: 0;
|
|
886
|
+
align-self: center;
|
|
887
|
+
overflow: hidden;
|
|
888
|
+
text-overflow: ellipsis;
|
|
889
|
+
white-space: nowrap;
|
|
890
|
+
font: 12px var(--hai-mono);
|
|
891
|
+
}
|
|
892
|
+
.hai-window-badge { flex: none; align-self: center; }
|
|
893
|
+
.hai-window-badge[hidden] { display: none; }
|
|
894
|
+
.hai-window-bar > .hai-bar-btn + .hai-bar-btn { margin-left: -10px; }
|
|
895
|
+
.hai-window[data-status="awaiting"] .hai-window-bar { background: var(--hai-warn); color: var(--hai-on-accent); }
|
|
896
|
+
.hai-window[data-status="expired"] { border-style: dashed; border-color: var(--hai-dim); box-shadow: none; }
|
|
897
|
+
.hai-window[data-status="expired"] .hai-window-bar {
|
|
898
|
+
border-bottom-style: dashed;
|
|
899
|
+
border-color: var(--hai-dim);
|
|
900
|
+
background: color-mix(in srgb, var(--hai-dim) 10%, var(--hai-panel-2));
|
|
901
|
+
color: var(--hai-fg);
|
|
902
|
+
}
|
|
903
|
+
.hai-window[data-status="expired"] .hai-window-badge { background: none; }
|
|
904
|
+
.hai-window-body {
|
|
905
|
+
min-height: 0;
|
|
906
|
+
overflow-y: auto;
|
|
907
|
+
scrollbar-width: thin;
|
|
908
|
+
scrollbar-color: var(--hai-line) transparent;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/* for a screen reader only: what docking just did */
|
|
912
|
+
.hai-dock-live {
|
|
913
|
+
position: absolute;
|
|
914
|
+
width: 1px;
|
|
915
|
+
height: 1px;
|
|
916
|
+
overflow: hidden;
|
|
917
|
+
clip-path: inset(50%);
|
|
918
|
+
white-space: nowrap;
|
|
919
|
+
}
|
|
920
|
+
|
|
580
921
|
/* ── model context debug drawer ─────────────────────────────────────── */
|
|
581
922
|
/* Closed, it waits off the right edge, out of the tab order and the
|
|
582
923
|
accessibility tree; open, it slides in, over the chat on a narrow screen. */
|
|
@@ -652,6 +993,32 @@
|
|
|
652
993
|
clip-path: inset(50%);
|
|
653
994
|
white-space: nowrap;
|
|
654
995
|
}
|
|
996
|
+
/* the window is a sheet over the chat, down to the composer; the chat
|
|
997
|
+
behind it dims, out of reach until it docks again */
|
|
998
|
+
.hai-window {
|
|
999
|
+
left: 0;
|
|
1000
|
+
right: 0;
|
|
1001
|
+
top: 12%;
|
|
1002
|
+
bottom: var(--hai-composer-height, 120px);
|
|
1003
|
+
width: auto;
|
|
1004
|
+
max-height: none;
|
|
1005
|
+
border-left: none;
|
|
1006
|
+
border-right: none;
|
|
1007
|
+
border-radius: 0;
|
|
1008
|
+
box-shadow: none;
|
|
1009
|
+
}
|
|
1010
|
+
.hai-chat.window-open > .hai-transcript,
|
|
1011
|
+
.hai-chat.window-open > .hai-status { opacity: .4; }
|
|
1012
|
+
.hai-dock-count { display: none; }
|
|
1013
|
+
}
|
|
1014
|
+
/* a finger's worth, wherever the pointer is coarse */
|
|
1015
|
+
@media (pointer: coarse) {
|
|
1016
|
+
.hai-dock-item { height: 44px; }
|
|
1017
|
+
.hai-dock { --hai-dock-clear: 44px; }
|
|
1018
|
+
.hai-surface-bar { min-height: 44px; }
|
|
1019
|
+
.hai-docked { min-height: 44px; }
|
|
1020
|
+
.hai-window-bar { min-height: 44px; }
|
|
1021
|
+
.hai-bar-icon { width: 44px; }
|
|
655
1022
|
}
|
|
656
1023
|
@media (max-width: 599.98px) {
|
|
657
1024
|
.hai-model { display: none; }
|
|
@@ -667,6 +1034,12 @@
|
|
|
667
1034
|
.hai-new:focus-visible { outline: 3px solid var(--hai-fg); outline-offset: 2px; }
|
|
668
1035
|
/* inside the row's border, clear of it, in the row's text colour: ink on amber too */
|
|
669
1036
|
.hai-tool summary:focus-visible { outline: 2px solid currentColor; outline-offset: -5px; }
|
|
1037
|
+
/* the dock's items and its pile take the buttons' ring; a bar's cells and the
|
|
1038
|
+
pile's rows, where a card would clip it, a ring inside */
|
|
1039
|
+
.hai-dock-item:focus-visible,
|
|
1040
|
+
.hai-dock-clear:focus-visible { outline: 3px solid var(--hai-fg); outline-offset: 2px; }
|
|
1041
|
+
.hai-bar-btn:focus-visible,
|
|
1042
|
+
.hai-pile-row:focus-visible { outline: 2px solid currentColor; outline-offset: -6px; }
|
|
670
1043
|
|
|
671
1044
|
@keyframes hai-pulse {
|
|
672
1045
|
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--hai-warn) 55%, transparent); }
|
|
@@ -682,6 +1055,7 @@
|
|
|
682
1055
|
.hai-shimmer { animation: none; }
|
|
683
1056
|
.hai-chevron,
|
|
684
1057
|
.hai-composer-box,
|
|
1058
|
+
.hai-dock,
|
|
685
1059
|
.hai-chat,
|
|
686
1060
|
.hai-inspector,
|
|
687
1061
|
.hai-main.inspector-open > .hai-inspector { transition: none; }
|
package/src/icons.js
CHANGED
|
@@ -16,6 +16,13 @@ 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
|
+
undockAll: "M12 13V3M7.5 7.5 12 3l4.5 4.5M4 17h16M4 21h16",
|
|
25
|
+
stack: "M3 8h13v13H3zM8 8V3h13v13h-5",
|
|
19
26
|
};
|
|
20
27
|
|
|
21
28
|
const SVG = "http://www.w3.org/2000/svg";
|
package/src/transcript.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|