@artooi/ag-ui-web-component 0.28.0 → 0.30.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 (65) hide show
  1. package/CHANGELOG.md +615 -1
  2. package/README.md +564 -35
  3. package/dist/ag-ui-web-component.bundle.js +491 -50
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +129 -1
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +232 -1
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +56 -1
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/index.d.ts +8 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2081 -98
  14. package/dist/index.js.map +4 -4
  15. package/dist/ui/approval_card.d.ts +18 -0
  16. package/dist/ui/approval_card.d.ts.map +1 -1
  17. package/dist/ui/checkpoint_menu.d.ts +10 -0
  18. package/dist/ui/checkpoint_menu.d.ts.map +1 -1
  19. package/dist/ui/confirmation_card.d.ts +16 -0
  20. package/dist/ui/confirmation_card.d.ts.map +1 -1
  21. package/dist/ui/message_actions.d.ts +56 -0
  22. package/dist/ui/message_actions.d.ts.map +1 -0
  23. package/dist/ui/page_quote_offer.d.ts +33 -0
  24. package/dist/ui/page_quote_offer.d.ts.map +1 -0
  25. package/dist/ui/quote_selection.d.ts +66 -0
  26. package/dist/ui/quote_selection.d.ts.map +1 -0
  27. package/dist/ui/relative_time.d.ts +10 -0
  28. package/dist/ui/relative_time.d.ts.map +1 -1
  29. package/dist/ui/stick_to_bottom.d.ts +55 -0
  30. package/dist/ui/stick_to_bottom.d.ts.map +1 -0
  31. package/dist/ui/styles.d.ts +1 -1
  32. package/dist/ui/styles.d.ts.map +1 -1
  33. package/dist/ui/subagent_panel.d.ts +92 -0
  34. package/dist/ui/subagent_panel.d.ts.map +1 -0
  35. package/dist/ui/subagent_update.d.ts +19 -0
  36. package/dist/ui/subagent_update.d.ts.map +1 -0
  37. package/dist/ui/suggestion_chips.d.ts +29 -0
  38. package/dist/ui/suggestion_chips.d.ts.map +1 -0
  39. package/dist/ui/thread_drawer.d.ts +10 -0
  40. package/dist/ui/thread_drawer.d.ts.map +1 -1
  41. package/dist/ui/tool_call_card.d.ts +81 -1
  42. package/dist/ui/tool_call_card.d.ts.map +1 -1
  43. package/dist/ui/ui_strings.d.ts +50 -0
  44. package/dist/ui/ui_strings.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/constants.ts +138 -1
  47. package/src/core/ag_ui_chat.ts +1081 -73
  48. package/src/core/agui_client.ts +89 -2
  49. package/src/index.ts +43 -0
  50. package/src/ui/approval_card.ts +90 -2
  51. package/src/ui/checkpoint_menu.ts +22 -5
  52. package/src/ui/confirmation_card.ts +29 -1
  53. package/src/ui/message_actions.ts +170 -0
  54. package/src/ui/page_quote_offer.ts +215 -0
  55. package/src/ui/quote_selection.ts +345 -0
  56. package/src/ui/relative_time.ts +11 -0
  57. package/src/ui/stick_to_bottom.ts +126 -0
  58. package/src/ui/styles.ts +410 -0
  59. package/src/ui/subagent_panel.ts +213 -0
  60. package/src/ui/subagent_update.ts +80 -0
  61. package/src/ui/suggestion_chips.ts +73 -0
  62. package/src/ui/thread_drawer.ts +22 -2
  63. package/src/ui/tool_call_card.ts +138 -3
  64. package/src/ui/ui_strings.ts +75 -0
  65. package/src/version.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,618 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.30.0] — 2026-08-30
