@artooi/ag-ui-web-component 0.46.0 → 0.48.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +221 -1
  2. package/README.md +298 -31
  3. package/dist/ag-ui-web-component.bundle.js +597 -177
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +37 -2
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +73 -2
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/chat_registry.d.ts +23 -0
  10. package/dist/core/chat_registry.d.ts.map +1 -0
  11. package/dist/core/events/bar_height_detail.d.ts +6 -0
  12. package/dist/core/events/bar_height_detail.d.ts.map +1 -0
  13. package/dist/core/events/run_finished_detail.d.ts +6 -0
  14. package/dist/core/events/run_finished_detail.d.ts.map +1 -1
  15. package/dist/core/events/thread_detail.d.ts +27 -0
  16. package/dist/core/events/thread_detail.d.ts.map +1 -0
  17. package/dist/core/run_handlers.d.ts +6 -1
  18. package/dist/core/run_handlers.d.ts.map +1 -1
  19. package/dist/dom/animations.d.ts +21 -6
  20. package/dist/dom/animations.d.ts.map +1 -1
  21. package/dist/dom/highlight_overlay.d.ts +8 -1
  22. package/dist/dom/highlight_overlay.d.ts.map +1 -1
  23. package/dist/dom/ring_color.d.ts +22 -0
  24. package/dist/dom/ring_color.d.ts.map +1 -0
  25. package/dist/index.d.ts +4 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1164 -95
  28. package/dist/index.js.map +4 -4
  29. package/dist/tools/chat_surface_tools.d.ts +8 -1
  30. package/dist/tools/chat_surface_tools.d.ts.map +1 -1
  31. package/dist/ui/history/conversation_history.d.ts +24 -2
  32. package/dist/ui/history/conversation_history.d.ts.map +1 -1
  33. package/dist/ui/placement/collapse_styles.d.ts.map +1 -1
  34. package/dist/ui/placement/dock_styles.d.ts +2 -0
  35. package/dist/ui/placement/dock_styles.d.ts.map +1 -0
  36. package/dist/ui/placement/panel_placement.d.ts +17 -0
  37. package/dist/ui/placement/panel_placement.d.ts.map +1 -1
  38. package/dist/ui/placement/small_viewport_styles.d.ts.map +1 -1
  39. package/dist/ui/placement/surface_for.d.ts +21 -17
  40. package/dist/ui/placement/surface_for.d.ts.map +1 -1
  41. package/dist/ui/progress/tool_call_card.d.ts.map +1 -1
  42. package/dist/ui/progress/tool_group_styles.d.ts.map +1 -1
  43. package/dist/ui/progress/tool_step_group.d.ts.map +1 -1
  44. package/dist/ui/progress/tool_step_styles.d.ts.map +1 -1
  45. package/dist/ui/shell/build_launcher.d.ts +15 -4
  46. package/dist/ui/shell/build_launcher.d.ts.map +1 -1
  47. package/dist/ui/shell/token_styles.d.ts.map +1 -1
  48. package/dist/ui/styles.d.ts.map +1 -1
  49. package/dist/ui/themes/scheme_styles.d.ts.map +1 -1
  50. package/package.json +1 -1
  51. package/src/constants.ts +41 -2
  52. package/src/core/ag_ui_chat.ts +430 -27
  53. package/src/core/chat_registry.ts +53 -0
  54. package/src/core/events/bar_height_detail.ts +5 -0
  55. package/src/core/events/run_finished_detail.ts +6 -0
  56. package/src/core/events/thread_detail.ts +26 -0
  57. package/src/core/run_handlers.ts +9 -4
  58. package/src/dom/animations.ts +30 -31
  59. package/src/dom/highlight_overlay.ts +10 -5
  60. package/src/dom/ring_color.ts +222 -0
  61. package/src/index.ts +7 -0
  62. package/src/tools/chat_surface_tools.ts +18 -8
  63. package/src/ui/history/conversation_history.ts +75 -7
  64. package/src/ui/placement/collapse_styles.ts +248 -2
  65. package/src/ui/placement/dock_styles.ts +91 -0
  66. package/src/ui/placement/embedded_styles.ts +2 -2
  67. package/src/ui/placement/panel_placement.ts +105 -12
  68. package/src/ui/placement/preset_styles.ts +1 -1
  69. package/src/ui/placement/sidebar_styles.ts +1 -1
  70. package/src/ui/placement/small_viewport_styles.ts +14 -0
  71. package/src/ui/placement/surface_for.ts +29 -18
  72. package/src/ui/progress/tool_call_card.ts +18 -8
  73. package/src/ui/progress/tool_group_styles.ts +49 -0
  74. package/src/ui/progress/tool_step_group.ts +3 -0
  75. package/src/ui/progress/tool_step_styles.ts +5 -3
  76. package/src/ui/shell/build_launcher.ts +32 -5
  77. package/src/ui/shell/token_styles.ts +30 -3
  78. package/src/ui/styles.ts +4 -0
  79. package/src/ui/themes/scheme_styles.ts +10 -1
  80. package/src/version.ts +1 -1
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The `<ag-ui-chat>` elements connected to this document, oldest first.
3
+ *
4
+ * The highlight ring is drawn in the host's page, outside every chat, so a
5
+ * token set on a chat never reaches it through the cascade. A free `flash()`
6
+ * still has to answer "which widget is this ring speaking for", and that is a
7
+ * question about the live elements rather than about any one of them.
8
+ *
9
+ * Module-level on purpose, and the second piece of shared mutable state the
10
+ * repository's `CLAUDE.md` records, beside the claimed storage namespaces. See
11
+ * the conditions it states, and how this one meets them, before adding a
12
+ * third.
13
+ */
14
+ const CONNECTED_CHATS = new Set<HTMLElement>();
15
+
16
+ /**
17
+ * One chat's place in the document's list of connected chats.
18
+ *
19
+ * Owned one-to-one by an `<ag-ui-chat>`, which joins on connect and leaves on
20
+ * disconnect, so the list holds exactly the elements that are in a document
21
+ * now. Typed as `HTMLElement` rather than the element's class, because the DOM
22
+ * driver reads it and must not import the element.
23
+ */
24
+ export class ChatRegistration {
25
+ readonly #chat: HTMLElement;
26
+
27
+ constructor(chat: HTMLElement) {
28
+ this.#chat = chat;
29
+ }
30
+
31
+ /**
32
+ * Join, as the most recently connected chat. An element moved in the DOM
33
+ * left on disconnect before it reconnected, so it rejoins at the end and no
34
+ * entry needs removing first.
35
+ */
36
+ join(): void {
37
+ CONNECTED_CHATS.add(this.#chat);
38
+ }
39
+
40
+ /** Leave. The previous chat, if one is still connected, becomes the latest. */
41
+ leave(): void {
42
+ CONNECTED_CHATS.delete(this.#chat);
43
+ }
44
+
45
+ /** The most recently connected chat still in the document, or `null`. */
46
+ static latest(): HTMLElement | null {
47
+ let latest: HTMLElement | null = null;
48
+ for (const chat of CONNECTED_CHATS) {
49
+ latest = chat;
50
+ }
51
+ return latest;
52
+ }
53
+ }
@@ -0,0 +1,5 @@
1
+ /** `detail` shape of the {@link BAR_HEIGHT_EVENT} CustomEvent. */
2
+ export interface BarHeightDetail {
3
+ /** The bottom bar's height in CSS px; 0 when no bar is drawn. */
4
+ readonly height: number;
5
+ }
@@ -2,6 +2,12 @@ import type { ToolRun } from "./tool_run.js";
2
2
 
3
3
  /** `detail` shape of the {@link RUN_FINISHED_EVENT} CustomEvent. */
4
4
  export interface RunFinishedDetail {
5
+ /**
6
+ * The thread the run belonged to, fixed when its client was built. Not always
7
+ * the thread on screen: a run stopped by a new chat or a thread switch still
8
+ * reports once its request closes, and by then another thread is.
9
+ */
10
+ readonly threadId: string;
5
11
  /** In settle order. Empty when the interaction called no tools. */
6
12
  readonly tools: readonly ToolRun[];
7
13
  /**
@@ -0,0 +1,26 @@
1
+ /** `detail` shape of the {@link THREAD_EVENT} CustomEvent. */
2
+ export interface ThreadDetail {
3
+ /** The thread now on screen. */
4
+ readonly threadId: string;
5
+ /**
6
+ * The thread on screen before the change: the one the previous event
7
+ * reported, and `null` before the first, as at the restore when the element
8
+ * first connects. Equal to `threadId` when the same thread was restored
9
+ * again (`reload()`, the element connecting again).
10
+ */
11
+ readonly previousThreadId: string | null;
12
+ /**
13
+ * What changed it:
14
+ *
15
+ * - `new`: `newChat()` or the built-in New chat button.
16
+ * - `switch`: `switchThread()` or a row picked in the conversation list.
17
+ * - `delete`: the active thread was deleted, and a fresh one took its place.
18
+ * - `restore`: the stored thread was replayed, on connect or by `reload()`.
19
+ * - `rescope`: a `user-key` change handed the element to another principal,
20
+ * live or while it was out of the document.
21
+ *
22
+ * A closed set today; a host should ignore a reason it does not know, so a
23
+ * later one can be added without breaking it.
24
+ */
25
+ readonly reason: "new" | "switch" | "delete" | "restore" | "rescope";
26
+ }
@@ -176,8 +176,13 @@ export class RunHandlers {
176
176
  * A handler table for one AG-UI client. Every table built for the current
177
177
  * conversation draws into the same transcript and shares its bookkeeping, so
178
178
  * a continuation run reports into the interaction the user is looking at.
179
+ *
180
+ * `threadId` is the thread the client was built for, named on the
181
+ * run-finished detail. Taken here rather than read when the run settles,
182
+ * because a stopped run settles after the reset that stopped it, when the
183
+ * thread on screen is already the next one.
179
184
  */
180
- forClient(): AgUiClientHandlers {
185
+ forClient(threadId: string): AgUiClientHandlers {
181
186
  const ledger = this.#ledger;
182
187
  const attached = (): boolean => ledger === this.#ledger;
183
188
  const { onSettled, ...drawing } = this.#tableFor(ledger);
@@ -190,7 +195,7 @@ export class RunHandlers {
190
195
  // Last, so a listener that refetches sees a transcript that has already
191
196
  // stopped changing -- and outside the check, because what a run did is
192
197
  // true whichever conversation is on screen when it ends.
193
- this.#dispatchRunFinished(ledger);
198
+ this.#dispatchRunFinished(ledger, threadId);
194
199
  },
195
200
  };
196
201
  }
@@ -444,7 +449,7 @@ export class RunHandlers {
444
449
  * server is a fact about the run, and a name can appear on both sides across a
445
450
  * conversation.
446
451
  */
447
- #dispatchRunFinished(ledger: RunLedger): void {
452
+ #dispatchRunFinished(ledger: RunLedger, threadId: string): void {
448
453
  const tools: ToolRun[] = ledger.tools.map(({ id, name }) => ({
449
454
  name,
450
455
  side: ledger.serverSettled.has(id) ? "server" : "client",
@@ -455,7 +460,7 @@ export class RunHandlers {
455
460
  ledger.invalidated.clear();
456
461
  this.#host.element.dispatchEvent(
457
462
  new CustomEvent<RunFinishedDetail>(RUN_FINISHED_EVENT, {
458
- detail: { tools, invalidated },
463
+ detail: { threadId, tools, invalidated },
459
464
  bubbles: true,
460
465
  composed: true,
461
466
  }),
@@ -8,17 +8,7 @@
8
8
 
9
9
  import { showHighlightOverlay } from "./highlight_overlay.js";
10
10
  import { setNativeChecked, setNativeValue } from "./native_setter.js";
11
-
12
- /** Ring colour when the host page has not themed `--ag-ui-accent`. */
13
- const ACCENT = "#4f46e5";
14
-
15
- /**
16
- * Fallback for the box-shadow rings, which read as a soft halo rather than a
17
- * line and so carry their own alpha.
18
- */
19
- const ACCENT_HALO = "rgba(79, 70, 229, 0.4)";
20
-
21
- const ACCENT_PROPERTY = "--ag-ui-accent";
11
+ import { ringColor } from "./ring_color.js";
22
12
 
23
13
  function delay(ms: number): Promise<void> {
24
14
  return new Promise<void>((resolve) => {
@@ -48,19 +38,23 @@ function motionDelay(ms: number): Promise<void> {
48
38
  }
49
39
 
50
40
  /**
51
- * Resolve the accent colour from the target's own computed style.
41
+ * Which `<ag-ui-chat>` a ring speaks for, so it is drawn in that chat's colour.
52
42
  *
53
- * `--ag-ui-accent` inherits, so reading it off the element about to be touched
54
- * picks up whatever the host's cascade produced there, and a themed page is
55
- * flashed in its own colour rather than this package's indigo.
43
+ * The ring is drawn in the host's page, which a token set on the chat never
44
+ * reaches, so the chat is read directly. Unset, it is the most recently
45
+ * connected chat; a page with two widgets names the one acting.
56
46
  */
57
- function accentColor(el: HTMLElement, fallback: string): string {
58
- const themed = window.getComputedStyle(el).getPropertyValue(ACCENT_PROPERTY).trim();
59
- return themed === "" ? fallback : themed;
47
+ export interface RingOwnerOptions {
48
+ /** The chat whose ring colour and accent to use. Default: the latest connected. */
49
+ chat?: HTMLElement;
60
50
  }
61
51
 
62
- function ringShadow(el: HTMLElement): string {
63
- return `0 0 0 3px ${accentColor(el, ACCENT_HALO)}`;
52
+ /**
53
+ * The soft halo the press and toggle rings draw: the ring colour at 40%.
54
+ * Mixed rather than given its own alpha, so it follows whichever colour won.
55
+ */
56
+ function ringShadow(el: HTMLElement, chat: HTMLElement | undefined): string {
57
+ return `0 0 0 3px color-mix(in srgb, ${ringColor(el, chat)} 40%, transparent)`;
64
58
  }
65
59
 
66
60
  /** An element whose `value` can be typed into. */
@@ -93,7 +87,7 @@ export async function typeInto(
93
87
  el.dispatchEvent(new Event("change", { bubbles: true }));
94
88
  }
95
89
 
96
- export interface HighlightClickOptions {
90
+ export interface HighlightClickOptions extends RingOwnerOptions {
97
91
  /** Milliseconds to hold the highlight before clicking. Default 280. */
98
92
  highlightMs?: number;
99
93
  }
@@ -106,7 +100,7 @@ export async function highlightThenClick(
106
100
  const highlightMs = options.highlightMs ?? 280;
107
101
  const previousOutline = el.style.outline;
108
102
  const previousOffset = el.style.outlineOffset;
109
- el.style.outline = `2px solid ${accentColor(el, ACCENT)}`;
103
+ el.style.outline = `2px solid ${ringColor(el, options.chat)}`;
110
104
  el.style.outlineOffset = "2px";
111
105
  await delay(highlightMs);
112
106
  el.style.outline = previousOutline;
@@ -176,10 +170,14 @@ const FLASH_MS = 1200;
176
170
  /** Share of {@link FLASH_MS} spent fading out rather than holding. */
177
171
  const FLASH_FADE_RATIO = 1 / 3;
178
172
 
179
- export interface FlashOptions {
173
+ export interface FlashOptions extends RingOwnerOptions {
180
174
  /** Total milliseconds the ring is on screen, hold plus fade. Default 1200. */
181
175
  flashMs?: number;
182
- /** Ring colour. Defaults to the target's `--ag-ui-accent`, else the package accent. */
176
+ /**
177
+ * Ring colour. Defaults to `--ag-ui-highlight-ring-color` (on the target,
178
+ * then on the chat), then the target's `--ag-ui-accent`, then the chat's
179
+ * accent.
180
+ */
183
181
  color?: string;
184
182
  /**
185
183
  * Dim everything except the target.
@@ -226,6 +224,7 @@ async function flashRing(
226
224
  gradient: options.gradient === true,
227
225
  ...(options.color === undefined ? {} : { color: options.color }),
228
226
  ...(options.ringPadding === undefined ? {} : { padding: options.ringPadding }),
227
+ ...(options.chat === undefined ? {} : { chat: options.chat }),
229
228
  });
230
229
  await delay(flashMs);
231
230
  dismiss();
@@ -234,7 +233,7 @@ async function flashRing(
234
233
  const previousOutline = el.style.outline;
235
234
  const previousOffset = el.style.outlineOffset;
236
235
  const previousTransition = el.style.transition;
237
- const color = options.color ?? accentColor(el, ACCENT);
236
+ const color = options.color ?? ringColor(el, options.chat);
238
237
  // outline, not box-shadow: a shadow paints outside the border box, so any
239
238
  // overflow:hidden ancestor sharing the target's box (a card, a table cell)
240
239
  // clips the whole ring away while the helper still reports success.
@@ -273,7 +272,7 @@ export function focusWithFlash(el: HTMLElement, options: FlashOptions = {}): Pro
273
272
  return flashRing(el, options, true);
274
273
  }
275
274
 
276
- export interface PressOptions {
275
+ export interface PressOptions extends RingOwnerOptions {
277
276
  /** Milliseconds to hold the pressed state before clicking. Default 140. */
278
277
  pressMs?: number;
279
278
  }
@@ -290,7 +289,7 @@ export async function pressThenClick(el: HTMLElement, options: PressOptions = {}
290
289
  const previousShadow = el.style.boxShadow;
291
290
  el.style.transition = "transform 80ms ease";
292
291
  el.style.transform = "scale(0.96)";
293
- el.style.boxShadow = ringShadow(el);
292
+ el.style.boxShadow = ringShadow(el, options.chat);
294
293
  await motionDelay(pressMs);
295
294
  el.style.transform = previousTransform;
296
295
  el.style.transition = previousTransition;
@@ -298,7 +297,7 @@ export async function pressThenClick(el: HTMLElement, options: PressOptions = {}
298
297
  el.click();
299
298
  }
300
299
 
301
- export interface SelectOptions {
300
+ export interface SelectOptions extends RingOwnerOptions {
302
301
  /** Milliseconds to hold the highlight before committing. Default 220. */
303
302
  highlightMs?: number;
304
303
  }
@@ -330,7 +329,7 @@ export async function selectOption(
330
329
  const highlightMs = options.highlightMs ?? 220;
331
330
  const previousOutline = el.style.outline;
332
331
  const previousOffset = el.style.outlineOffset;
333
- el.style.outline = `2px solid ${accentColor(el, ACCENT)}`;
332
+ el.style.outline = `2px solid ${ringColor(el, options.chat)}`;
334
333
  el.style.outlineOffset = "2px";
335
334
  await motionDelay(highlightMs);
336
335
  setNativeValue(el, option.value);
@@ -340,7 +339,7 @@ export async function selectOption(
340
339
  el.style.outlineOffset = previousOffset;
341
340
  }
342
341
 
343
- export interface ToggleOptions {
342
+ export interface ToggleOptions extends RingOwnerOptions {
344
343
  /** Milliseconds to hold the flash. Default 200. */
345
344
  flashMs?: number;
346
345
  }
@@ -356,7 +355,7 @@ export async function toggleControl(
356
355
  ): Promise<void> {
357
356
  const flashMs = options.flashMs ?? 200;
358
357
  const previousShadow = el.style.boxShadow;
359
- el.style.boxShadow = ringShadow(el);
358
+ el.style.boxShadow = ringShadow(el, options.chat);
360
359
  await motionDelay(flashMs);
361
360
  setNativeChecked(el, checked);
362
361
  el.dispatchEvent(new Event("input", { bubbles: true }));
@@ -1,7 +1,5 @@
1
1
  import { HIGHLIGHT_OVERLAY_Z_INDEX } from "../constants.js";
2
-
3
- /** The package accent, used when the host has themed nothing. */
4
- const ACCENT = "#4f46e5";
2
+ import { ringColor } from "./ring_color.js";
5
3
 
6
4
  /** How the overlay is drawn around the element being pointed at. */
7
5
  export interface HighlightOverlayOptions {
@@ -9,8 +7,15 @@ export interface HighlightOverlayOptions {
9
7
  readonly scrim?: boolean;
10
8
  /** Draw the ring as a moving gradient rather than a flat colour. Default false. */
11
9
  readonly gradient?: boolean;
12
- /** Ring colour, and the flat fallback for the gradient. */
10
+ /**
11
+ * Ring colour, and the flat fallback for the gradient. Default:
12
+ * `--ag-ui-highlight-ring-color` on the target, then on the chat, then the
13
+ * target's `--ag-ui-accent`, then the chat's accent, held to 3:1 against the
14
+ * surface behind the target.
15
+ */
13
16
  readonly color?: string;
17
+ /** The chat whose ring colour and accent to use. Default: the latest connected. */
18
+ readonly chat?: HTMLElement;
14
19
  /** Gap between the target's box and the ring. Default 4. */
15
20
  readonly padding?: number;
16
21
  /** Corner radius of the cut-out and the ring. Default: the target's own. */
@@ -205,7 +210,7 @@ function safeColor(value: string | undefined): string | null {
205
210
  * the padding box, leaving the frame.
206
211
  */
207
212
  function ringPaint(target: HTMLElement, options: HighlightOverlayOptions): string {
208
- const color = safeColor(options.color) ?? tokenOn(target, "--ag-ui-accent", ACCENT);
213
+ const color = safeColor(options.color) ?? ringColor(target, options.chat);
209
214
  if (options.gradient !== true) {
210
215
  return `border-color: ${color}`;
211
216
  }
@@ -0,0 +1,222 @@
1
+ import { ChatRegistration } from "../core/chat_registry.js";
2
+
3
+ /** The public ring token, read from the target and then from the chat. */
4
+ const RING_TOKEN = "--ag-ui-highlight-ring-color";
5
+
6
+ /** The accent a host themes its page with, read from the target. */
7
+ const ACCENT_TOKEN = "--ag-ui-accent";
8
+
9
+ /**
10
+ * The accent the chat actually resolved, its theme's default included. Read
11
+ * through the private alias because the public token is unset on a page that
12
+ * themed nothing, which is exactly the page this exists for.
13
+ */
14
+ const CHAT_ACCENT = "--_accent";
15
+
16
+ /** The package indigo, for a page with no chat and no theming at all. */
17
+ const PACKAGE_ACCENT = "#4f46e5";
18
+
19
+ /**
20
+ * The colour a highlight ring is drawn in when the caller passed none.
21
+ *
22
+ * In order: `--ag-ui-highlight-ring-color` on the target, the same token on
23
+ * the chat, the target's `--ag-ui-accent`, the chat's resolved accent, the
24
+ * package indigo. The target's accent stays above the chat's own default so a
25
+ * host that themes `--ag-ui-accent` on its page keeps the rings it has; the
26
+ * ring token sits above both so a host can keep the ring apart from the accent.
27
+ *
28
+ * Only the chat's own accent is adjusted, because it follows the widget's
29
+ * scheme rather than the page's: a dark widget's near-white accent on a light
30
+ * page all but disappears. It is held to 3:1 against the surface behind the
31
+ * target (see {@link surfaceBehind}): the accent as resolved if it holds, else
32
+ * the other half of the chat's light-dark pair, else the accent stepped toward
33
+ * black or white. Every value above it is a choice someone made, and is drawn
34
+ * as given.
35
+ *
36
+ * `chat` defaults to the most recently connected `<ag-ui-chat>`, the common
37
+ * page with one widget. A page with two passes the one the ring speaks for.
38
+ */
39
+ export function ringColor(target: HTMLElement, chat?: HTMLElement): string {
40
+ const onTarget = readToken(target, RING_TOKEN);
41
+ if (onTarget !== "") {
42
+ return onTarget;
43
+ }
44
+ const owner = chat ?? ChatRegistration.latest();
45
+ const onChat = owner === null ? "" : resolveOn(owner, RING_TOKEN);
46
+ if (onChat !== "") {
47
+ return onChat;
48
+ }
49
+ const accent = readToken(target, ACCENT_TOKEN);
50
+ if (accent !== "") {
51
+ return accent;
52
+ }
53
+ if (owner === null) {
54
+ return PACKAGE_ACCENT;
55
+ }
56
+ const chatAccent = resolveOn(owner, CHAT_ACCENT);
57
+ return chatAccent === "" ? PACKAGE_ACCENT : legible(owner, chatAccent, target);
58
+ }
59
+
60
+ /** The contrast a ring needs against what it is drawn on (WCAG 2.4.13). */
61
+ const RING_CONTRAST = 3;
62
+
63
+ /** How far each step moves a failing accent toward black or white. */
64
+ const STEP = 0.1;
65
+
66
+ type Rgba = readonly [number, number, number, number];
67
+
68
+ /**
69
+ * `accent`, or the nearest variant of it that holds {@link RING_CONTRAST}
70
+ * against the surface behind `target`.
71
+ *
72
+ * Colours are compared as sRGB bytes painted onto a one-pixel canvas, the one
73
+ * reading that turns a resolved color-mix() or oklch() into numbers. Where
74
+ * there is no 2d context to paint on, nothing can be measured, and the accent
75
+ * is drawn as it was.
76
+ */
77
+ function legible(chat: HTMLElement, accent: string, target: HTMLElement): string {
78
+ const paint = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
79
+ if (paint === null) {
80
+ return accent;
81
+ }
82
+ const bytes = (color: string): Rgba => {
83
+ paint.clearRect(0, 0, 1, 1);
84
+ paint.fillStyle = "rgb(0 0 0 / 0)";
85
+ paint.fillStyle = color;
86
+ paint.fillRect(0, 0, 1, 1);
87
+ const [r = 0, g = 0, b = 0, a = 0] = paint.getImageData(0, 0, 1, 1).data;
88
+ return [r, g, b, a / 255];
89
+ };
90
+ const surface = surfaceBehind(target, bytes);
91
+ const holds = (color: Rgba): boolean => contrast(over(color, surface), surface) >= RING_CONTRAST;
92
+ const own = [accent, resolveOn(chat, CHAT_ACCENT, "light"), resolveOn(chat, CHAT_ACCENT, "dark")];
93
+ const fit = own.find((candidate) => holds(bytes(candidate)));
94
+ if (fit !== undefined) {
95
+ return fit;
96
+ }
97
+ // Black or white, whichever the surface sets off better, holds at least
98
+ // 4.5:1 against any surface, so the walk always ends by the last step.
99
+ const from = over(bytes(accent), surface);
100
+ const black: Rgba = [0, 0, 0, 1];
101
+ const white: Rgba = [255, 255, 255, 1];
102
+ const toward = contrast(black, surface) > contrast(white, surface) ? black : white;
103
+ let step = 1;
104
+ while (!holds(mix(from, toward, step * STEP))) {
105
+ step += 1;
106
+ }
107
+ const [r, g, b] = mix(from, toward, step * STEP);
108
+ return `rgb(${r}, ${g}, ${b})`;
109
+ }
110
+
111
+ /**
112
+ * The colour of the surface a ring around `target` is drawn on, as opaque
113
+ * sRGB bytes.
114
+ *
115
+ * The ring sits outside the target's border box (the flash outline is offset
116
+ * from it, the overlay leaves a gap), so the target's own fill is not under
117
+ * it: a white field on a dark page is ringed on the dark page. The surface is
118
+ * every background from the target's parent up, composited until one is
119
+ * opaque, over the page canvas, which follows the root's colour scheme. A
120
+ * background image is not read; its colour beneath is.
121
+ */
122
+ function surfaceBehind(target: HTMLElement, bytes: (color: string) => Rgba): Rgba {
123
+ const layers: Rgba[] = [];
124
+ for (let el = parentOf(target); el !== null; el = parentOf(el)) {
125
+ const layer = bytes(window.getComputedStyle(el).backgroundColor);
126
+ layers.push(layer);
127
+ if (layer[3] === 1) {
128
+ break;
129
+ }
130
+ }
131
+ const probe = document.createElement("span");
132
+ probe.hidden = true;
133
+ probe.style.color = "Canvas";
134
+ document.documentElement.appendChild(probe);
135
+ let surface = bytes(window.getComputedStyle(probe).color);
136
+ probe.remove();
137
+ for (const layer of layers.reverse()) {
138
+ surface = over(layer, surface);
139
+ }
140
+ return surface;
141
+ }
142
+
143
+ /** The element above `el`, crossing out of a shadow root to its host. */
144
+ function parentOf(el: Element): Element | null {
145
+ if (el.parentElement !== null) {
146
+ return el.parentElement;
147
+ }
148
+ const root = el.getRootNode();
149
+ return root instanceof ShadowRoot ? root.host : null;
150
+ }
151
+
152
+ /** `top` painted over the opaque `under`. */
153
+ function over(top: Rgba, under: Rgba): Rgba {
154
+ return mix(under, top, top[3]);
155
+ }
156
+
157
+ /** `from` moved `share` of the way to `to`, as opaque bytes. */
158
+ function mix(from: Rgba, to: Rgba, share: number): Rgba {
159
+ const channel = (index: 0 | 1 | 2): number =>
160
+ Math.round(from[index] + (to[index] - from[index]) * share);
161
+ return [channel(0), channel(1), channel(2), 1];
162
+ }
163
+
164
+ /** WCAG relative luminance of opaque sRGB bytes. */
165
+ function luminance([r, g, b]: Rgba): number {
166
+ const linear = (channel: number): number => {
167
+ const c = channel / 255;
168
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
169
+ };
170
+ return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
171
+ }
172
+
173
+ /** WCAG contrast ratio between two opaque colours. */
174
+ function contrast(a: Rgba, b: Rgba): number {
175
+ const [la, lb] = [luminance(a), luminance(b)];
176
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
177
+ }
178
+
179
+ function readToken(el: HTMLElement, name: string): string {
180
+ return window.getComputedStyle(el).getPropertyValue(name).trim();
181
+ }
182
+
183
+ /**
184
+ * The custom property `name` on `chat`, as the colour it computes to there, or
185
+ * `""` when it is unset or not a colour.
186
+ *
187
+ * Resolved inside the chat rather than handed to the target, because the
188
+ * chat's colours are `light-dark()` pairs and a custom property keeps them
189
+ * unresolved: written onto an element in the page, the pair would pick its
190
+ * half by the page's colour scheme, so a dark widget on a light page would ring
191
+ * in its light-mode accent. A probe inside the shadow tree inherits the chat's
192
+ * scheme, and its computed `color` is the resolved value.
193
+ *
194
+ * An element with no open shadow root (a host passing something other than a
195
+ * chat) is read as it is, unresolved; that is the best it offers. `scheme`
196
+ * forces one half of the pair, which is how a ring finds the chat's other
197
+ * accent when its own scheme's is too faint against the page.
198
+ */
199
+ function resolveOn(chat: HTMLElement, name: string, scheme?: "light" | "dark"): string {
200
+ const value = readToken(chat, name);
201
+ const root = chat.shadowRoot;
202
+ if (value === "" || root === null) {
203
+ return value;
204
+ }
205
+ const probe = document.createElement("span");
206
+ probe.hidden = true;
207
+ // A scheme named here picks that half of a light-dark() pair instead of
208
+ // the one the chat's own scheme picks.
209
+ if (scheme !== undefined) {
210
+ probe.style.colorScheme = scheme;
211
+ }
212
+ probe.style.color = value;
213
+ // An unparseable value leaves the declaration empty, and the probe would
214
+ // then report the colour it inherits instead of saying it has none.
215
+ if (probe.style.color === "") {
216
+ return "";
217
+ }
218
+ root.appendChild(probe);
219
+ const resolved = window.getComputedStyle(probe).color;
220
+ probe.remove();
221
+ return resolved;
222
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  export {
4
4
  ATTACHMENT_EVENT,
5
+ BAR_HEIGHT_EVENT,
5
6
  CHART_ACTIVITY_TYPE,
6
7
  COMPACTION_ACTIVITY_TYPE,
7
8
  CUSTOM_AGENT_EVENT,
@@ -17,10 +18,13 @@ export {
17
18
  STATE_EVENT,
18
19
  SUBMIT_EVENT,
19
20
  SUGGESTIONS_ACTIVITY_TYPE,
21
+ THREAD_EVENT,
20
22
  TOGGLE_EVENT,
21
23
  TOOL_CALL_STATUS,
22
24
  TOOL_DISPLAY,
25
+ TOOL_GROUP_DISPLAY,
23
26
  TOOL_OUTCOME,
27
+ type ToolGroupDisplayMode,
24
28
  UNREAD_EVENT,
25
29
  X_CONFIRM_KEY,
26
30
  X_DESTRUCTIVE_KEY,
@@ -57,12 +61,14 @@ export {
57
61
  } from "./core/create_http_agent.js";
58
62
  export { defineAgUiChat } from "./core/define_ag_ui_chat.js";
59
63
  export type { AttachmentsDetail } from "./core/events/attachments_detail.js";
64
+ export type { BarHeightDetail } from "./core/events/bar_height_detail.js";
60
65
  export type { CustomAgentDetail } from "./core/events/custom_agent_detail.js";
61
66
  export type { FeedbackDetail } from "./core/events/feedback_detail.js";
62
67
  export type { InvalidateDetail } from "./core/events/invalidate_detail.js";
63
68
  export type { RunFinishedDetail } from "./core/events/run_finished_detail.js";
64
69
  export type { StateDetail } from "./core/events/state_detail.js";
65
70
  export type { SubmitDetail } from "./core/events/submit_detail.js";
71
+ export type { ThreadDetail } from "./core/events/thread_detail.js";
66
72
  export type { ToggleDetail } from "./core/events/toggle_detail.js";
67
73
  export type { ToolRun } from "./core/events/tool_run.js";
68
74
  export type { UnreadDetail } from "./core/events/unread_detail.js";
@@ -89,6 +95,7 @@ export {
89
95
  type PressOptions,
90
96
  prefersReducedMotion,
91
97
  pressThenClick,
98
+ type RingOwnerOptions,
92
99
  type ScrollOptions,
93
100
  type SelectOptions,
94
101
  scrollIntoCenterView,
@@ -1,4 +1,5 @@
1
1
  import { X_SUMMARY_KEY } from "../constants.js";
2
+ import type { CollapseTarget } from "../ui/placement/surface_for.js";
2
3
  import type { ClientTool } from "./client_tool_registry.js";
3
4
 
4
5
  /** Every corner the panel can be sent to, in the order the schema states them. */
@@ -11,7 +12,13 @@ export type ChatCorner = (typeof CHAT_CORNERS)[number];
11
12
  export interface ChatSurfaceReport {
12
13
  /** The placement in force, or `null` when the host set none. */
13
14
  readonly placement: string | null;
14
- /** Whether the panel is currently at its launcher. */
15
+ /**
16
+ * What the panel collapses to: `launcher`, `rail`, `header`, `bar` (the
17
+ * bottom bar it rises from), `none` (hidden, brought back by the host's own
18
+ * control), or `null` when it cannot collapse.
19
+ */
20
+ readonly collapseTo: CollapseTarget;
21
+ /** Whether the panel is currently collapsed. */
15
22
  readonly collapsed: boolean;
16
23
  /** Whether this placement has a collapsed state at all. */
17
24
  readonly collapsible: boolean;
@@ -154,9 +161,11 @@ export function createChatSurfaceTools(surface: ChatSurface): ClientTool[] {
154
161
  moved: false,
155
162
  reason: report.fullBleed
156
163
  ? "the panel fills the screen, so there is nowhere to move it to"
157
- : report.draggable === false
158
- ? "this page has turned off moving the panel"
159
- : `the "${report.placement ?? ""}" placement owns the panel's position`,
164
+ : report.collapseTo === "bar" || report.collapseTo === "none"
165
+ ? "the panel is docked: it rises from the page's bottom bar and stays there"
166
+ : report.draggable === false
167
+ ? "this page has turned off moving the panel"
168
+ : `the "${report.placement ?? ""}" placement owns the panel's position`,
160
169
  suggestion: report.collapsible ? "minimise_chat" : null,
161
170
  };
162
171
  }
@@ -166,9 +175,10 @@ export function createChatSurfaceTools(surface: ChatSurface): ClientTool[] {
166
175
  {
167
176
  name: "minimise_chat",
168
177
  description:
169
- "Collapse your own panel to its launcher, so the user can see the whole page. " +
170
- "The launcher stays visible and reopens it. Answers `minimised: false` when the " +
171
- "placement has no collapsed state, which is the case for a full-page chat.",
178
+ "Collapse your own panel, so the user can see the whole page. The user reopens " +
179
+ "it from where it collapsed to (a launcher, a rail, a bottom bar or the page's " +
180
+ "own control). Answers `minimised: false` when the placement has no collapsed " +
181
+ "state, which is the case for a full-page chat.",
172
182
  parameters: {
173
183
  type: "object",
174
184
  properties: {},
@@ -180,7 +190,7 @@ export function createChatSurfaceTools(surface: ChatSurface): ClientTool[] {
180
190
  if (!report.collapsible) {
181
191
  return {
182
192
  minimised: false,
183
- reason: "this placement has no collapsed state, so there is no launcher to return to",
193
+ reason: "this placement has no collapsed state, so there is nothing to minimise to",
184
194
  };
185
195
  }
186
196
  surface.setCollapsed(true, { announce: true });