@artooi/ag-ui-web-component 0.20.0 → 0.21.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 (41) hide show
  1. package/CHANGELOG.md +135 -1
  2. package/README.md +286 -20
  3. package/dist/ag-ui-web-component.bundle.js +407 -382
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/core/ag_ui_chat.d.ts +82 -1
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/create_http_agent.d.ts +9 -0
  8. package/dist/core/create_http_agent.d.ts.map +1 -1
  9. package/dist/core/remote_conversation_store.d.ts +8 -1
  10. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  11. package/dist/core/run_index.d.ts +8 -1
  12. package/dist/core/run_index.d.ts.map +1 -1
  13. package/dist/core/transcribe_audio.d.ts +7 -0
  14. package/dist/core/transcribe_audio.d.ts.map +1 -1
  15. package/dist/core/upload_attachment.d.ts +9 -0
  16. package/dist/core/upload_attachment.d.ts.map +1 -1
  17. package/dist/core/utils.d.ts +12 -0
  18. package/dist/core/utils.d.ts.map +1 -0
  19. package/dist/dom/animations.d.ts +51 -11
  20. package/dist/dom/animations.d.ts.map +1 -1
  21. package/dist/dom/dom_driver.d.ts +12 -7
  22. package/dist/dom/dom_driver.d.ts.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +766 -430
  26. package/dist/index.js.map +3 -3
  27. package/dist/ui/styles.d.ts +1 -1
  28. package/dist/ui/styles.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/core/ag_ui_chat.ts +292 -31
  31. package/src/core/create_http_agent.ts +14 -3
  32. package/src/core/remote_conversation_store.ts +25 -6
  33. package/src/core/run_index.ts +27 -5
  34. package/src/core/transcribe_audio.ts +20 -5
  35. package/src/core/upload_attachment.ts +13 -0
  36. package/src/core/utils.ts +18 -0
  37. package/src/dom/animations.ts +175 -31
  38. package/src/dom/dom_driver.ts +18 -12
  39. package/src/index.ts +2 -0
  40. package/src/ui/styles.ts +377 -352
  41. package/src/version.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,138 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.21.0] — 2026-08-11