11
+
12
+ ### Added
13
+
14
+ - **The sub-agent fixture was re-copied after the server added a `timestamp` to
15
+ every `CUSTOM` event.** Reading the wire rather than the prose is what found
16
+ that gap: `CUSTOM` was the only event type in the stream without one. The
17
+ client needed no change to absorb it — the field is additive and ignored here —
18
+ which is the degradation story working, and the copy is byte-identical to the
19
+ producer's again.
20
+
21
+ - **A delegated sub-agent's progress, on the card that delegated it.** A run that
22
+ hands work to a sub-agent read as a stall: the parent's `delegate_task` card
23
+ sat at "running..." for the child's entire duration, however many tools the
24
+ child called, with nothing on screen. The component now consumes the AG-UI
25
+ `CUSTOM` event named `ag_ui.subagent` and draws it — the first real consumer of
26
+ the `onCustomEvent` carrier opened in 0.29.0.
27
+
28
+ **It attaches rather than floats, because the wire lets it.** `delegationId` is
29
+ the *parent's own* `delegate_task` tool-call id, not the child's run id, so the
30
+ thing being narrated is a card this component already drew on
31
+ `TOOL_CALL_START`. The surface is one collapsed row inside that card, carrying
32
+ the server's pre-rendered `status` line and nothing else, expanding onto the
33
+ child agent's own tool calls. A ten-step child costs one row until somebody
34
+ opens it, and there is no second visual language to learn.
35
+
36
+ Two alternatives were considered and dropped. A bare status line is cheaper and
37
+ gives up the detail entirely. Inline child cards in the main transcript
38
+ interleave parent and child with nothing marking whose is whose — and in an
39
+ order the persisted transcript, which never held the progress, cannot
40
+ reproduce.
41
+
42
+ **A failure carries no exception text on this channel, and none is invented
43
+ here.** That is the same reasoning that redacts a `RUN_ERROR`: an exception's
44
+ words are written for an operator. The detail rides the ordinary
45
+ `TOOL_CALL_RESULT` for that delegation and lands in the same card's result
46
+ region, a few pixels below the row that reported the failure.
47
+
48
+ **Nothing is persisted or replayed.** A `CUSTOM` event never enters the message
49
+ list, which is the correct half of the carrier split — a delegation that was
50
+ live an hour ago is not live now. Reload mid-run and the tool card is still
51
+ there while the nested detail is not; that is the intended behaviour rather
52
+ than a gap. Like `ag_ui.invalidate`, the name is routed rather than forwarded,
53
+ so it does not also arrive as an `ag-ui-custom` event; every other name still
54
+ reaches the host untouched.
55
+
56
+ A child's calls are keyed by the child's own `toolCallId`, so the `tool_call`
57
+ that opens one and the `tool_result` that settles it are one row updated in
58
+ place. The wire's tri-state `ok` is kept as one: absent while the call runs,
59
+ and only then a mark, since flattening "in flight" into "failed" would draw
60
+ every running call as a failure for as long as it ran. The row sits outside the
61
+ card body so it survives every `data-tool-display` mode — a progress line
62
+ visible only in `full` would leave the stall it exists to end.
63
+
64
+ New parts: `tool-card-subagent` on the card's region, and `subagent`,
65
+ `subagent-row`, `subagent-icon`, `subagent-status`, `subagent-steps`,
66
+ `subagent-step`, `subagent-step-icon`, `subagent-step-name` inside it. New
67
+ strings: `subAgentWorking`, `subAgentSteps`.
68
+
69
+ Tested against a fixture generated by the server's own encoder rather than a
70
+ hand-written double, replayed both through the subscriber and, in Chromium,
71
+ through the real `HttpAgent` from Server-Sent Events — with the resulting boxes
72
+ measured at sidebar and phone widths, since the row is a new control in a card
73
+ that already existed.
74
+
75
+ - **`formatToolPayload` — a host hook for what a tool card's body says.** The
76
+ card pretty-printed its two payloads as JSON and offered no way in, so a
77
+ thirty-field result rendered as a wall of text where the host wanted a table
78
+ or a sentence. `ClientTool.render` could not answer it: it is handed the
79
+ *arguments* only, and a server-side tool has no `ClientTool` at all, which
80
+ left the result region the one part of the transcript a host could not reach.
81
+ The hook is asked about each region of each card and may return a `Node`, a
82
+ `string`, or `null` to leave the built-in rendering alone.
83
+
84
+ **Both halves, one hook, told apart by `kind`.** They take different code
85
+ paths — arguments are rendered when the card is built, the result when it
86
+ settles — so covering only the result would have left the other half needing a
87
+ second hook later, and two hooks differing only in which region they draw is a
88
+ worse surface than one that says which. They do not carry the same thing,
89
+ which is why the payload is a discriminated union rather than a flat
90
+ `(toolName, payload, kind)`: `arguments` hands over the parsed record the call
91
+ was made with, `result` the raw string the tool returned plus the outcome it
92
+ settled on. Flattening them would have forced every formatter to re-derive
93
+ which it had, and re-serialised the arguments for nothing.
94
+
95
+ **Scoped to presentation, deliberately.** The card and the model already read
96
+ separate copies of a tool result — the model's is maintained by
97
+ `@ag-ui/client` from the same event and persisted with the history, and the
98
+ card has always shown that string reformatted — so a formatter changes what
99
+ the person reads and nothing the agent reads. That is what makes restyling
100
+ safe here, and equally why *rewording* was rejected: renaming a value belongs
101
+ on the server, where it reaches the model's prose too, instead of leaving the
102
+ card disagreeing with the answer beside it. A returned string is set as text
103
+ and never parsed as markup, so this is not a second HTML channel into the
104
+ transcript.
105
+
106
+ A region a formatter drew is marked `data-formatted`, which relaxes the
107
+ preformatted whitespace the JSON block relies on — a table would otherwise
108
+ inherit it as mangled cell spacing. Whitespace only: the card's face, frame
109
+ and scroll cap stay, because the card is one visual object and a payload sized
110
+ for a wide page must still be contained by a sidebar. `ToolPayload`,
111
+ `ToolPayloadFormatter` and `ToolCallCardOptions` are exported for a host
112
+ building cards itself.
113
+
114
+ - **`data-max-tool-rounds` — the tool-round budget is configurable.** The cap on
115
+ frontend tool-call to re-run rounds within one send was the constant
116
+ `MAX_TOOL_ROUNDS` (10) with one read and no way to change it. Ten suits a chat
117
+ whose tools answer questions; a page-driving deployment reaches it
118
+ legitimately — filling a form is one round per field — and the symptom is not
119
+ an error but an answer that stops mid-task, which reads as the model giving
120
+ up. `AgUiClientConfig.maxToolRounds` is the seam for a host driving the client
121
+ directly. A value below one is ignored rather than honoured: it would not be a
122
+ smaller budget but a send that never runs the agent at all, which would look
123
+ exactly like a broken endpoint. Validation lives in the client, so the
124
+ attribute and the config option cannot drift apart.
125
+
126
+ - **`data-message-actions` — the message action row has an opt-out.** The row
127
+ shipped with `::part()` hooks and no off switch, and ran on every finished
128
+ assistant bubble; a host embedding the component in a constrained surface had
129
+ no way to suppress it. The attribute is a comma list of the actions to keep
130
+ (`copy` / `retry` / `feedback`), and `="false"` — the spelling its sibling
131
+ gesture `data-quote-selection` already uses — leaves none. Absent means all
132
+ three, so the attribute only ever subtracts and a host that never sets it
133
+ keeps exactly what it had.
134
+
135
+ **Per-action rather than one switch**, because the three disappear for
136
+ different reasons: the rating pair is only useful to a host listening for
137
+ `ag-ui-feedback` and is two dead buttons otherwise, retry re-runs the agent
138
+ which a constrained surface may forbid, and copy is the one nobody objects to.
139
+ A single switch would have made dropping either of the first two cost the
140
+ third — and a host wanting one gone would have rebuilt the row from
141
+ `attachMessageActions`, reimplementing the part names, the accessible grouping
142
+ and the retry hand-off in order to lose two buttons. With nothing left the row
143
+ is not built at all: an empty one still takes its margin and still announces
144
+ itself to a screen reader as a group of actions. `MESSAGE_ACTIONS` is exported
145
+ as the token vocabulary.
146
+
147
+ ### Changed
148
+
149
+ - **`MessageActionsOptions.text` is optional**, and its absence is what omits
150
+ the copy button — the same idiom `onFeedback` already used, where what a
151
+ button needs to do its job is also the statement that it belongs. Additive for
152
+ existing callers.
153
+
154
+ ### Documentation
155
+
156
+ - **The README's API reference now agrees with the source, and a test keeps it
157
+ there.** `tests/readme_api_surface.test.ts` asserted only that every exported
158
+ *name* appeared somewhere in the README, which is the weakest claim a document
159
+ can make about a symbol: two of the wrong descriptions below were about
160
+ symbols the README named correctly and then described wrongly, so they passed
161
+ the gate as written. It now checks the claims the README actually makes,
162
+ wherever those can be derived from the source cheaply — the completeness of
163
+ the attribute, method and property lists; the live / connect-time split
164
+ against `observedAttributes` itself; the members of a documented object shape
165
+ against the interface that declares them; the parameter count of a documented
166
+ call or arrow type; that every `chat.x` the README writes names a real member;
167
+ and the markdown mechanics that make a claim readable at all — a link
168
+ resolving to a heading, a table row not split by a bare `|`, a run of rows not
169
+ orphaned from its header. The file states its own boundary in a comment: it reads structure and
170
+ never semantics, so return types, parameter types, prose and inherited
171
+ interface members are deliberately outside it. Reimplementing a TypeScript
172
+ parser here would cost more than the drift it caught.
173
+
174
+ - **`title-text` was documented as "the only observed attribute".** It is one of
175
+ eighteen, and the fourteen it did not mention are exactly the ones whose whole
176
+ purpose is to warn a framework host that a late attribute write is inert — so
177
+ the sentence told a reader that the machinery built for their case does not
178
+ exist. A new *When each attribute is read* subsection splits the observed set
179
+ into the four that are live and the fourteen that are connect-time, says what
180
+ a late write to each does, and names the one attribute
181
+ (`data-launcher-icon-url`) that is read while connecting but is not observed,
182
+ so a late write to that one is inert *and* silent.
183
+
184
+ - **`UploadHandler` was documented without the `signal` that prevents a leak.**
185
+ The type takes a third `signal?: AbortSignal`, fired when the tray removes a
186
+ chip or the element is torn down. A tus or direct-to-S3 adapter written from
187
+ the two-parameter signature orphans a server-side file on every removed chip,
188
+ which is a storage bill rather than a visible bug. `UploadOptions` and
189
+ `TranscribeOptions` were each missing `credentials` for the same reason
190
+ nothing noticed: an omitted option reads as an option that does not exist.
191
+
192
+ - **Other corrections found by the same sweep.** `parseToolCatalog` was still
193
+ documented with its pre-0.28.0 name-to-summary return, though it returns
194
+ `Record<string, ToolCatalogEntry>`; `QuestionRenderer` pointed at
195
+ `AgUiChat.questionRenderer`, which has never existed (the property is
196
+ `askUserRenderer`); `ConfirmationOptions`, `ApprovalOptions` and
197
+ `ApprovalRequest` each omitted a member; `quotableSelection` was documented
198
+ with two of its three parameters; the `TOOL_DISPLAY` constants row omitted
199
+ `inline` though the attribute row had it; the Methods list was eight short and
200
+ the Properties list nine, including the deprecated `registerStateHook` and the
201
+ `closeCheckpoints` / `toggleCheckpoints` pair; two rows hid an unescaped `|`
202
+ inside a code span, which splits the row wherever the README is rendered; a
203
+ paragraph with a code block sat in the middle of the attribute table, breaking
204
+ it into two tables and orphaning the seven rows below it; and two links
205
+ pointed at an `#events` section that has never existed.
206
+
207
+ ## [0.29.0] — 2026-08-29
208
+
209
+ ### Added
210
+
211
+ - **Quote a selection into the composer.** Selecting text in the transcript now
212
+ floats a **Quote** offer beside it; taking it drops the selection in as a
213
+ markdown blockquote and leaves the caret on a fresh line under it. Nothing is
214
+ sent -- a quotation is how a question narrows to one part of an answer, so the
215
+ question still has to be written. Quoting appends, so a second quotation is a
216
+ second thing being asked about. Long selections cap at 500 characters.
217
+ `data-quote-selection="false"` turns the offer off; the `quote-selection`
218
+ `::part()` styles it.
219
+
220
+ **The half worth having is the one the transcript cannot reach.** A chat
221
+ mounted beside a table, a diff or a report sits in the surface the user
222
+ actually works in, and a selection made *there* is one no hosted chat can see.
223
+ `offerQuoteInPage()` extends the same select-then-offer gesture to the host's
224
+ own page, or to one region of it, and `quote(text)` is the seam underneath for
225
+ a deliberate trigger like a per-row "ask about this" button.
226
+
227
+ **The page half is a method rather than a documented recipe, and that is the
228
+ correction, not the design.** It shipped first as four lines in the README --
229
+ quote every settled selection -- which appends to the composer on every drag
230
+ the user made to *read*, to copy, or to fix a typo. Worse, it cannot tell a
231
+ selection in the page's prose from one inside the user's own half-typed
232
+ `<input>`: Chrome reports a field's internal selection through
233
+ `document.getSelection()` as an ordinary range over the field's **wrapper**,
234
+ so the text reads back perfectly and nothing about the range says where it
235
+ came from. The only signal is `document.activeElement`. That guard, plus
236
+ skipping the widget's own transcript -- which needs the *event path*, since
237
+ `Node.contains` is false across a shadow boundary -- plus retiring a
238
+ fixed-position affordance on scroll, is three non-obvious guards, and three
239
+ guards is a feature rather than a snippet. `attachQuoteOffer` is exported for
240
+ a host that wants it without the element.
241
+
242
+ **A selection across several elements is not a paragraph, and was treated as
243
+ one twice over.** The offer was hung off the selection's *bounding box*, whose
244
+ centre belongs to no line -- a drag from a form's left column down to a
245
+ full-width line running under the chat panel put the offer on the panel,
246
+ pointing at a line the user had never looked at. It now hangs off the line the
247
+ gesture ended on, or the first line for a keyboard selection. And the text was
248
+ read with `Range.toString()`, which concatenates text nodes and asks nothing
249
+ about CSS: quoting a form returned the values of every `<option>` in a closed
250
+ `<select>`, the markup's own indentation on every line, and a blank `>` for
251
+ every gap between elements -- twenty-four lines of which twelve were empty.
252
+ The read is now what the engine says is rendered (`checkVisibility`), with the
253
+ whitespace a collapsing `white-space` collapses, and preformatted text passed
254
+ through so a quoted code block keeps its shape. Four leading spaces inside a
255
+ blockquote is a markdown code block, so this was a rendering defect and not
256
+ only an untidy one.
257
+
258
+ Reading a selection out of a shadow tree is the part that takes care, and the
259
+ component now does it properly: engines disagree about what
260
+ `document.getSelection()` reports for a selection made inside a shadow root,
261
+ and `getComposedRanges` is used where the engine has it, with the direct read
262
+ behind it. `quotableSelection`, `asQuote` and `MAX_QUOTE_CHARS` are exported
263
+ for a host with the same problem in its own component.
264
+
265
+ - **`approveWithEdits` — edit a gated call's arguments before approving it.**
266
+ AG-UI's resume payload carries `editedArgs` and the protocol gates it on the
267
+ agent's `approveWithEdits` capability; the approval card could not offer it.
268
+ It now shows the call's arguments as editable JSON.
269
+
270
+ **Off by default, and an assertion about the server rather than a
271
+ negotiation.** Capabilities are not on the wire this component reads, so it
272
+ cannot check — and turned on against a server that ignores `editedArgs`, a
273
+ user would edit arguments it silently discards, which is worse than not
274
+ offering.
275
+
276
+ `editedArgs` rides the payload **only when something actually changed**, so a
277
+ server can tell "approved as proposed" from "approved, but like this" without
278
+ diffing what it sent. Unparseable JSON, or JSON that is not an object, keeps
279
+ the card open with the reason on it rather than approving the original behind
280
+ the user's back. Offered only for an interrupt naming a call this component
281
+ holds a card for, since the card is where the arguments still are.
282
+
283
+ New `::part()`s `approval-edit`, `approval-args`, `approval-error`, and three
284
+ strings.
285
+
286
+ - **`formatRelativeTime` — replace the drawer and checkpoint timestamps.** There
287
+ is no `Intl` anywhere in this component, and the locale-neutral `"5m ago"` is
288
+ deliberate: guessing a locale would disagree with the host page, and being
289
+ wrong in a second language is worse than being neutral in one. That is a good
290
+ default and a bad requirement, and a host previously could not even reach the
291
+ formatter to replace it.
292
+
293
+ `relativeTime` and the `RelativeTimeFormatter` type are now exported, and the
294
+ formatter is read at render rather than at connect, so setting it after mount
295
+ works.
296
+
297
+
298
+ - **Server-pushed follow-up suggestions.** A `suggestions` activity draws its
299
+ prompts as chips; clicking one sends it as the user's message. Registered
300
+ skill chips are static and host-configured, so they can say "summarize this"
301
+ but never "want me to update the shipping address too?" after a tool has run.
302
+
303
+ Rides the activity envelope charts already use rather than a `CUSTOM` event,
304
+ which buys persistence for nothing: chips are content, so a reload puts them
305
+ back, and a set pushed under an id already on screen replaces that row.
306
+
307
+ Bounded at 4 prompts of 120 characters, mirroring django-ag-ui's
308
+ `suggestions_activity()`. The producer *raises* past those bounds while this
309
+ side silently drops — deliberate asymmetry, because the producer can report
310
+ the problem and the client cannot. Both numbers live on both sides for the
311
+ reason the chart bounds do: mirroring only some of them leaves exactly the
312
+ silent-drop hole they exist to close.
313
+
314
+ New `::part()`s `suggestions` / `suggestion-chip`, new
315
+ `SUGGESTIONS_ACTIVITY_TYPE`, and `renderSuggestionChips` / `suggestionPrompts`
316
+ exported for a host drawing its own.
317
+
318
+
319
+ - **A message action row — copy, retry, thumbs up/down — under every finished
320
+ assistant message.** There were **zero** message-level actions before this;
321
+ `attachCopyButtons` handled fenced code and nothing else.
322
+
323
+ **Retry is the item that earns it.** History truncates to the most recent user
324
+ message inclusive and the run repeats, so the agent answers the question it was
325
+ asked rather than being told its last answer was wrong. It sits on the **last**
326
+ answer only: re-running an older turn is branching, and for a page-driving
327
+ agent editing a past turn is not neutral, because those turns clicked buttons.
328
+ `retryLastTurn()` is public for a host driving its own message UI.
329
+
330
+ A retried turn **re-runs its tools**, which the previous attempt already ran.
331
+ Confirmation still applies, so a destructive tool asks again unless the user
332
+ waived it this session.
333
+
334
+ Ratings fire `ag-ui-feedback` and **store nothing**: a rating belongs to
335
+ whatever the host already uses for product signal, and a write-only table
336
+ inside a chat widget is a schema nobody reads.
337
+
338
+ New `::part()`s `message-actions` / `message-action` (plus
339
+ `message-action-retry`, `-copy`, `-up`, `-down`), new `FEEDBACK_EVENT` and
340
+ `FeedbackDetail`, and `attachMessageActions` / `messageActionBar` exported for
341
+ a host assembling its own transcript.
342
+
343
+ - **A dropped run has a way back.** `ConnectionLostError` rendered a dead
344
+ "Connection lost" bubble; only *uploads* had retry. The failed bubble now
345
+ carries the same action row, with Retry and Copy and no rating — error text is
346
+ what people paste into a bug report, while "the connection dropped" is not a
347
+ statement about answer quality and mixing it into feedback makes that signal
348
+ say less.
349
+
350
+ **Kept as an error rather than demoted to a run notice**, which is what was
351
+ originally proposed. `renderRunNotice`'s contract is that a notice "never
352
+ settles, takes no action, and carries no controls" and is "distinct from an
353
+ error, which is a failure". This is a failure that now needs a control, so the
354
+ taxonomy already had the answer.
355
+
356
+
357
+ - **"Always allow" on the confirmation card — a session-scoped waiver, per tool
358
+ name.** Confirmation was binary and permanent: `autoConfirm` is
359
+ all-or-nothing and `confirmPredicate` has no memory, so a tool the user
360
+ approves every single time keeps asking every single time.
361
+
362
+ A prompt approved nearly every time is not a decision, it is a speed bump, and
363
+ the reflex it trains is what makes the rare refusal easy to miss. Anthropic
364
+ published that users approve **~93%** of Claude Code permission prompts
365
+ manually and called interactive confirmation *"behaviorally unreliable as a
366
+ sole safety mechanism"* on exactly that basis. The waiver exists so the
367
+ prompts that remain still mean something.
368
+
369
+ **The button appears only where the `x-destructive` default is what gated the
370
+ call.** `confirmPredicate` is documented as authoritative, so letting one
371
+ click retire it would silently defeat a host policy — and because the offer
372
+ and the allowlist sit on the same path, there is no dead button either. The
373
+ waiver is per tool name and per element, held in memory and never persisted: a
374
+ session decision that outlived the tab would be a permanent grant made by one
375
+ click, which is what `autoConfirm` already exists to say deliberately.
376
+
377
+ New `::part()` `confirm-always`, new string `confirmAlways`, and
378
+ `requestConfirmation` gains an `onAlwaysAllow` option — its presence is what
379
+ renders the button, so the affordance can never appear with nothing listening.
380
+ The card still resolves `true`: the waiver is *in addition to* approving this
381
+ call, not instead of it.
382
+
383
+
384
+ - **`ag-ui-invalidate` and `RunFinishedDetail.invalidated` — the agent tells your
385
+ page what it moved.** The agent writes and the page still shows the old list.
386
+ `ag-ui-run-finished` already said *something* moved, so this is **precision on
387
+ a channel that ships**: the server names the resources and a host refetches
388
+ only those.
389
+
390
+ ```js
391
+ chat.addEventListener("ag-ui-invalidate", (e) => {
392
+ if (formIsDirty()) return showBanner("This data changed.", e.detail.keys);
393
+ refetch(e.detail.keys);
394
+ });
395
+ ```
396
+
397
+ **Do not reload on this.** The user was probably typing, and an
398
+ agent-triggered reload into a live form destroys unsaved input with no
399
+ explanation the user can see. The component never reloads by itself and offers
400
+ no option that would.
401
+
402
+ **Two dispatches, deliberately.** The live event fires as each announcement
403
+ arrives, which is what makes a long multi-step run feel live -- the list
404
+ refreshes as the third of eight writes lands. `invalidated` on the existing
405
+ run-finished detail carries the same keys de-duplicated at the end, so a host
406
+ already listening there upgrades by reading one extra field:
407
+
408
+ ```js
409
+ if (detail.invalidated.length > 0) refetchOnly(detail.invalidated);
410
+ else if (detail.tools.some((t) => t.side === "server")) refetchEverything();
411
+ ```
412
+
413
+ That `else` is the whole compatibility story. A new server with an old client
414
+ has its `CUSTOM` event ignored and still gets the coarse refetch; an old server
415
+ with a new client leaves `invalidated` empty and falls through to the same
416
+ branch. Nothing negotiates, which is what makes this shippable across repos
417
+ with independent release cadences.
418
+
419
+ **Keys are opaque and matching is exact.** `orders/42` does not imply `orders`
420
+ -- a prefix rule would be this component guessing at a scheme it does not own,
421
+ and `orders/1` would match `orders/11`. A server that wants the collection
422
+ refreshed names it. Hosts may match hierarchically in their own vocabulary,
423
+ where the scheme is known.
424
+
425
+ Built on the `CUSTOM` carrier rather than `ACTIVITY_SNAPSHOT`, because an
426
+ invalidation is an imperative: activities are materialised into messages,
427
+ persisted and replayed on every thread restore, and an invalidation replayed on
428
+ every thread load is a refetch storm. Nothing is rendered or persisted, and
429
+ that absence is asserted. Every other `CUSTOM` name still arrives on
430
+ `ag-ui-custom` unchanged, and an invalidation does **not** also fire it -- a
431
+ host listening to both would otherwise refetch twice for one announcement.
432
+
433
+ Requires django-ag-ui 0.51 to have anything to receive.
434
+
435
+ - **`registerActivityRenderer` — `activityType` is an open set now, not two
436
+ branches.** AG-UI leaves exactly two payload names an open string the protocol
437
+ does not enumerate, and the component treated neither as open: `chart` and
438
+ `compaction` were handled and everything else fell through a bare `return`,
439
+ with no host seam and no record that anything had arrived. The server could
440
+ only say things the client had been compiled to understand, in a protocol
441
+ designed so it can say more.
442
+
443
+ ```js
444
+ chat.registerActivityRenderer({
445
+ type: "build_status",
446
+ render: (content) => {
447
+ const el = document.createElement("div");
448
+ el.textContent = `Build ${content.status}`;
449
+ return el;
450
+ },
451
+ });
452
+ ```
453
+
454
+ `render` carries the same contract as a client tool's `render`, and for the
455
+ same reason rather than by analogy: activities are materialised into
456
+ `role: "activity"` messages, persisted with the transcript and re-fired on
457
+ restore, so a renderer that writes to the page instead of returning DOM fires
458
+ again on every thread load.
459
+
460
+ **Both built-ins go through the registry**, which is the test that the seam is
461
+ real — a built-in needing a privileged branch would mean the seam cannot
462
+ express what the component itself needs. Registering either name replaces it.
463
+
464
+ Compaction gains two things by going through the seam: a reload puts the
465
+ notice back (it is content, and content replays), and a server redrawing under
466
+ the same `messageId` replaces it instead of leaving two notices standing for
467
+ one event.
468
+
469
+ - **`unhandledActivityTypes`** — the activity types that arrived with nobody
470
+ registered to draw them. Deliberately the only trace: ignoring an unknown name
471
+ is the protocol's own answer, and a warning would fire on every
472
+ forward-compatible server, but "nothing happened and nothing was said" is
473
+ impossible to debug. Note `chart` is listed until `enableCharts(["activity"])`
474
+ is called, which is the honest answer to "I pushed a chart and nothing
475
+ happened".
476
+
477
+ - **`ag-ui-custom`** (`CUSTOM_AGENT_EVENT`, detail `CustomAgentDetail`) — the
478
+ other open carrier, which had no implementation at all. An AG-UI `CUSTOM`
479
+ event is forwarded to the host page whole and uninterpreted, `bubbles` and
480
+ `composed` like every other event the element dispatches.
481
+
482
+ **It is deliberately not rendered, persisted or replayed.** That asymmetry is
483
+ the rule for choosing between the two carriers: `ACTIVITY_SNAPSHOT` is content
484
+ and has a place in the conversation, `CUSTOM` is an imperative with no meaning
485
+ once acted on, and replaying "refetch the board" on every thread load would be
486
+ a bug rather than a feature.
487
+
488
+ **Note:** pydantic-ai emits its own compaction activity under
489
+ `pydantic_ai_compaction`, by a different route than the harness sink this
490
+ package renders as `compaction`. The registry deliberately does **not** answer
491
+ to both names — doing so would give a deployment running both two notices for
492
+ one event. It shows up in `unhandledActivityTypes` instead, so a host that
493
+ wants it can register it and decide about duplication itself.
494
+
495
+ ### Fixed
496
+
497
+ - **The message action row sat closer to the block below it than to the message
498
+ it acts on.** The answer group is a flex column with a 10px gap, so the row's
499
+ `margin-top: 6px` **added** to that gap instead of tightening it: 16px above,
500
+ 10px below, and the buttons read as belonging to whatever card followed. A
501
+ negative margin pulls the row back inside the gap — 4px above, 10px below.
502
+
503
+ Found by driving the demo, not by any assertion: every existing check was
504
+ satisfied by the broken spacing. Ordered correctly, contained correctly,
505
+ visible — and grouped with the wrong thing. The new browser case fails against
506
+ the previous stylesheet with `expected 16 to be less than 10`.
507
+
508
+
509
+ - **The action row is a sibling of the message bubble, not a child.** Inside, the
510
+ buttons join the bubble's `textContent` — which is what Copy reads, what
511
+ history persists, and what every existing assertion about a message's text
512
+ compares against. An answer would have been copied back carrying the glyphs of
513
+ the buttons that copied it. Caught by twelve existing tests failing at once,
514
+ which is the check working.
515
+
516
+
517
+ - **The confirmation card's action row could push a button outside the card.**
518
+ It was a `justify-content: flex-end` flex line with no wrap, built when there
519
+ were two buttons. Measured in Chromium at a 260px panel: the three buttons want
520
+ 71 + 107 + 81 plus two 8px gaps against a 200px row, and the overflow went off
521
+ the **left** edge — Cancel rendered, styled and reporting its label, 27px
522
+ outside the box the user can see and hit.
523
+
524
+ `flex-wrap: wrap` lets the row take a second line instead, as the checkpoint
525
+ row already does. Found by measuring rather than by review: happy-dom lays out
526
+ no boxes and answers 0 for every width, so it called the overflowing row and
527
+ the fitting one the same pass. The new browser test fails against the previous
528
+ stylesheet at that width and passes at the two wider ones, which is the honest
529
+ shape of the bug.
530
+
531
+
532
+ - **A server that replaced the conversation did it in silence.** AG-UI's
533
+ `MESSAGES_SNAPSHOT` is applied by `@ag-ui/client` before any subscriber runs,
534
+ so `agent.messages` **is** the server's list by the time the host sees
535
+ anything — and the run loop persists `agent.messages`. The replacement
536
+ therefore reached the conversation store either way, while the DOM was
537
+ untouched. Nothing looked wrong until a reload, in a later session, served a
538
+ transcript the user had never seen, with no event that could be correlated to
539
+ it. That is not reported as a bug; it is reported as "the chat lost my
540
+ messages".
541
+
542
+ The store still follows the server, deliberately — the server is authoritative
543
+ about what the conversation *is*, and it would follow it regardless. What
544
+ changes is that the replacement is now announced in the transcript.
545
+
546
+ **Re-rendering from the snapshot was the other candidate and is declined.** A
547
+ snapshot can land mid-run, and rebuilding the transcript then destroys the
548
+ in-flight run's own state: the streaming bubble, the open answer group, and
549
+ every tool card keyed by call id, some still waiting on results. This is the
550
+ same answer the same question already got for compaction.
551
+
552
+ - **One string-valued point silently dropped a whole chart that was on screen.**
553
+ `chartSpecFrom` returns `null` for the entire spec when any point is not a
554
+ finite number — not the offending series, the whole chart — and the pushed
555
+ activity path then removed any chart already rendered and returned, with no
556
+ `console` call anywhere on that path. The triggering shape is not exotic: a
557
+ Django `Sum` over a `DecimalField` serialises as a JSON string, and money is
558
+ the most common chart input there is.
559
+
560
+ **Removing it is still right** and is unchanged: leaving retracted numbers on
561
+ screen reading as current is worse, and a reload drops the chart anyway
562
+ because the *stored* content is the version that could not be drawn. Live and
563
+ reload should agree. What was wrong was doing it silently. The path now warns
564
+ on the console naming the likely cause, and posts a notice in the transcript
565
+ when a chart that had been drawn is taken away.
566
+
567
+ Nothing is coerced, client-side or server-side. `django-ag-ui` already raises
568
+ at construction and names `Decimal` on purpose, and guessing whether
569
+ `"1234.50"` lost precision upstream is not a favour worth doing.
570
+
571
+
572
+ - **A screen reader re-announced the whole answer tens of times per turn.** The
573
+ transcript carried both `role="log"` and an explicit `aria-live="polite"`,
574
+ and the streaming bubble's `innerHTML` is replaced inside it on every
575
+ animation frame. `role="log"` already implies polite announcement and the
576
+ default `aria-relevant` includes text additions, so every frame was a fresh
577
+ announcement of the answer so far. That is not merely unhelpful; it is
578
+ hostile.
579
+
580
+ The transcript is demoted out of live-region duty with an explicit
581
+ `aria-live="off"`, which overrides the value the role implies. **The role
582
+ stays** -- the log semantics are what let the transcript be navigated as one,
583
+ and only the announcing was the defect.
584
+
585
+ A separate visually-hidden status region takes over, and roughly four short
586
+ statuses land per turn: responding, answered, a decision is waiting and how
587
+ many, stopped, failed. Five new `strings` keys (`announceResponding`,
588
+ `announceAnswerReady`, `announceAwaitingDecision`, `announceStopped`,
589
+ `announceFailed`) make all of them translatable. The answer's own words never
590
+ reach it, and neither does an exception's.
591
+
592
+ - **The transcript could not be read while anything streamed.** Eleven separate
593
+ sites assigned `scrollTop = scrollHeight` unconditionally, and nothing in the
594
+ element listened for a `scroll` event -- so nothing knew the reader had
595
+ scrolled up, and scrolling back during a run was undone by the next token.
596
+
597
+ All eleven now follow the foot only while the reader is already there. A
598
+ **jump-to-latest** button (`jump-latest` part, `jumpToLatest` string) appears
599
+ once they have scrolled away *and* have since missed something; scrolling up
600
+ through a settled transcript is not a reason to nag. A user's own message
601
+ still goes to the foot -- pressing Send is as deliberate as pressing the
602
+ button. `overflow-anchor: none` stops the browser's own scroll anchoring
603
+ competing for the same job.
604
+
605
+ - **A strict-CSP host got an unstyled widget.** The stylesheet was injected as
606
+ an inline `<style>`, which a host with a strict `style-src` and no
607
+ `'unsafe-inline'` drops silently: the component mounted, functioned, and
608
+ rendered with no styling at all, and nothing in the console pointed at why.
609
+ It is attached with `adoptedStyleSheets` instead, which carries no
610
+ inline-style origin.
611
+
612
+ The sheet is per instance rather than shared at module scope. A shared one
613
+ would also stop re-parsing the stylesheet once per mount, but a module-level
614
+ singleton is what this package forbids, and per instance is no worse than the
615
+ `<style>` it replaces.
616
+
617
+ All three came out of a **survey of how other products build chat**, not a
618
+ review. Two review passes and a full audit wave went over this component
619
+ without surfacing any of them, because each is invisible unless you ask how
620
+ everyone else does it.
621
+
10
622
  ## [0.28.0] — 2026-08-26
11
623
 
12
624
  ### Added
@@ -1950,7 +2562,9 @@ hosts that both arrange the page the way it expects.
1950
2562
  ### Notes
1951
2563
  - First release — exercising the automated npm OIDC publish pipeline end-to-end.
1952
2564
 
1953
- [Unreleased]: https://github.com/Artui/ag-ui-web-component/compare/v0.28.0...HEAD
2565
+ [Unreleased]: https://github.com/Artui/ag-ui-web-component/compare/v0.30.0...HEAD
2566
+ [0.30.0]: https://github.com/Artui/ag-ui-web-component/compare/v0.29.0...v0.30.0
2567
+ [0.29.0]: https://github.com/Artui/ag-ui-web-component/compare/v0.28.0...v0.29.0
1954
2568
  [0.28.0]: https://github.com/Artui/ag-ui-web-component/compare/v0.27.0...v0.28.0
1955
2569
  [0.27.0]: https://github.com/Artui/ag-ui-web-component/compare/v0.26.1...v0.27.0
1956
2570
  [0.26.1]: https://github.com/Artui/ag-ui-web-component/compare/v0.26.0...v0.26.1