@artooi/ag-ui-web-component 0.27.0 → 0.29.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 (85) hide show
  1. package/CHANGELOG.md +663 -1
  2. package/README.md +557 -11
  3. package/dist/ag-ui-web-component.bundle.js +294 -36
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +69 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +262 -1
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +46 -1
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/core/conversation_store.d.ts +43 -1
  12. package/dist/core/conversation_store.d.ts.map +1 -1
  13. package/dist/core/create_http_agent.d.ts +13 -0
  14. package/dist/core/create_http_agent.d.ts.map +1 -1
  15. package/dist/core/remote_conversation_store.d.ts +23 -1
  16. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  17. package/dist/core/utils.d.ts +28 -0
  18. package/dist/core/utils.d.ts.map +1 -1
  19. package/dist/index.d.ts +7 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2104 -194
  22. package/dist/index.js.map +4 -4
  23. package/dist/tools/is_destructive.d.ts +8 -2
  24. package/dist/tools/is_destructive.d.ts.map +1 -1
  25. package/dist/tools/parse_tool_catalog.d.ts +11 -4
  26. package/dist/tools/parse_tool_catalog.d.ts.map +1 -1
  27. package/dist/ui/approval_card.d.ts +18 -0
  28. package/dist/ui/approval_card.d.ts.map +1 -1
  29. package/dist/ui/checkpoint_menu.d.ts +10 -0
  30. package/dist/ui/checkpoint_menu.d.ts.map +1 -1
  31. package/dist/ui/confirmation_card.d.ts +16 -0
  32. package/dist/ui/confirmation_card.d.ts.map +1 -1
  33. package/dist/ui/message_actions.d.ts +46 -0
  34. package/dist/ui/message_actions.d.ts.map +1 -0
  35. package/dist/ui/page_quote_offer.d.ts +33 -0
  36. package/dist/ui/page_quote_offer.d.ts.map +1 -0
  37. package/dist/ui/quote_selection.d.ts +66 -0
  38. package/dist/ui/quote_selection.d.ts.map +1 -0
  39. package/dist/ui/relative_time.d.ts +10 -0
  40. package/dist/ui/relative_time.d.ts.map +1 -1
  41. package/dist/ui/render_markdown.d.ts +23 -5
  42. package/dist/ui/render_markdown.d.ts.map +1 -1
  43. package/dist/ui/resize_handle.d.ts +5 -1
  44. package/dist/ui/resize_handle.d.ts.map +1 -1
  45. package/dist/ui/stick_to_bottom.d.ts +55 -0
  46. package/dist/ui/stick_to_bottom.d.ts.map +1 -0
  47. package/dist/ui/styles.d.ts +1 -1
  48. package/dist/ui/styles.d.ts.map +1 -1
  49. package/dist/ui/suggestion_chips.d.ts +29 -0
  50. package/dist/ui/suggestion_chips.d.ts.map +1 -0
  51. package/dist/ui/thread_drawer.d.ts +10 -0
  52. package/dist/ui/thread_drawer.d.ts.map +1 -1
  53. package/dist/ui/tool_call_card.d.ts +8 -0
  54. package/dist/ui/tool_call_card.d.ts.map +1 -1
  55. package/dist/ui/ui_strings.d.ts +53 -7
  56. package/dist/ui/ui_strings.d.ts.map +1 -1
  57. package/dist/ui/voice_input.d.ts.map +1 -1
  58. package/package.json +1 -1
  59. package/src/constants.ts +75 -0
  60. package/src/core/ag_ui_chat.ts +1357 -113
  61. package/src/core/agui_client.ts +81 -1
  62. package/src/core/conversation_store.ts +128 -42
  63. package/src/core/create_http_agent.ts +24 -2
  64. package/src/core/remote_conversation_store.ts +35 -2
  65. package/src/core/utils.ts +58 -0
  66. package/src/index.ts +39 -0
  67. package/src/tools/is_destructive.ts +8 -2
  68. package/src/tools/parse_tool_catalog.ts +18 -6
  69. package/src/ui/approval_card.ts +90 -2
  70. package/src/ui/checkpoint_menu.ts +22 -5
  71. package/src/ui/confirmation_card.ts +29 -1
  72. package/src/ui/message_actions.ts +158 -0
  73. package/src/ui/page_quote_offer.ts +215 -0
  74. package/src/ui/quote_selection.ts +345 -0
  75. package/src/ui/relative_time.ts +11 -0
  76. package/src/ui/render_markdown.ts +111 -21
  77. package/src/ui/resize_handle.ts +32 -2
  78. package/src/ui/stick_to_bottom.ts +126 -0
  79. package/src/ui/styles.ts +227 -0
  80. package/src/ui/suggestion_chips.ts +73 -0
  81. package/src/ui/thread_drawer.ts +22 -2
  82. package/src/ui/tool_call_card.ts +9 -0
  83. package/src/ui/ui_strings.ts +79 -8
  84. package/src/ui/voice_input.ts +43 -0
  85. package/src/version.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,666 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.29.0] — 2026-08-29