11
+
12
+ Ten findings from a real embed — a cross-origin, cookie-authenticated React host.
13
+ Six of them are one defect: **the component assumed it owned the page.** It had
14
+ only ever been embedded in its own playground and in `django-admin-agent`, two
15
+ hosts that both arrange the page the way it expects.
16
+
17
+ ### Added
18
+
19
+ - **`getHeaders`** — a function consulted immediately before **every** request, so
20
+ a rotating credential (a short-lived JWT, a re-issued CSRF token) reaches the
21
+ request that needs it. `headers` was a plain field the element never wrote to,
22
+ read at ten sites; configuring auth through `agentFactory` therefore
23
+ authenticated the run and nothing else, and thread history, attachments and the
24
+ catalogs went out anonymous and 401'd — which reads as a backend fault. The two
25
+ compose, merged per key with `getHeaders` winning, so adding a rotating header
26
+ cannot silently drop a static one.
27
+
28
+ - **`credentials`** (attribute and property) — `omit` / `same-origin` / `include`,
29
+ applied to every request. There was previously no occurrence of `credentials`
30
+ anywhere in the source, so every request used the browser default and a host
31
+ serving its SPA and API from different subdomains sent no cookies, with **no way
32
+ to express the fix** short of replacing the transport. An unknown value is
33
+ rejected where it was written (the property throws; the attribute logs and stays
34
+ inert) rather than becoming a 401 later.
35
+
36
+ - **`flash(el, options)`** — ring an element without moving focus.
37
+ `focusWithFlash` calls `el.focus()`, which for a helper named "flash" is
38
+ surprising: it takes focus off the composer, can fire blur validation on
39
+ whatever the user was mid-edit in, and can close a menu. Both now accept an
40
+ explicit `focus` option, and `focusWithFlash` focuses with `preventScroll: true`
41
+ so it no longer fights the scroll it just started.
42
+
43
+ - **`openThreads()` / `openCheckpoints()` / `reload()`.** The header's controls all
44
+ live inside `::part(header)`, so a host rendering its own title bar and hiding it
45
+ lost thread switching entirely. The built-in buttons call exactly these methods,
46
+ so the two routes cannot drift. `reload()` re-runs the startup fetches once
47
+ credentials that arrive late have landed.
48
+
49
+ - **`ScrollOptions`**, and `scrollIntoCenterView` now returns a promise that
50
+ resolves when the scroll has settled (`scrollend` where available, a short probe
51
+ when nothing moved, a 600 ms cap otherwise). Every DOM-driver primitive awaits it.
52
+
53
+ ### Changed
54
+
55
+ - **CSS custom properties now work from an ancestor**, which is what the README
56
+ always described and the one thing that could not work: every `--ag-ui-*` default
57
+ was declared on `:host`, which sets the property *on the element*, and an
58
+ element's own value beats anything inherited. A consumer ran the full token map
59
+ on a wrapper for an entire build and concluded they had the names wrong. The
60
+ defaults now sit behind private aliases, so `:root`, a wrapper, the element and
61
+ an inline style all work and resolve in the usual order. **One vocabulary — the
62
+ public `--ag-ui-*` names are unchanged**, and a built-in `theme` / `density` /
63
+ `placement` preset still loses to an explicit page rule.
64
+
65
+ - **The flash is an `outline`, not a `box-shadow`**, and holds for **1200 ms** with
66
+ a fade rather than 200 ms. A shadow paints outside the border box, so any
67
+ `overflow: hidden` ancestor sharing the element's box — a card, a table cell —
68
+ clipped it entirely while the tool reported success. And 200 ms is below the
69
+ threshold at which someone who does not know where to look notices anything.
70
+ ⭐ Neither our tests nor the consumer's could have found the second one:
71
+ headless Chromium hides the scroll race, and no automated check has an opinion
72
+ about whether a human sees a 200 ms ring.
73
+
74
+ - **The flash colour comes from the target's `--ag-ui-accent`** rather than a
75
+ hardcoded indigo, so a themed page is flashed in its own colour.
76
+
77
+ - **`prefersReducedMotion()` is now honoured where the README already claimed it
78
+ was.** The flash ignored it entirely while the docs said the preference was
79
+ "honoured throughout"; a consumer investigated reduced motion as the cause of an
80
+ invisible highlight on the strength of that sentence. Under reduced motion the
81
+ ring drops its fade but keeps its full hold — reduced motion asks for no
82
+ animation, not for no feedback. `typeInto` and `highlightThenClick` keep their
83
+ explicit-duration contract, and the docs now say so instead of overclaiming.
84
+
85
+ - **The tool and skill catalog fetches are deferred by one microtask**, so a React
86
+ `ref` assigned in the same commit as insertion is honoured. ⚠ The thread-history
87
+ request is deliberately **not** deferred: a deferred replay can land after a
88
+ `sendMessage()` and duplicate the transcript. Configure before you insert, or
89
+ call `reload()`; the new React recipe in the README shows both.
90
+
91
+ ### Fixed
92
+
93
+ - **The resize grip now sits on the corner that actually moves.** The element
94
+ measures which edges its host's layout holds still and stamps them as
95
+ `data-resize-anchor="<y>-<x>"`, but the rules meant to read it were written as
96
+ `[data-resize-anchor~="left"]` — and `~=` matches whitespace-separated words
97
+ while the stamped value is a single hyphenated token, so they could never
98
+ match. ⚠ **The cursor rules used `=` and did match**, so the pointer followed
99
+ the measurement while the grip stayed where `placement` had guessed: for any
100
+ host that aligns the panel the other way, the cursor promised a diagonal the
101
+ grip was not on, and the grip sat on the corner that stays put.
102
+
103
+ Each anchor rule now sets **both** sides of its axis. One that flipped a
104
+ single way could not undo a placement guess that had flipped the other, which
105
+ put the grip back on the anchored corner for an embedded panel its host
106
+ right-aligns.
107
+
108
+ - **`placement="page"` gutters its composer to the same reading column as its
109
+ messages.** The rule was unscoped, which tied it on specificity with the base
110
+ `.input-row` rule that sets the `padding` shorthand later in the stylesheet;
111
+ source order decided and the shorthand won. ⇒ *No placement ever got the
112
+ gutter* — least of all the one it was written for, where the messages sat in a
113
+ centred column and the composer spanned the full width.
114
+
115
+ A duplicated copy of the whole resize block also sat earlier in the file,
116
+ welded to the preceding rule by a comment left between a selector and its
117
+ subject; it parsed as `:host([placement="page"]) .resize-handle`, scoping the
118
+ grip's base positioning to the one placement that hides it.
119
+
120
+ ## [0.20.1] — 2026-08-11
121
+
122
+ ### Fixed
123
+
124
+ - ⛔ **`placement="side"` (and `sidebar`) stopped being full height once the
125
+ panel had been resized.** A dragged size is written as a custom property on
126
+ the host, and an inline custom property **outranks a `:host([placement=…])`
127
+ rule setting the same property** — so a height dragged while floating capped a
128
+ docked sidebar that had asked for `100vh`. Since the size persists per tab,
129
+ one drag broke every later visit.
130
+
131
+ ⚠ **The previous release claimed this was already handled, and the reasoning
132
+ was wrong.** Writing `--ag-ui-height` rather than inline `height` was supposed
133
+ to leave placement with the final say; it does not, because the indirection
134
+ changes nothing about the cascade. The fix is explicit rather than
135
+ cascade-dependent: **a placement owns the axes it fixes**, a persisted size is
136
+ applied only to the axes it leaves free, and switching placement hands the
137
+ owned axes back.
138
+
139
+ ⇒ *"I used the more specific-looking mechanism" is not a substitute for
140
+ checking which declaration actually wins.*
141
+
10
142
  ## [0.20.0] — 2026-08-11