11
+
12
+ ### Added
13
+
14
+ - **Quote a selection into the composer.** Selecting text in the transcript now
15
+ floats a **Quote** offer beside it; taking it drops the selection in as a
16
+ markdown blockquote and leaves the caret on a fresh line under it. Nothing is
17
+ sent -- a quotation is how a question narrows to one part of an answer, so the
18
+ question still has to be written. Quoting appends, so a second quotation is a
19
+ second thing being asked about. Long selections cap at 500 characters.
20
+ `data-quote-selection="false"` turns the offer off; the `quote-selection`
21
+ `::part()` styles it.
22
+
23
+ **The half worth having is the one the transcript cannot reach.** A chat
24
+ mounted beside a table, a diff or a report sits in the surface the user
25
+ actually works in, and a selection made *there* is one no hosted chat can see.
26
+ `offerQuoteInPage()` extends the same select-then-offer gesture to the host's
27
+ own page, or to one region of it, and `quote(text)` is the seam underneath for
28
+ a deliberate trigger like a per-row "ask about this" button.
29
+
30
+ **The page half is a method rather than a documented recipe, and that is the
31
+ correction, not the design.** It shipped first as four lines in the README --
32
+ quote every settled selection -- which appends to the composer on every drag
33
+ the user made to *read*, to copy, or to fix a typo. Worse, it cannot tell a
34
+ selection in the page's prose from one inside the user's own half-typed
35
+ `<input>`: Chrome reports a field's internal selection through
36
+ `document.getSelection()` as an ordinary range over the field's **wrapper**,
37
+ so the text reads back perfectly and nothing about the range says where it
38
+ came from. The only signal is `document.activeElement`. That guard, plus
39
+ skipping the widget's own transcript -- which needs the *event path*, since
40
+ `Node.contains` is false across a shadow boundary -- plus retiring a
41
+ fixed-position affordance on scroll, is three non-obvious guards, and three
42
+ guards is a feature rather than a snippet. `attachQuoteOffer` is exported for
43
+ a host that wants it without the element.
44
+
45
+ **A selection across several elements is not a paragraph, and was treated as
46
+ one twice over.** The offer was hung off the selection's *bounding box*, whose
47
+ centre belongs to no line -- a drag from a form's left column down to a
48
+ full-width line running under the chat panel put the offer on the panel,
49
+ pointing at a line the user had never looked at. It now hangs off the line the
50
+ gesture ended on, or the first line for a keyboard selection. And the text was
51
+ read with `Range.toString()`, which concatenates text nodes and asks nothing
52
+ about CSS: quoting a form returned the values of every `<option>` in a closed
53
+ `<select>`, the markup's own indentation on every line, and a blank `>` for
54
+ every gap between elements -- twenty-four lines of which twelve were empty.
55
+ The read is now what the engine says is rendered (`checkVisibility`), with the
56
+ whitespace a collapsing `white-space` collapses, and preformatted text passed
57
+ through so a quoted code block keeps its shape. Four leading spaces inside a
58
+ blockquote is a markdown code block, so this was a rendering defect and not
59
+ only an untidy one.
60
+
61
+ Reading a selection out of a shadow tree is the part that takes care, and the
62
+ component now does it properly: engines disagree about what
63
+ `document.getSelection()` reports for a selection made inside a shadow root,
64
+ and `getComposedRanges` is used where the engine has it, with the direct read
65
+ behind it. `quotableSelection`, `asQuote` and `MAX_QUOTE_CHARS` are exported
66
+ for a host with the same problem in its own component.
67
+
68
+ - **`approveWithEdits` — edit a gated call's arguments before approving it.**
69
+ AG-UI's resume payload carries `editedArgs` and the protocol gates it on the
70
+ agent's `approveWithEdits` capability; the approval card could not offer it.
71
+ It now shows the call's arguments as editable JSON.
72
+
73
+ **Off by default, and an assertion about the server rather than a
74
+ negotiation.** Capabilities are not on the wire this component reads, so it
75
+ cannot check — and turned on against a server that ignores `editedArgs`, a
76
+ user would edit arguments it silently discards, which is worse than not
77
+ offering.
78
+
79
+ `editedArgs` rides the payload **only when something actually changed**, so a
80
+ server can tell "approved as proposed" from "approved, but like this" without
81
+ diffing what it sent. Unparseable JSON, or JSON that is not an object, keeps
82
+ the card open with the reason on it rather than approving the original behind
83
+ the user's back. Offered only for an interrupt naming a call this component
84
+ holds a card for, since the card is where the arguments still are.
85
+
86
+ New `::part()`s `approval-edit`, `approval-args`, `approval-error`, and three
87
+ strings.
88
+
89
+ - **`formatRelativeTime` — replace the drawer and checkpoint timestamps.** There
90
+ is no `Intl` anywhere in this component, and the locale-neutral `"5m ago"` is
91
+ deliberate: guessing a locale would disagree with the host page, and being
92
+ wrong in a second language is worse than being neutral in one. That is a good
93
+ default and a bad requirement, and a host previously could not even reach the
94
+ formatter to replace it.
95
+
96
+ `relativeTime` and the `RelativeTimeFormatter` type are now exported, and the
97
+ formatter is read at render rather than at connect, so setting it after mount
98
+ works.
99
+
100
+
101
+ - **Server-pushed follow-up suggestions.** A `suggestions` activity draws its
102
+ prompts as chips; clicking one sends it as the user's message. Registered
103
+ skill chips are static and host-configured, so they can say "summarize this"
104
+ but never "want me to update the shipping address too?" after a tool has run.
105
+
106
+ Rides the activity envelope charts already use rather than a `CUSTOM` event,
107
+ which buys persistence for nothing: chips are content, so a reload puts them
108
+ back, and a set pushed under an id already on screen replaces that row.
109
+
110
+ Bounded at 4 prompts of 120 characters, mirroring django-ag-ui's
111
+ `suggestions_activity()`. The producer *raises* past those bounds while this
112
+ side silently drops — deliberate asymmetry, because the producer can report
113
+ the problem and the client cannot. Both numbers live on both sides for the
114
+ reason the chart bounds do: mirroring only some of them leaves exactly the
115
+ silent-drop hole they exist to close.
116
+
117
+ New `::part()`s `suggestions` / `suggestion-chip`, new
118
+ `SUGGESTIONS_ACTIVITY_TYPE`, and `renderSuggestionChips` / `suggestionPrompts`
119
+ exported for a host drawing its own.
120
+
121
+
122
+ - **A message action row — copy, retry, thumbs up/down — under every finished
123
+ assistant message.** There were **zero** message-level actions before this;
124
+ `attachCopyButtons` handled fenced code and nothing else.
125
+
126
+ **Retry is the item that earns it.** History truncates to the most recent user
127
+ message inclusive and the run repeats, so the agent answers the question it was
128
+ asked rather than being told its last answer was wrong. It sits on the **last**
129
+ answer only: re-running an older turn is branching, and for a page-driving
130
+ agent editing a past turn is not neutral, because those turns clicked buttons.
131
+ `retryLastTurn()` is public for a host driving its own message UI.
132
+
133
+ A retried turn **re-runs its tools**, which the previous attempt already ran.
134
+ Confirmation still applies, so a destructive tool asks again unless the user
135
+ waived it this session.
136
+
137
+ Ratings fire `ag-ui-feedback` and **store nothing**: a rating belongs to
138
+ whatever the host already uses for product signal, and a write-only table
139
+ inside a chat widget is a schema nobody reads.
140
+
141
+ New `::part()`s `message-actions` / `message-action` (plus
142
+ `message-action-retry`, `-copy`, `-up`, `-down`), new `FEEDBACK_EVENT` and
143
+ `FeedbackDetail`, and `attachMessageActions` / `messageActionBar` exported for
144
+ a host assembling its own transcript.
145
+
146
+ - **A dropped run has a way back.** `ConnectionLostError` rendered a dead
147
+ "Connection lost" bubble; only *uploads* had retry. The failed bubble now
148
+ carries the same action row, with Retry and Copy and no rating — error text is
149
+ what people paste into a bug report, while "the connection dropped" is not a
150
+ statement about answer quality and mixing it into feedback makes that signal
151
+ say less.
152
+
153
+ **Kept as an error rather than demoted to a run notice**, which is what was
154
+ originally proposed. `renderRunNotice`'s contract is that a notice "never
155
+ settles, takes no action, and carries no controls" and is "distinct from an
156
+ error, which is a failure". This is a failure that now needs a control, so the
157
+ taxonomy already had the answer.
158
+
159
+
160
+ - **"Always allow" on the confirmation card — a session-scoped waiver, per tool
161
+ name.** Confirmation was binary and permanent: `autoConfirm` is
162
+ all-or-nothing and `confirmPredicate` has no memory, so a tool the user
163
+ approves every single time keeps asking every single time.
164
+
165
+ A prompt approved nearly every time is not a decision, it is a speed bump, and
166
+ the reflex it trains is what makes the rare refusal easy to miss. Anthropic
167
+ published that users approve **~93%** of Claude Code permission prompts
168
+ manually and called interactive confirmation *"behaviorally unreliable as a
169
+ sole safety mechanism"* on exactly that basis. The waiver exists so the
170
+ prompts that remain still mean something.
171
+
172
+ **The button appears only where the `x-destructive` default is what gated the
173
+ call.** `confirmPredicate` is documented as authoritative, so letting one
174
+ click retire it would silently defeat a host policy — and because the offer
175
+ and the allowlist sit on the same path, there is no dead button either. The
176
+ waiver is per tool name and per element, held in memory and never persisted: a
177
+ session decision that outlived the tab would be a permanent grant made by one
178
+ click, which is what `autoConfirm` already exists to say deliberately.
179
+
180
+ New `::part()` `confirm-always`, new string `confirmAlways`, and
181
+ `requestConfirmation` gains an `onAlwaysAllow` option — its presence is what
182
+ renders the button, so the affordance can never appear with nothing listening.
183
+ The card still resolves `true`: the waiver is *in addition to* approving this
184
+ call, not instead of it.
185
+
186
+
187
+ - **`ag-ui-invalidate` and `RunFinishedDetail.invalidated` — the agent tells your
188
+ page what it moved.** The agent writes and the page still shows the old list.
189
+ `ag-ui-run-finished` already said *something* moved, so this is **precision on
190
+ a channel that ships**: the server names the resources and a host refetches
191
+ only those.
192
+
193
+ ```js
194
+ chat.addEventListener("ag-ui-invalidate", (e) => {
195
+ if (formIsDirty()) return showBanner("This data changed.", e.detail.keys);
196
+ refetch(e.detail.keys);
197
+ });
198
+ ```
199
+
200
+ **Do not reload on this.** The user was probably typing, and an
201
+ agent-triggered reload into a live form destroys unsaved input with no
202
+ explanation the user can see. The component never reloads by itself and offers
203
+ no option that would.
204
+
205
+ **Two dispatches, deliberately.** The live event fires as each announcement
206
+ arrives, which is what makes a long multi-step run feel live -- the list
207
+ refreshes as the third of eight writes lands. `invalidated` on the existing
208
+ run-finished detail carries the same keys de-duplicated at the end, so a host
209
+ already listening there upgrades by reading one extra field:
210
+
211
+ ```js
212
+ if (detail.invalidated.length > 0) refetchOnly(detail.invalidated);
213
+ else if (detail.tools.some((t) => t.side === "server")) refetchEverything();
214
+ ```
215
+
216
+ That `else` is the whole compatibility story. A new server with an old client
217
+ has its `CUSTOM` event ignored and still gets the coarse refetch; an old server
218
+ with a new client leaves `invalidated` empty and falls through to the same
219
+ branch. Nothing negotiates, which is what makes this shippable across repos
220
+ with independent release cadences.
221
+
222
+ **Keys are opaque and matching is exact.** `orders/42` does not imply `orders`
223
+ -- a prefix rule would be this component guessing at a scheme it does not own,
224
+ and `orders/1` would match `orders/11`. A server that wants the collection
225
+ refreshed names it. Hosts may match hierarchically in their own vocabulary,
226
+ where the scheme is known.
227
+
228
+ Built on the `CUSTOM` carrier rather than `ACTIVITY_SNAPSHOT`, because an
229
+ invalidation is an imperative: activities are materialised into messages,
230
+ persisted and replayed on every thread restore, and an invalidation replayed on
231
+ every thread load is a refetch storm. Nothing is rendered or persisted, and
232
+ that absence is asserted. Every other `CUSTOM` name still arrives on
233
+ `ag-ui-custom` unchanged, and an invalidation does **not** also fire it -- a
234
+ host listening to both would otherwise refetch twice for one announcement.
235
+
236
+ Requires django-ag-ui 0.51 to have anything to receive.
237
+
238
+ - **`registerActivityRenderer` — `activityType` is an open set now, not two
239
+ branches.** AG-UI leaves exactly two payload names an open string the protocol
240
+ does not enumerate, and the component treated neither as open: `chart` and
241
+ `compaction` were handled and everything else fell through a bare `return`,
242
+ with no host seam and no record that anything had arrived. The server could
243
+ only say things the client had been compiled to understand, in a protocol
244
+ designed so it can say more.
245
+
246
+ ```js
247
+ chat.registerActivityRenderer({
248
+ type: "build_status",
249
+ render: (content) => {
250
+ const el = document.createElement("div");
251
+ el.textContent = `Build ${content.status}`;
252
+ return el;
253
+ },
254
+ });
255
+ ```
256
+
257
+ `render` carries the same contract as a client tool's `render`, and for the
258
+ same reason rather than by analogy: activities are materialised into
259
+ `role: "activity"` messages, persisted with the transcript and re-fired on
260
+ restore, so a renderer that writes to the page instead of returning DOM fires
261
+ again on every thread load.
262
+
263
+ **Both built-ins go through the registry**, which is the test that the seam is
264
+ real — a built-in needing a privileged branch would mean the seam cannot
265
+ express what the component itself needs. Registering either name replaces it.
266
+
267
+ Compaction gains two things by going through the seam: a reload puts the
268
+ notice back (it is content, and content replays), and a server redrawing under
269
+ the same `messageId` replaces it instead of leaving two notices standing for
270
+ one event.
271
+
272
+ - **`unhandledActivityTypes`** — the activity types that arrived with nobody
273
+ registered to draw them. Deliberately the only trace: ignoring an unknown name
274
+ is the protocol's own answer, and a warning would fire on every
275
+ forward-compatible server, but "nothing happened and nothing was said" is
276
+ impossible to debug. Note `chart` is listed until `enableCharts(["activity"])`
277
+ is called, which is the honest answer to "I pushed a chart and nothing
278
+ happened".
279
+
280
+ - **`ag-ui-custom`** (`CUSTOM_AGENT_EVENT`, detail `CustomAgentDetail`) — the
281
+ other open carrier, which had no implementation at all. An AG-UI `CUSTOM`
282
+ event is forwarded to the host page whole and uninterpreted, `bubbles` and
283
+ `composed` like every other event the element dispatches.
284
+
285
+ **It is deliberately not rendered, persisted or replayed.** That asymmetry is
286
+ the rule for choosing between the two carriers: `ACTIVITY_SNAPSHOT` is content
287
+ and has a place in the conversation, `CUSTOM` is an imperative with no meaning
288
+ once acted on, and replaying "refetch the board" on every thread load would be
289
+ a bug rather than a feature.
290
+
291
+ **Note:** pydantic-ai emits its own compaction activity under
292
+ `pydantic_ai_compaction`, by a different route than the harness sink this
293
+ package renders as `compaction`. The registry deliberately does **not** answer
294
+ to both names — doing so would give a deployment running both two notices for
295
+ one event. It shows up in `unhandledActivityTypes` instead, so a host that
296
+ wants it can register it and decide about duplication itself.
297
+
298
+ ### Fixed
299
+
300
+ - **The message action row sat closer to the block below it than to the message
301
+ it acts on.** The answer group is a flex column with a 10px gap, so the row's
302
+ `margin-top: 6px` **added** to that gap instead of tightening it: 16px above,
303
+ 10px below, and the buttons read as belonging to whatever card followed. A
304
+ negative margin pulls the row back inside the gap — 4px above, 10px below.
305
+
306
+ Found by driving the demo, not by any assertion: every existing check was
307
+ satisfied by the broken spacing. Ordered correctly, contained correctly,
308
+ visible — and grouped with the wrong thing. The new browser case fails against
309
+ the previous stylesheet with `expected 16 to be less than 10`.
310
+
311
+
312
+ - **The action row is a sibling of the message bubble, not a child.** Inside, the
313
+ buttons join the bubble's `textContent` — which is what Copy reads, what
314
+ history persists, and what every existing assertion about a message's text
315
+ compares against. An answer would have been copied back carrying the glyphs of
316
+ the buttons that copied it. Caught by twelve existing tests failing at once,
317
+ which is the check working.
318
+
319
+
320
+ - **The confirmation card's action row could push a button outside the card.**
321
+ It was a `justify-content: flex-end` flex line with no wrap, built when there
322
+ were two buttons. Measured in Chromium at a 260px panel: the three buttons want
323
+ 71 + 107 + 81 plus two 8px gaps against a 200px row, and the overflow went off
324
+ the **left** edge — Cancel rendered, styled and reporting its label, 27px
325
+ outside the box the user can see and hit.
326
+
327
+ `flex-wrap: wrap` lets the row take a second line instead, as the checkpoint
328
+ row already does. Found by measuring rather than by review: happy-dom lays out
329
+ no boxes and answers 0 for every width, so it called the overflowing row and
330
+ the fitting one the same pass. The new browser test fails against the previous
331
+ stylesheet at that width and passes at the two wider ones, which is the honest
332
+ shape of the bug.
333
+
334
+
335
+ - **A server that replaced the conversation did it in silence.** AG-UI's
336
+ `MESSAGES_SNAPSHOT` is applied by `@ag-ui/client` before any subscriber runs,
337
+ so `agent.messages` **is** the server's list by the time the host sees
338
+ anything — and the run loop persists `agent.messages`. The replacement
339
+ therefore reached the conversation store either way, while the DOM was
340
+ untouched. Nothing looked wrong until a reload, in a later session, served a
341
+ transcript the user had never seen, with no event that could be correlated to
342
+ it. That is not reported as a bug; it is reported as "the chat lost my
343
+ messages".
344
+
345
+ The store still follows the server, deliberately — the server is authoritative
346
+ about what the conversation *is*, and it would follow it regardless. What
347
+ changes is that the replacement is now announced in the transcript.
348
+
349
+ **Re-rendering from the snapshot was the other candidate and is declined.** A
350
+ snapshot can land mid-run, and rebuilding the transcript then destroys the
351
+ in-flight run's own state: the streaming bubble, the open answer group, and
352
+ every tool card keyed by call id, some still waiting on results. This is the
353
+ same answer the same question already got for compaction.
354
+
355
+ - **One string-valued point silently dropped a whole chart that was on screen.**
356
+ `chartSpecFrom` returns `null` for the entire spec when any point is not a
357
+ finite number — not the offending series, the whole chart — and the pushed
358
+ activity path then removed any chart already rendered and returned, with no
359
+ `console` call anywhere on that path. The triggering shape is not exotic: a
360
+ Django `Sum` over a `DecimalField` serialises as a JSON string, and money is
361
+ the most common chart input there is.
362
+
363
+ **Removing it is still right** and is unchanged: leaving retracted numbers on
364
+ screen reading as current is worse, and a reload drops the chart anyway
365
+ because the *stored* content is the version that could not be drawn. Live and
366
+ reload should agree. What was wrong was doing it silently. The path now warns
367
+ on the console naming the likely cause, and posts a notice in the transcript
368
+ when a chart that had been drawn is taken away.
369
+
370
+ Nothing is coerced, client-side or server-side. `django-ag-ui` already raises
371
+ at construction and names `Decimal` on purpose, and guessing whether
372
+ `"1234.50"` lost precision upstream is not a favour worth doing.
373
+
374
+
375
+ - **A screen reader re-announced the whole answer tens of times per turn.** The
376
+ transcript carried both `role="log"` and an explicit `aria-live="polite"`,
377
+ and the streaming bubble's `innerHTML` is replaced inside it on every
378
+ animation frame. `role="log"` already implies polite announcement and the
379
+ default `aria-relevant` includes text additions, so every frame was a fresh
380
+ announcement of the answer so far. That is not merely unhelpful; it is
381
+ hostile.
382
+
383
+ The transcript is demoted out of live-region duty with an explicit
384
+ `aria-live="off"`, which overrides the value the role implies. **The role
385
+ stays** -- the log semantics are what let the transcript be navigated as one,
386
+ and only the announcing was the defect.
387
+
388
+ A separate visually-hidden status region takes over, and roughly four short
389
+ statuses land per turn: responding, answered, a decision is waiting and how
390
+ many, stopped, failed. Five new `strings` keys (`announceResponding`,
391
+ `announceAnswerReady`, `announceAwaitingDecision`, `announceStopped`,
392
+ `announceFailed`) make all of them translatable. The answer's own words never
393
+ reach it, and neither does an exception's.
394
+
395
+ - **The transcript could not be read while anything streamed.** Eleven separate
396
+ sites assigned `scrollTop = scrollHeight` unconditionally, and nothing in the
397
+ element listened for a `scroll` event -- so nothing knew the reader had
398
+ scrolled up, and scrolling back during a run was undone by the next token.
399
+
400
+ All eleven now follow the foot only while the reader is already there. A
401
+ **jump-to-latest** button (`jump-latest` part, `jumpToLatest` string) appears
402
+ once they have scrolled away *and* have since missed something; scrolling up
403
+ through a settled transcript is not a reason to nag. A user's own message
404
+ still goes to the foot -- pressing Send is as deliberate as pressing the
405
+ button. `overflow-anchor: none` stops the browser's own scroll anchoring
406
+ competing for the same job.
407
+
408
+ - **A strict-CSP host got an unstyled widget.** The stylesheet was injected as
409
+ an inline `<style>`, which a host with a strict `style-src` and no
410
+ `'unsafe-inline'` drops silently: the component mounted, functioned, and
411
+ rendered with no styling at all, and nothing in the console pointed at why.
412
+ It is attached with `adoptedStyleSheets` instead, which carries no
413
+ inline-style origin.
414
+
415
+ The sheet is per instance rather than shared at module scope. A shared one
416
+ would also stop re-parsing the stylesheet once per mount, but a module-level
417
+ singleton is what this package forbids, and per instance is no worse than the
418
+ `<style>` it replaces.
419
+
420
+ All three came out of a **survey of how other products build chat**, not a
421
+ review. Two review passes and a full audit wave went over this component
422
+ without surfacing any of them, because each is invisible unless you ask how
423
+ everyone else does it.
424
+
425
+ ## [0.28.0] — 2026-08-26
426
+
427
+ ### Added
428
+
429
+ - **A conversation could carry from one signed-in user to the next in the same
430
+ tab.** New `user-key` attribute (and matching `userKey` property): set it to
431
+ whatever identifies the signed-in principal, and the stored conversation is
432
+ scoped to them, so two principals in one tab cannot reach each other's
433
+ transcript. Changing it — or clearing it — purges everything the previous
434
+ principal left behind under this element: the transcript, the history drawer
435
+ index and any navigation checkpoint. That purge is why it is a live attribute
436
+ rather than a connect-time one: `sessionStorage` is scoped to the tab and not
437
+ to the session, so it survives the navigation a logout is, and a single-page
438
+ app signs out through its own router without remounting anything — the host
439
+ naming the new principal is the only signal there is. The first value to
440
+ arrive is treated as a host naming the user who was already there rather than
441
+ as a handover, so an element configured by an async auth handshake keeps the
442
+ conversation on screen. Absent, behaviour is exactly what it was, including
443
+ that carry-over; the README says so where the attribute is documented.
444
+ `SessionStorageStore.purge(namespace)` exposes the same primitive for a host
445
+ driving its own sign-out.
446
+
447
+ - **Pointing history at a server still left a full copy of every transcript in
448
+ the browser.** `RemoteConversationStore` mirrored each message body into
449
+ `sessionStorage` whatever the deployment had chosen, so an operator who
450
+ configured `data-threads-url` precisely to keep conversations in the database
451
+ got them in both places. The mirror is now a `cacheMessages` constructor
452
+ option, exposed on the element as `data-threads-cache="false"`, and it still
453
+ defaults to the caching behaviour so nobody's setup changes silently. Turning
454
+ it off keeps only the client-only state — the active thread id and the
455
+ navigation checkpoint — so reloads and navigating tools work as before; what
456
+ it costs is the offline fallback, since there is no longer a local copy to
457
+ fall back to.
458
+
459
+ - **`UiStrings.recordingLimit`** — what the mic button says after a recording
460
+ stopped itself at the length cap. Token: `{n}`, the cap in minutes. Like every
461
+ other key it has an English default, so an existing `strings` override keeps
462
+ working untouched.
463
+
464
+ - **`trustedOrigins` on the agent factory options** — the origins, besides the
465
+ document's own, the agent may carry host credentials to. Naming one confirms
466
+ the destination was chosen deliberately and silences the notice above for it.
467
+ Compared as serialized origins (`https://agent.example.com`), scheme and port
468
+ included. Reachable through a custom `agentFactory`, which is how a host wraps
469
+ `createHttpAgent` today.
470
+
471
+ - **`trustedOrigins` on the element** — the origins, besides the page's own, that
472
+ every one of its seven configurable URLs may carry host credentials to without
473
+ a console notice. Forwarded to the agent factory as well, so a host that does
474
+ not override `agentFactory` configures all seven in one place.
475
+
476
+ ### Changed
477
+
478
+ - **`parseToolCatalog` returns whole catalog entries, not bare summaries.** Its
479
+ return type was `Record<string, string>`, so the `description` field that
480
+ `ToolCatalogEntry` declares and documents was dropped at parse time for every
481
+ entry — a documented wire field no consumer could reach, and none could be
482
+ added without changing this signature first. It now returns
483
+ `Record<string, ToolCatalogEntry>`. Tool-call cards still label themselves from
484
+ `summary`; callers of the exported parser get the entry the server actually
485
+ sent. A malformed `description` costs that field, not the entry, matching the
486
+ tolerance the rest of the parse already had.
487
+
488
+ ### Fixed
489
+
490
+ - **A reasoning block lost its last sentence, and a short one never appeared at
491
+ all.** The protocol client hands a delta subscriber the text accumulated
492
+ *before* the delta it is announcing, so following that callback alone trails
493
+ the stream by one and renders nothing whatsoever for reasoning that arrives as
494
+ a single delta. The answer text was spared because its own end event carries
495
+ the whole message; reasoning subscribed to an end event that carries no buffer.
496
+ It now also listens to the one that does. The test helper was the reason this
497
+ went unseen: it handed the subscriber the full buffer on every delta, a wire no
498
+ server writes, so every existing test agreed with the bug.
499
+
500
+ - **Two chats on one page shared one conversation.** The storage namespace falls
501
+ back from the element's `id` to its `endpoint`, so two `<ag-ui-chat>` elements
502
+ with no `id` against the same agent mount — a docked support panel and an
503
+ inline page assistant, say, and nothing requires an `id` — resolved to the same
504
+ namespace and shared a thread pointer, a history drawer and every message key.
505
+ Whichever mounted second adopted the first's active thread and rehydrated its
506
+ transcript into its own panel. The first element to mount now keeps the
507
+ namespace, so the ordinary single-element case is untouched, and a second is
508
+ given a throwaway namespace of its own plus a console warning naming the fix.
509
+ The throwaway namespace is minted per mount, so give each element an `id` for
510
+ its conversation to survive a reload.
511
+
512
+ - **A full storage quota was reported as an agent failure.** `sessionStorage`
513
+ writes throw once the quota is exhausted — a long conversation, or one turn
514
+ carrying a large tool result — and in privacy modes that deny storage
515
+ outright. The transcript is persisted from inside the run loop, so that throw
516
+ surfaced as a run error and told the user the agent had failed when nothing
517
+ but the browser's storage had; on the cancel path it escaped as an unhandled
518
+ rejection instead. A write that cannot be made now costs the reload and not
519
+ the conversation, and says so in the console once rather than once per turn.
520
+
521
+ - **A tool a page deliberately withheld from a run still ran when the agent
522
+ called it.** `getTools` is a per-run catalog provider, so a host is invited to
523
+ scope what a given page offers while registering everything once at mount.
524
+ Dispatch never consulted it: it resolved the call name straight against the
525
+ mount-wide registry, so a call naming a scoped-out tool found its handler and
526
+ ran it — with the confirmation card the only remaining gate, and that card is
527
+ waived by `autoConfirm`, by a `confirmPredicate`, or by a schema without
528
+ `x-destructive`. The page's decision not to offer the tool carried no weight
529
+ where it mattered. The names a run advertises are now captured as the catalog
530
+ goes out, and a call outside that set is treated exactly as a call naming a
531
+ tool that was never registered. The set is the snapshot, not a fresh
532
+ `getTools()` at dispatch time — asking the provider again would re-open the
533
+ window it exists to close. Hosts that never override `getTools` advertise the
534
+ built-ins plus everything registered, which is exactly what dispatch could
535
+ reach before, so nothing changes for them.
536
+
537
+ - **A long streamed answer slowed the tab down as it arrived, and destroyed any
538
+ text selected inside it on every token.** Each `TEXT_MESSAGE_CONTENT` event
539
+ carries the whole answer so far, and every one of them re-parsed the markdown,
540
+ re-sanitised it and replaced the bubble's entire subtree. Cost grew with the
541
+ square of the answer's length — a 40 KB answer streamed token by token meant
542
+ thousands of full parses over a document that kept getting longer — and
543
+ because the subtree was rebuilt each time, a selection or a focus inside the
544
+ bubble could not survive a single token. Since a long answer is entirely
545
+ agent-controlled, an agent induced to produce one turned an ordinary run into
546
+ a stalling tab. Deltas now coalesce into one render per animation frame: a
547
+ burst of tokens costs one parse, and a frame is the fastest anything on screen
548
+ can change anyway, so the text still flows rather than arriving in chunks. The
549
+ bubble opens on the first token as before, keeps its identity throughout, and
550
+ a run that ends without closing its text message — a cancel, an error, a round
551
+ boundary — draws the queued delta before letting the bubble go, so a stopped
552
+ answer keeps its last words.
553
+
554
+ - **Model output could draw a pixel-accurate copy of the approval card.** The
555
+ markdown sanitiser kept `class` on every element it allowed, and the shadow
556
+ stylesheet's component classes are unscoped selectors, so a
557
+ `<span class="approval-btn approval-btn--approve">` in an assistant message
558
+ resolved to the same background, border and radius as the genuine
559
+ human-in-the-loop approve button — rendered as ordinary prose, inside the one
560
+ surface where the user decides whether to approve something. The same trick
561
+ reproduced the question card, the tool-call card and a turn the user never
562
+ took. `class` was on the allowlist for exactly one thing, `marked`'s
563
+ `language-*` code-fence hint, and is now narrowed to it: a `language-*` token
564
+ on a `code` or `pre` element survives, and every other class is dropped.
565
+ Highlighting a fenced code block is unaffected.
566
+
567
+ - **The sanitiser allowed far more attributes than the three it declared.**
568
+ DOMPurify's `ALLOW_DATA_ATTR` and `ALLOW_ARIA_ATTR` default to `true`, so every
569
+ `data-*` and `aria-*` attribute passed through alongside `href`/`title`/`class`
570
+ while the configuration read as though only those three could survive. That
571
+ handed model output the attributes the cards drive their resolved, status and
572
+ expanded appearance from, and let an `aria-label` make a screen reader announce
573
+ something other than what a sighted user reads. Both are now off, so the
574
+ declared allowlist is the effective one.
575
+
576
+ - **Rendered markdown was edited after the sanitiser had finished with it.** The
577
+ sanitised string was parsed into a `<template>`, given its `target`/`rel` link
578
+ hardening there, and re-serialised — so the markup actually inserted into a
579
+ bubble was never markup DOMPurify inspected, and it carried two attributes the
580
+ allowlist did not name. Nothing exploitable came of it, because no allowed
581
+ element serialises asymmetrically, but that held by accident rather than by
582
+ design: adding `svg`, `style` or `noscript` to the allowlist would have turned
583
+ the round trip into a bypass, and the suite asserted on the sanitiser's output
584
+ instead of on what was inserted. Link hardening now runs inside the sanitiser,
585
+ so what a caller inserts is exactly what DOMPurify approved, and a test holds
586
+ it there.
587
+
588
+ - **Resizing the panel from the keyboard called `commit` on every key repeat.**
589
+ It is documented as one call per completed resize and the pointer path honours
590
+ that, but the keyboard path called it straight from each `keydown`. Holding an
591
+ arrow key means OS key repeat at twenty to thirty events a second, so a host
592
+ that put a `sessionStorage` write or a `PATCH` behind `commit` got that many
593
+ for a single press — landing hardest on the keyboard users the path exists for.
594
+ Live feedback still happens per key event; the commit now waits for the key to
595
+ come up, or for focus to leave the grip mid-press.
596
+
597
+ - **A voice recording ran until somebody stopped it.** Tapping the mic and then
598
+ being interrupted left `MediaRecorder` running with no upper bound: audio
599
+ accumulated in memory, the browser's recording indicator stayed lit in a tab
600
+ nobody was looking at, and whenever the user came back the whole accumulated
601
+ clip was posted to the transcription endpoint in one body no client-side check
602
+ sized. A recording now stops itself after two minutes — far longer than a
603
+ dictated chat message, short enough to bound a forgotten one. The audio is kept
604
+ and transcribed rather than discarded, and the mic button says why it stopped.
605
+
606
+ - **The README's "Public API surface" tables were missing a third of the surface
607
+ they enumerate.** The section presents itself as complete, so a consumer who
608
+ went looking for the chart-drawing seam, the approval and question card
609
+ helpers, the transcription defaults, `prettifyToolName`, `parseToolCatalog` or
610
+ two of the event-name constants found no mention and concluded the package had
611
+ none. Thirty-two absent exports are now listed, and a test compares the tables
612
+ against the package root's export list so the next new export cannot go missing
613
+ quietly.
614
+
615
+ - **`UiStrings.checkpoints` had no documentation to hover.** An insertion of the
616
+ copy-button strings landed between the checkpoint panel's doc comment and the
617
+ field it described, stranding the comment above `copyCode`. It is back on
618
+ `checkpoints`, and the copy-button and checkpoint fields now have sections of
619
+ their own rather than splitting the relative-time group in half.
620
+
621
+ ### Security
622
+
623
+ - **Host credentials no longer leave the page's origin silently.** `endpoint` and
624
+ its sibling URL attributes are plain HTML, so a page that builds one from a
625
+ query parameter or from tenant-authored configuration has handed whoever wrote
626
+ that value the destination of the element's requests. The browser preflights
627
+ the custom header, any server willing to answer receives it, and the CSRF token
628
+ or bearer the host supplies through `headers` / `getHeaders()` leaves on the
629
+ very first request — before the user has done anything. Nothing in the package
630
+ compared a configured URL against an expected origin, so the delivery was
631
+ invisible. The agent now reports it on the console, naming the destination and
632
+ the header names, once per origin.
633
+
634
+ It reports rather than refuses: an agent on another subdomain is a documented
635
+ deployment and keeps working unchanged. What is removed is the silence.
636
+
637
+ All seven configurable URLs are covered, not the agent endpoint alone. The tool
638
+ catalog, the skills list, the thread index, the attachment upload and the
639
+ transcription endpoint are named by the same kind of host attribute and carry
640
+ the same headers, so reporting only the agent would have reported the least
641
+ interesting of the seven.
642
+
643
+ ### Documentation
644
+
645
+ - **Registering a duplicate tool name does not throw.** The README said it did.
646
+ `ClientToolRegistry.register` is a plain map write, deliberately, so a re-fired
647
+ host ref or React StrictMode's double-invoke replaces rather than raises. The
648
+ harm in the claim is not the defensive guard nobody needs; it is a host
649
+ believing two tools cannot quietly share a name, where the second wins.
650
+
651
+ - **`x-destructive` gates frontend tools only, and the docs claimed otherwise.**
652
+ `isDestructive` described the flag as one the server stamps, which cannot
653
+ reach it: tool schemas travel client-to-server on `RunAgentInput.tools`, and
654
+ the only channel coming back is the label catalog, which carries names and
655
+ summaries and no flags. Marking a server-side tool destructive therefore
656
+ produces no confirmation card in the browser — it has to be gated server-side,
657
+ which surfaces as an approval card instead. The helper's doc comment and the
658
+ README's confirmation section now say so.
659
+
660
+ - **A tool handler's thrown message reaches the model.** When a handler rejects,
661
+ its `Error.message` is posted back as that call's tool result: into the
662
+ conversation, on to the AG-UI endpoint, persisted there, and replayed to the
663
+ model provider on every later round. That is deliberate — a real reason is
664
+ what lets the agent recover — but the same string is only ever shown to the
665
+ user as a short card label, so a host rethrowing an internal error had no way
666
+ to see that an internal hostname, a signed URL or a stack-derived path had
667
+ left the browser. `registerTool` and the README now say it plainly, so hosts
668
+ can throw the message they would be content for the model to read.
669
+
10
670
  ## [0.27.0] — 2026-08-26
11
671
 
12
672
  ### Fixed
@@ -1705,7 +2365,9 @@ hosts that both arrange the page the way it expects.
1705
2365
  ### Notes
1706
2366
  - First release — exercising the automated npm OIDC publish pipeline end-to-end.
1707
2367
 
1708
- [Unreleased]: https://github.com/Artui/ag-ui-web-component/compare/v0.27.0...HEAD
2368
+ [Unreleased]: https://github.com/Artui/ag-ui-web-component/compare/v0.29.0...HEAD
2369
+ [0.29.0]: https://github.com/Artui/ag-ui-web-component/compare/v0.28.0...v0.29.0
2370
+ [0.28.0]: https://github.com/Artui/ag-ui-web-component/compare/v0.27.0...v0.28.0
1709
2371
  [0.27.0]: https://github.com/Artui/ag-ui-web-component/compare/v0.26.1...v0.27.0
1710
2372
  [0.26.1]: https://github.com/Artui/ag-ui-web-component/compare/v0.26.0...v0.26.1
1711
2373
  [0.26.0]: https://github.com/Artui/ag-ui-web-component/compare/v0.25.2...v0.26.0