11
143
 
12
144
  ### Added
@@ -1039,7 +1171,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1039
1171
  ### Notes
1040
1172
  - First release — exercising the automated npm OIDC publish pipeline end-to-end.
1041
1173
 
1042
- [Unreleased]: https://github.com/Artui/ag-ui-web-component/compare/v0.20.0...HEAD
1174
+ [Unreleased]: https://github.com/Artui/ag-ui-web-component/compare/v0.21.0...HEAD
1175
+ [0.21.0]: https://github.com/Artui/ag-ui-web-component/compare/v0.20.1...v0.21.0
1176
+ [0.20.1]: https://github.com/Artui/ag-ui-web-component/compare/v0.20.0...v0.20.1
1043
1177
  [0.20.0]: https://github.com/Artui/ag-ui-web-component/compare/v0.19.0...v0.20.0
1044
1178
  [0.19.0]: https://github.com/Artui/ag-ui-web-component/compare/v0.18.0...v0.19.0
1045
1179
  [0.18.0]: https://github.com/Artui/ag-ui-web-component/compare/v0.17.0...v0.18.0
package/README.md CHANGED
@@ -38,6 +38,7 @@ No framework, no Django, no admin specifics live here. Downstream consumers (e.g
38
38
 
39
39
  - [Install](#install)
40
40
  - [Quickstart](#quickstart)
41
+ - [Authenticating requests](#authenticating-requests)
41
42
  - [Core concepts](#core-concepts)
42
43
  - [The run loop and the AG-UI client](#the-run-loop-and-the-ag-ui-client)
43
44
  - [Stopping a run](#stopping-a-run)
@@ -103,7 +104,9 @@ Drop the element into your page and register the tools the agent may call:
103
104
 
104
105
  const chat = document.querySelector("ag-ui-chat");
105
106
 
106
- // Extra request headers (e.g. CSRF) sent to the AG-UI endpoint.
107
+ // Extra request headers (e.g. CSRF), sent with every request the element makes.
108
+ // For a credential that rotates, set chat.getHeaders instead - it is consulted
109
+ // per request. See "Authenticating requests".
107
110
  chat.headers = { "X-CSRFToken": getCsrfToken() };
108
111
 
109
112
  // A non-destructive tool: fills a text field with a typing animation.
@@ -138,7 +141,9 @@ Drop the element into your page and register the tools the agent may call:
138
141
  ```
139
142
 
140
143
  That's the whole integration: an `endpoint` attribute pointing at your AG-UI server, optional
141
- `headers`, and the tools you want the agent to be able to invoke in the browser.
144
+ `headers`, and the tools you want the agent to be able to invoke in the browser. If your API is on
145
+ another origin, add `credentials="include"` too; see
146
+ [Authenticating requests](#authenticating-requests).
142
147
 
143
148
  ### Attributes and properties
144
149
 
@@ -147,14 +152,15 @@ That's the whole integration: an `endpoint` attribute pointing at your AG-UI ser
147
152
  | Attribute | Property | Notes |
148
153
  | --- | --- | --- |
149
154
  | `endpoint` | `endpoint` | The AG-UI endpoint URL. Required to send. Reflecting getter + setter. |
155
+ | `credentials` | `credentials` | Cookie policy for every request the element makes: `omit` / `same-origin` / `include`. Unset means the browser default (`same-origin`), which sends no cookies cross-origin. See [Authenticating requests](#authenticating-requests). |
150
156
  | `title-text` | — | Header label; defaults to `"Assistant"`. The only **observed** attribute (live-updates the header). |
151
157
  | `data-tool-display` | `toolDisplay` | Tool-call card detail: `inline` / `minimal` / `compact` / `full` (default `full`). |
152
158
  | `data-text-animation` | — | Incoming-text reveal: `none` (default) / `fade` / `word`. |
153
159
  | `data-prompt-chips` | — | Present (bare, or any value but `"false"`) to surface skills as chips. |
154
160
  | `data-slash-commands` | — | Present (bare, or any value but `"false"`) to enable the `/`-command palette. |
155
161
  | `data-skills` | — | Inline JSON skill catalog. |
156
- | `data-skills-url` | — | URL of a JSON skill catalog (fetched with `headers`). |
157
- | `data-tools-url` | — | URL of a server tool-label catalog (`[{ name, summary, description? }]`), fetched with `headers`; labels tool-call cards for server-side tools. |
162
+ | `data-skills-url` | — | URL of a JSON skill catalog (fetched with the element's headers and cookie policy). |
163
+ | `data-tools-url` | — | URL of a server tool-label catalog (`[{ name, summary, description? }]`), fetched with the element's headers and cookie policy; labels tool-call cards for server-side tools. |
158
164
  | `data-threads-url` | — | URL of a server thread index (django-ag-ui's `ThreadsView`); enables durable, cross-device chat history. |
159
165
  | `data-runs-url` | — | URL of a server run index (django-ag-ui's `RunsView`); reveals the header's ⭯ *Continue a run* panel. See [Resuming a run](#resuming-a-run). |
160
166
  | `data-attachments-url` | — | URL of the file-upload endpoint (django-ag-ui's `AttachmentsView`); reveals the composer's 📎 picker + drag-and-drop. |
@@ -182,11 +188,15 @@ brand `<img>` or `<svg>` rather than only restyling the character:
182
188
  | `density` | — | CSS-only: `comfortable` (default) / `compact`. |
183
189
  | `placement` | — | CSS-only: `floating` (default) / `bottom-left` / `side` / `sidebar` / `full` / `page` / `embedded`. |
184
190
 
185
- **Properties** (JS only, not attributes): `headers`, `allowImages`, `autoConfirm`,
191
+ **Properties** (JS only, not attributes): `headers`, `getHeaders`, `allowImages`, `autoConfirm`,
186
192
  `confirmPredicate`, `askUser`, `agentFactory`, `getTools`, `getContext`, `routeMap`, `navigate`,
187
193
  `getPageMap`, `autoInjectPageMap`, `conversationStore`, `uploadHandler`, `transcribeHandler`,
188
194
  `navigationResult`, `skillContext`, `toolSummaries`, `strings`, `resolvePageTarget`, plus the
189
- mirrors `endpoint` / `toolDisplay` / `collapsed`.
195
+ mirrors `endpoint` / `toolDisplay` / `collapsed` / `credentials`.
196
+
197
+ `headers` and `getHeaders` authenticate **every** request the element makes, not only the agent
198
+ run; `getHeaders` is the one to use for a credential that rotates. See
199
+ [Authenticating requests](#authenticating-requests).
190
200
 
191
201
  `allowImages` (default `false`) re-enables `<img>` in rendered assistant markdown.
192
202
  It is off by default because a model-controlled image URL is fetched by the browser
@@ -209,7 +219,8 @@ keyboard focus and styleable via the `code-copy` part. Override its labels with
209
219
  the `copyCode` / `copied` / `copyFailed` strings.
210
220
 
211
221
  **Methods**: `registerTool`, `registerPageState`, `setSkills`, `sendMessage`, `attachFile`,
212
- `appendMessage`, `newChat`, `setCollapsed`, `toggleCollapsed`.
222
+ `appendMessage`, `newChat`, `setCollapsed`, `toggleCollapsed`, `toggleTheme`, `openThreads`,
223
+ `openCheckpoints`, `reload`.
213
224
 
214
225
  ### Sending from your own UI
215
226
 
@@ -243,6 +254,137 @@ mock AG-UI server.
243
254
 
244
255
  ---
245
256
 
257
+ ## Authenticating requests
258
+
259
+ The element talks to more than one endpoint. Beyond the AG-UI run itself, it may fetch the thread
260
+ index and a thread's messages, the tool-label and skill catalogs, the run index, and it may POST an
261
+ upload or a voice clip. **Every one of them is authenticated the same way**, by the element rather
262
+ than by the agent — so configuring authentication on a custom `agentFactory` authenticates the run
263
+ and nothing else, and the history drawer comes back empty because its request was anonymous.
264
+
265
+ | Request | Endpoint | Transport |
266
+ | --- | --- | --- |
267
+ | The agent run | `endpoint` | `fetch` (SSE), via `agentFactory` |
268
+ | Thread index / a thread's messages / rename / delete | `data-threads-url` | `fetch` |
269
+ | Tool-label catalog | `data-tools-url` | `fetch` |
270
+ | Skill catalog | `data-skills-url` | `fetch` |
271
+ | Run index | `data-runs-url` | `fetch` |
272
+ | Voice transcription | `data-transcribe-url` | `fetch` |
273
+ | File upload | `data-attachments-url` | `XMLHttpRequest` (for progress events) |
274
+
275
+ ### `headers` and `getHeaders`
276
+
277
+ `headers` is a plain record sent with every request above:
278
+
279
+ ```js
280
+ chat.headers = { "X-CSRFToken": getCsrfToken() };
281
+ ```
282
+
283
+ It is read at request time, but only an assignment changes it — so a token captured there is pinned
284
+ until you remember to assign again. For anything that rotates (a short-lived JWT, a re-issued CSRF
285
+ token) set **`getHeaders`** instead, a function consulted immediately before every request:
286
+
287
+ ```js
288
+ chat.getHeaders = () => ({ Authorization: `Bearer ${auth.accessToken()}` });
289
+ ```
290
+
291
+ Because it is called per request, a token refreshed between two requests reaches the second one —
292
+ including mid-conversation, on the cached agent's own stream.
293
+
294
+ The two **compose**: they are merged per key, `getHeaders` winning, so a fixed header and a rotating
295
+ one can be configured independently and neither silently drops the other.
296
+
297
+ ```js
298
+ chat.headers = { "X-Client": "admin" };
299
+ chat.getHeaders = () => ({ Authorization: `Bearer ${auth.accessToken()}` });
300
+ // every request: X-Client: admin + a freshly-read Authorization
301
+ ```
302
+
303
+ ### Cross-origin cookies (`credentials`)
304
+
305
+ If your API is on a different origin from the page — `app.example.com` calling `api.example.com`
306
+ counts, subdomains are cross-origin — the browser's default of `same-origin` sends **no cookies at
307
+ all**. The requests still go out; they arrive unauthenticated, and the server answers `401` while
308
+ looking perfectly configured. Set the cookie policy explicitly:
309
+
310
+ ```html
311
+ <ag-ui-chat endpoint="https://api.example.com/agent/" credentials="include"></ag-ui-chat>
312
+ ```
313
+
314
+ ```js
315
+ chat.credentials = "include"; // mirrors the attribute
316
+ ```
317
+
318
+ It takes `fetch`'s own three modes — `omit`, `same-origin`, `include` — applies to every request in
319
+ the table above, and is read per request, so a late assignment applies to everything after it.
320
+ Anything else is rejected where you wrote it: an unknown value assigned as a property throws, and an
321
+ unknown value in the attribute is reported to the console and ignored, rather than becoming a `401`
322
+ later on.
323
+
324
+ The server has to agree: `Access-Control-Allow-Credentials: true` and a concrete
325
+ `Access-Control-Allow-Origin` (the wildcard is invalid with credentials).
326
+
327
+ One asymmetry: uploads use `XMLHttpRequest` for real progress events, and its cookie switch is
328
+ two-state. `include` turns it on; every other value leaves it off. `omit` therefore cannot suppress
329
+ cookies on a *same-origin* upload — supply your own `uploadHandler` if that matters.
330
+
331
+ ### Framework hosts: configure before you insert
332
+
333
+ `headers`, `getHeaders` and `credentials` are read when a request is made, so they can be set at any
334
+ time. Several other things are read once, while the element **connects**: `strings`, `uploadHandler`
335
+ and `transcribeHandler` (they decide whether the attach and voice affordances exist at all) and
336
+ every chrome-building `data-*` attribute — and the catalogs and thread history are requested at, or
337
+ just after, that same moment.
338
+
339
+ React attaches `ref`s *after* it inserts the node, which puts the canonical integration on the wrong
340
+ side of that boundary. Create the element, configure it, then append:
341
+
342
+ ```jsx
343
+ function Assistant() {
344
+ const host = useRef(null);
345
+
346
+ useEffect(() => {
347
+ defineAgUiChat();
348
+ const chat = document.createElement("ag-ui-chat");
349
+
350
+ // Configure first - every one of these is read as the element connects.
351
+ chat.setAttribute("endpoint", "/agent/");
352
+ chat.setAttribute("data-threads-url", "/agent/threads/");
353
+ chat.credentials = "include";
354
+ chat.getHeaders = () => ({ Authorization: `Bearer ${auth.accessToken()}` });
355
+ chat.registerTool(myTool);
356
+
357
+ // ...then insert it.
358
+ host.current.appendChild(chat);
359
+ return () => chat.remove();
360
+ }, []);
361
+
362
+ return <div ref={host} />;
363
+ }
364
+ ```
365
+
366
+ Writing it as `<ag-ui-chat ref={...} />` in JSX and configuring in the ref callback mostly works —
367
+ the catalog requests are held back one microtask precisely so a ref assigned in the same commit is
368
+ honoured — but the thread-history request is **not** deferred (a deferred replay could land after a
369
+ `sendMessage()` and duplicate the transcript), so that one goes out with whatever was configured at
370
+ insertion.
371
+
372
+ If your credentials can only arrive later still — an awaited token, a passive effect — call
373
+ **`reload()`** once they land:
374
+
375
+ ```js
376
+ const token = await auth.login();
377
+ chat.getHeaders = () => ({ Authorization: `Bearer ${token}` });
378
+ await chat.reload();
379
+ ```
380
+
381
+ `reload()` re-runs everything the element loads on startup (tool catalog, skills, thread history)
382
+ with the configuration as it then stands. It is a reload, not a merge: the in-flight run is
383
+ cancelled and the transcript is rebuilt from the persisted history, so call it when configuration
384
+ lands rather than between turns.
385
+
386
+ ---
387
+
246
388
  ## Core concepts
247
389
 
248
390
  ### The run loop and the AG-UI client
@@ -432,19 +574,58 @@ human-readable speed (configurable; pass small/zero durations in tests):
432
574
  - `highlightThenClick(el, { highlightMs })` / `pressThenClick(el, options)` — outline/press an
433
575
  element, pause, then click.
434
576
  - `selectOption(el, value)` / `toggleControl(el, checked)` — animate a `<select>` / checkbox.
435
- - `scrollIntoCenterView(el)` / `focusWithFlash(el, { flashMs })`.
436
- - `prefersReducedMotion()` honoured throughout so animations collapse to instant when the user
437
- asks for reduced motion.
577
+ - `scrollIntoCenterView(el, { settleMs })` scrolls the element to the vertical centre and
578
+ resolves once the scroll has **settled**, so the ring that follows is drawn where the user is
579
+ looking rather than mid-glide. Awaiting is optional; the scroll is requested synchronously
580
+ either way. Settlement is `scrollend` where the browser has it, a short probe when nothing
581
+ actually moved (the element was already in view), and a 600 ms cap otherwise.
582
+ - `flash(el, { flashMs, color })` / `focusWithFlash(el, { flashMs, color, focus })` — ring the
583
+ element so the user can find it. The ring is an `outline`, not a `box-shadow`, because a shadow
584
+ paints outside the border box and any `overflow: hidden` ancestor sharing the element's box (a
585
+ card, a table cell) clips it away entirely. It holds for **1200 ms** by default and fades out
586
+ over the last third — a 200 ms blink is not long enough to be *found* by someone who does not
587
+ yet know where to look. The colour comes from the target's own `--ag-ui-accent` (so a themed
588
+ page is flashed in its own colour), or from `color`.
589
+ `flash` leaves focus alone; `focusWithFlash` moves it — see "Flash versus focus" below.
590
+ - `prefersReducedMotion()` — honoured by every primitive that *moves* something: the hold delays
591
+ in `pressThenClick` / `selectOption` / `toggleControl` collapse to instant, `scrollIntoCenterView`
592
+ jumps instead of gliding and settles immediately, and the flash drops its fade while still
593
+ holding the ring for its full duration. Reduced motion asks for no animation, not for no
594
+ feedback. `typeInto` and `highlightThenClick` are the exceptions: they keep their
595
+ explicit-duration contract, so pass `charDelayMs: 0` / `highlightMs: 0` yourself if you want
596
+ them instant.
438
597
 
439
598
  The **DOM-driver** primitives ([`dom_driver.ts`](src/dom/dom_driver.ts)) compose those into the
440
599
  operations a tool handler typically wants:
441
600
 
442
- - `fillField(el, value, options)` — scroll to, focus-flash, and type into a text field.
601
+ - `fillField(el, value, options)` — scroll to, focus-flash, and type into a text field. The
602
+ flash defaults to `flashMs: 0` here: the field is about to be typed into, which is its own
603
+ highlight. Pass `flashMs` (and optionally `color`) to ring it first.
443
604
  - `clickElement(el, options)` / `pressButton(el, options)` — scroll to, highlight/press, and click.
444
605
  - `selectControl(el, value)` / `toggleCheckbox(el, checked)` — animate a `<select>` / checkbox.
445
606
  - `setControlValue(el, value)` — set a `<select>` or checkbox without animation, dispatching
446
607
  `input`/`change`.
447
608
 
609
+ Every driver primitive **awaits the scroll** before it animates. A smooth scroll is not awaitable
610
+ on its own, so a highlight fired straight after `scrollIntoView` could be applied and removed
611
+ while the element was still travelling — visible to nobody. Budget up to ~600 ms of settle time
612
+ per action in a browser without `scrollend`; an element already in view costs ~100 ms.
613
+
614
+ **Flash versus focus.** `focusWithFlash` does what its name says: it moves keyboard focus. That is
615
+ rarely what you want just to *point at* something — it takes focus off the composer, can fire blur
616
+ validation on whatever the user was mid-edit in, and can close an open menu. Reach for `flash(el)`
617
+ to highlight, and keep `focusWithFlash(el)` for the case where the agent is about to type. Either
618
+ way you can be explicit with `focus`:
619
+
620
+ ```js
621
+ await flash(el); // highlight, focus untouched
622
+ await focusWithFlash(el); // highlight and take focus
623
+ await focusWithFlash(el, { focus: false }); // same as flash(el)
624
+ ```
625
+
626
+ `focusWithFlash` focuses with `preventScroll: true`, so it cannot fight a smooth scroll that is
627
+ still in flight.
628
+
448
629
  The native-setter helpers ([`native_setter.ts`](src/dom/native_setter.ts)) — `setNativeValue` /
449
630
  `setNativeChecked` — set a control through its native prototype setter so React-controlled inputs
450
631
  register the change.
@@ -574,10 +755,15 @@ puts it. The element probes its own geometry and reflects the result as
574
755
  `data-resize-anchor` (e.g. `bottom-right` means those two edges are fixed), which
575
756
  is what positions the grip.
576
757
 
577
- A drag writes `--ag-ui-width` / `--ag-ui-height` on the host **as custom
578
- properties, not inline `width`/`height`** — the placement rules set those same
579
- properties, so an inline dimension would outrank them and a panel dragged while
580
- floating would keep that width after switching to fullscreen.
758
+ A drag writes `--ag-ui-width` / `--ag-ui-height` on the host as custom
759
+ properties.
760
+
761
+ **That alone does not leave placement in charge** — an inline custom property
762
+ still outranks a `:host([placement=…])` rule setting the same property. So the
763
+ component enforces the split directly: **a placement owns the axes it fixes**,
764
+ and a dragged or persisted size is only ever applied to the ones it leaves free.
765
+ Switching placement hands the owned axes back. Without that, a height dragged
766
+ while floating capped a docked sidebar that had asked for `100vh`.
581
767
 
582
768
  ⚠ **A host rule that sizes the element wins over both.** `ag-ui-chat { flex: 1 }`
583
769
  stretches the panel to its container and the dragged width has no visible
@@ -914,6 +1100,11 @@ Client-side `accept` / size checks are an instant-feedback nicety — **the serv
914
1100
  authoritative**. Refs persist on the message, so a restored conversation re-renders its chips.
915
1101
  Without the attribute the affordance stays hidden and the chat is text-only.
916
1102
 
1103
+ The built-in handler sends the element's `headers` / `getHeaders` with every upload, and honours
1104
+ `credentials="include"` — with the caveat that it is an `XMLHttpRequest` (for real progress
1105
+ events), whose cookie switch is two-state: `include` turns it on, every other value leaves it off.
1106
+ See [Authenticating requests](#authenticating-requests).
1107
+
917
1108
  **Swapping the upload transport.** The built-in multipart `POST` is just the default
918
1109
  `uploadHandler`. Set your own to use a different transport — a resumable
919
1110
  [`tus-js-client`](https://github.com/tus/tus-js-client) adapter, direct-to-S3 multipart, etc.
@@ -1033,10 +1224,10 @@ re-export point. Internal modules import from leaf paths.
1033
1224
  | `DEFAULT_UI_STRINGS` | const | The English defaults (the override floor). |
1034
1225
  | `mergeUiStrings(overrides)` | function | Merge a partial override over the defaults. |
1035
1226
  | `renderMarkdown(text)` | function | Render sanitized markdown/HTML (marked + DOMPurify). |
1036
- | `typeInto` / `highlightThenClick` / `pressThenClick` / `selectOption` / `toggleControl` / `scrollIntoCenterView` / `focusWithFlash` / `prefersReducedMotion` | function | Animation primitives. |
1227
+ | `typeInto` / `highlightThenClick` / `pressThenClick` / `selectOption` / `toggleControl` / `scrollIntoCenterView` / `flash` / `focusWithFlash` / `prefersReducedMotion` | function | Animation primitives. |
1037
1228
  | `fillField` / `clickElement` / `pressButton` / `selectControl` / `setControlValue` / `toggleCheckbox` | function | DOM-driver primitives. |
1038
1229
  | `setNativeValue` / `setNativeChecked` | function | Set a control via its native prototype setter (React-controlled inputs). |
1039
- | `TypeOptions` / `HighlightClickOptions` / `PressOptions` / `SelectOptions` / `ToggleOptions` / `FlashOptions` / `FillFieldOptions` / `TextLikeElement` | type | Primitive option shapes. |
1230
+ | `TypeOptions` / `HighlightClickOptions` / `PressOptions` / `SelectOptions` / `ToggleOptions` / `FlashOptions` / `ScrollOptions` / `FillFieldOptions` / `TextLikeElement` | type | Primitive option shapes. |
1040
1231
 
1041
1232
  ### Constants
1042
1233
 
@@ -1058,8 +1249,24 @@ re-export point. Internal modules import from leaf paths.
1058
1249
  ## Theming, density, and placement
1059
1250
 
1060
1251
  The chat shell is styled inside its Shadow DOM and exposes a large set of `--ag-ui-*` CSS custom
1061
- properties on `:host` (colors, status, surface, spacing, layout), so you theme it from outside
1062
- without piercing the shadow boundary. A few of the knobs:
1252
+ properties (colors, status, surface, spacing, layout), so you theme it from outside without
1253
+ piercing the shadow boundary. Set them anywhere above the element and they inherit in — on the
1254
+ element itself, on a wrapper, or on `:root` for a whole page. The closest declaration wins, the
1255
+ way any inherited CSS property behaves:
1256
+
1257
+ ```css
1258
+ /* All three work. The most specific one that applies wins. */
1259
+ :root { --ag-ui-accent: #4f46e5; } /* whole page */
1260
+ .chat-dock { --ag-ui-accent: #0f766e; } /* one region */
1261
+ ag-ui-chat { --ag-ui-accent: #b91c1c; } /* one widget */
1262
+ ```
1263
+
1264
+ > Until 0.20.x the defaults were declared on `:host`, which set them *on the element* — and an
1265
+ > element's own value beats anything inherited from an ancestor, so only the `ag-ui-chat { … }`
1266
+ > form did anything and the wrapper form silently did nothing. The defaults now sit behind an
1267
+ > internal alias, so all three forms work.
1268
+
1269
+ A few of the knobs:
1063
1270
 
1064
1271
  ```css
1065
1272
  ag-ui-chat {
@@ -1076,6 +1283,45 @@ ag-ui-chat {
1076
1283
  }
1077
1284
  ```
1078
1285
 
1286
+ ### Where to put the variables
1287
+
1288
+ There is one vocabulary — the `--ag-ui-*` names above — and it works from any ancestor. What
1289
+ differs is only *which* declaration wins, and that is ordinary CSS inheritance:
1290
+
1291
+ ```css
1292
+ /* A whole page or design-system scope. */
1293
+ :root { --ag-ui-accent: var(--brand-600); --ag-ui-radius: 4px; }
1294
+
1295
+ /* One region — the widget picks this up through the wrapper. */
1296
+ aside.support-dock { --ag-ui-accent: #0f766e; }
1297
+
1298
+ /* One widget. Beats both of the above, because it targets the element. */
1299
+ ag-ui-chat#support { --ag-ui-accent: #b91c1c; }
1300
+
1301
+ /* Set at runtime with el.style.setProperty(...) — an inline style beats all of the above. */
1302
+ ```
1303
+
1304
+ Two that do not work:
1305
+
1306
+ ```css
1307
+ /* ::part() reaches structural elements, not variables — a custom property set
1308
+ here applies to that part's own subtree, not to the whole shell. */
1309
+ ag-ui-chat::part(panel) { --ag-ui-accent: #b91c1c; }
1310
+
1311
+ /* The internal --_* aliases are private and unversioned; they are renamed
1312
+ without notice. Always set the public --ag-ui-* name. */
1313
+ ag-ui-chat { --_accent: #b91c1c; }
1314
+ ```
1315
+
1316
+ The preset attributes below sit *underneath* anything you declare: a `theme="dark"` widget still
1317
+ honours an explicit `--ag-ui-bg` from your CSS, so you can adopt a preset and correct one token
1318
+ rather than re-declaring the whole palette.
1319
+
1320
+ `--ag-ui-accent` reaches further than the widget: it also colours the rings the DOM driver draws
1321
+ on **your** page. Each primitive reads it from the computed style of the element it is about to
1322
+ touch, so setting it on `:root` (or on any ancestor of the elements the agent drives) themes the
1323
+ highlights too. Without it they fall back to the package indigo.
1324
+
1079
1325
  For the common cases there are three CSS-reactive **preset attributes** (no JS API), so you don't
1080
1326
  have to hand-tune the variables:
1081
1327
 
@@ -1132,6 +1378,26 @@ the skills UI (`skill-chips`, `skill-chip`, `skill-palette`, `skill-item`, `skil
1132
1378
  `drawer-row-delete`, `drawer-rename-input`, `drawer-confirm`, `drawer-confirm-label`,
1133
1379
  `drawer-confirm-yes`, `drawer-confirm-no`).
1134
1380
 
1381
+ > **Hiding `::part(header)` hides the controls inside it.** The history, checkpoints, new-chat,
1382
+ > theme and collapse buttons are all children of the header, so a host that renders its own title
1383
+ > bar and does `ag-ui-chat::part(header) { display: none }` loses thread switching entirely. Every
1384
+ > one of them has an imperative equivalent, so your own chrome can drive them:
1385
+ >
1386
+ > | Control | Method |
1387
+ > | --- | --- |
1388
+ > | History drawer | `chat.openThreads()` |
1389
+ > | Checkpoints panel | `chat.openCheckpoints()` |
1390
+ > | New chat | `chat.newChat()` |
1391
+ > | Collapse | `chat.toggleCollapsed()` / `chat.setCollapsed(bool)` |
1392
+ > | Theme toggle | `chat.toggleTheme()` |
1393
+ >
1394
+ > ```js
1395
+ > myHeaderButton.onclick = () => chat.openThreads();
1396
+ > ```
1397
+ >
1398
+ > The built-in buttons call exactly these methods, so the two routes cannot drift. If you only want
1399
+ > to restyle the header, prefer `::part(header)` styling or the `header-actions` slot over hiding it.
1400
+
1135
1401
  Coarse **slots** let you replace whole regions with your own markup (project light-DOM children
1136
1402
  with a matching `slot=`):
1137
1403
 
@@ -1155,7 +1421,7 @@ with a matching `slot=`):
1155
1421
  Give the header a brand icon with either the `icon` slot (any markup) or the `data-icon-url`
1156
1422
  convenience attribute (an `<img>`); the slot wins when both are set, and with neither the header
1157
1423
  stays icon-less. The same icon seam feeds the collapsed sidebar rail. Size it via
1158
- `--ag-ui-icon-size` (default `22px`).
1424
+ `--ag-ui-icon-size` (default `22px`) and round it with `--ag-ui-icon-radius` (default `4px`).
1159
1425
 
1160
1426
  ```html
1161
1427
  <ag-ui-chat endpoint="/agent/" data-icon-url="/logo.png"></ag-ui-chat>