@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
@@ -1,20 +1,29 @@
1
1
  import { randomUUID } from "@ag-ui/client";
2
2
  import type { Context, Interrupt, Message, Tool } from "@ag-ui/core";
3
3
  import {
4
+ ANNOUNCE_CLEAR_MS,
4
5
  ATTACHMENT_EVENT,
5
6
  CHART_ACTIVITY_TYPE,
6
7
  COMPACTION_ACTIVITY_TYPE,
8
+ CUSTOM_AGENT_EVENT,
7
9
  DEFAULT_ATTACHMENT_MAX_BYTES,
10
+ FEEDBACK_EVENT,
8
11
  ICON_ATTACH,
9
12
  ICON_LAUNCHER,
10
13
  ICON_SEND,
11
14
  ICON_STOP,
15
+ INVALIDATE_CUSTOM_NAME,
16
+ INVALIDATE_EVENT,
12
17
  LOAD_CAPABILITY_TOOL,
18
+ MAX_TOOL_ROUNDS,
19
+ MESSAGE_ACTIONS,
13
20
  MESSAGE_ROLE,
14
21
  READ_PAGE_TOOL,
15
22
  RUN_FINISHED_EVENT,
16
23
  STATE_EVENT,
24
+ SUBAGENT_CUSTOM_NAME,
17
25
  SUBMIT_EVENT,
26
+ SUGGESTIONS_ACTIVITY_TYPE,
18
27
  TOGGLE_EVENT,
19
28
  TOOL_CALL_STATUS,
20
29
  TOOL_DISPLAY,
@@ -47,12 +56,20 @@ import { chartSpecFrom } from "../ui/chart_spec_from.js";
47
56
  import { CHART_TOOL_NAME, createChartTool } from "../ui/chart_tool.js";
48
57
  import { CheckpointMenu, type CheckpointVerb } from "../ui/checkpoint_menu.js";
49
58
  import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
59
+ import {
60
+ attachMessageActions,
61
+ messageActionBar,
62
+ messageActionButton,
63
+ } from "../ui/message_actions.js";
64
+ import { attachQuoteOffer, type PageQuoteOffer } from "../ui/page_quote_offer.js";
50
65
  import { prettifyToolName } from "../ui/prettify_tool_name.js";
51
66
  import {
52
67
  type QuestionRenderer,
53
68
  type QuestionRequest,
54
69
  requestQuestion,
55
70
  } from "../ui/question_card.js";
71
+ import { asQuote, quotableSelection } from "../ui/quote_selection.js";
72
+ import type { RelativeTimeFormatter } from "../ui/relative_time.js";
56
73
  import { renderMarkdown } from "../ui/render_markdown.js";
57
74
  import {
58
75
  createResizeHandle,
@@ -63,10 +80,18 @@ import {
63
80
  import { wrapWords } from "../ui/reveal_words.js";
64
81
  import { renderRunNotice } from "../ui/run_notice.js";
65
82
  import { SkillsMenu } from "../ui/skills_menu.js";
83
+ import { createStickToBottom, type StickToBottom } from "../ui/stick_to_bottom.js";
66
84
  import { STYLES } from "../ui/styles.js";
85
+ import { SubAgentPanel } from "../ui/subagent_panel.js";
86
+ import { subAgentUpdate } from "../ui/subagent_update.js";
87
+ import { renderSuggestionChips } from "../ui/suggestion_chips.js";
67
88
  import { ThoughtsBlock } from "../ui/thoughts_block.js";
68
89
  import { ThreadDrawer } from "../ui/thread_drawer.js";
69
- import { ToolCallCard, type ToolDisplayMode } from "../ui/tool_call_card.js";
90
+ import {
91
+ ToolCallCard,
92
+ type ToolDisplayMode,
93
+ type ToolPayloadFormatter,
94
+ } from "../ui/tool_call_card.js";
70
95
  import { DEFAULT_UI_STRINGS, mergeUiStrings, type UiStrings } from "../ui/ui_strings.js";
71
96
  import { VoiceInput } from "../ui/voice_input.js";
72
97
  import {
@@ -109,6 +134,13 @@ export interface AttachmentsDetail {
109
134
  }
110
135
 
111
136
  /** `detail` shape of the {@link STATE_EVENT} CustomEvent. */
137
+ /** {@link FEEDBACK_EVENT} detail: what was rated, and how. */
138
+ export interface FeedbackDetail {
139
+ /** The rated message's text, as rendered. */
140
+ readonly content: string;
141
+ readonly rating: "up" | "down";
142
+ }
143
+
112
144
  export interface StateDetail {
113
145
  readonly state: Readonly<Record<string, unknown>>;
114
146
  }
@@ -128,6 +160,83 @@ export interface ToolRun {
128
160
  export interface RunFinishedDetail {
129
161
  /** In settle order. Empty when the interaction called no tools. */
130
162
  readonly tools: readonly ToolRun[];
163
+ /**
164
+ * Every key announced during the interaction, de-duplicated, first-seen order.
165
+ *
166
+ * **This is the field that makes adoption one line** for a host already
167
+ * listening here, and the `else` is the whole compatibility story:
168
+ *
169
+ * ```js
170
+ * if (detail.invalidated.length > 0) refetchOnly(detail.invalidated);
171
+ * else if (detail.tools.some((t) => t.side === "server")) refetchEverything();
172
+ * ```
173
+ *
174
+ * Empty against a server that announces nothing, so an old server and a new
175
+ * client fall through to the coarse refetch that shipped before either.
176
+ */
177
+ readonly invalidated: readonly string[];
178
+ }
179
+
180
+ /**
181
+ * Draw one activity, from its content alone.
182
+ *
183
+ * The contract is {@link ClientTool.render}'s, and for the same reason rather
184
+ * than by analogy. An activity is materialised into a `role: "activity"`
185
+ * message, persisted with the transcript, and re-fired on every restore -- so a
186
+ * renderer that writes to the page instead of returning DOM fires again on
187
+ * every thread load, which is exactly the bug the tool registry's purity rule
188
+ * was written to make unmakeable.
189
+ *
190
+ * - a pure function of `content` -- no host state, no network, no clock;
191
+ * - deterministic, so a reload reproduces what was there before;
192
+ * - free of effects outside the node it returns, which the component places.
193
+ *
194
+ * Return `null` for content that says nothing worth drawing. Anything already
195
+ * drawn under that message id is then removed: live and reload should agree,
196
+ * and the stored content is the version that could not be drawn.
197
+ */
198
+ export type ActivityRenderer = (content: unknown) => Node | null;
199
+
200
+ /** One `activity_type` a host can draw. See {@link AgUiChat.registerActivityRenderer}. */
201
+ export interface ActivityRegistration {
202
+ /**
203
+ * The AG-UI `activity_type` this draws, matched exactly.
204
+ *
205
+ * An open string the protocol does not enumerate -- which is the whole reason
206
+ * this is a registry rather than a branch.
207
+ */
208
+ readonly type: string;
209
+ readonly render: ActivityRenderer;
210
+ /**
211
+ * Shown in the transcript when something already drawn under this type stops
212
+ * being renderable. Omit for an activity whose disappearance needs no
213
+ * explanation.
214
+ */
215
+ readonly removedNotice?: string;
216
+ }
217
+
218
+ /** `detail` shape of the {@link CUSTOM_AGENT_EVENT} CustomEvent. */
219
+ export interface CustomAgentDetail {
220
+ /** The `CUSTOM` event's `name`, verbatim. An open string; never interpreted here. */
221
+ readonly name: string;
222
+ /** Its `value`, verbatim and unparsed. `unknown` because the protocol says nothing about it. */
223
+ readonly value: unknown;
224
+ }
225
+
226
+ /** `detail` shape of the {@link INVALIDATE_EVENT} CustomEvent. */
227
+ export interface InvalidateDetail {
228
+ /**
229
+ * The resources that moved, as the server named them.
230
+ *
231
+ * **Opaque strings, and matching is exact.** `orders/42` does not imply
232
+ * `orders` -- a prefix rule would be this component guessing at a scheme it
233
+ * does not own, and `orders/1` would match `orders/11`. A server that wants
234
+ * the collection refreshed names it. Your own matching may be hierarchical,
235
+ * because in your vocabulary the scheme is known.
236
+ */
237
+ readonly keys: readonly string[];
238
+ /** What caused the write -- usually the tool's name. `null` when unstated. */
239
+ readonly reason: string | null;
131
240
  }
132
241
 
133
242
  /** `detail` shape of the {@link TOGGLE_EVENT} CustomEvent. */
@@ -190,6 +299,9 @@ const SIZE_KEY = "ag-ui-chat:size";
190
299
  /** Per-tab persistence key for the built-in theme toggle. */
191
300
  const THEME_KEY = "ag-ui-chat:theme";
192
301
 
302
+ /** Pixels between a selection and the offer to quote it. */
303
+ const QUOTE_GAP = 6;
304
+
193
305
  /**
194
306
  * Storage namespaces already spoken for in this document.
195
307
  *
@@ -263,6 +375,23 @@ export class AgUiChat extends HTMLElement {
263
375
  */
264
376
  allowImages = false;
265
377
 
378
+ /**
379
+ * Replace the relative timestamps in the thread drawer and the checkpoint
380
+ * panel -- `"5m ago"`, `"2d ago"` -- with the host's own formatting.
381
+ *
382
+ * The built-in is locale-neutral on purpose: there is no `Intl` anywhere in
383
+ * this component, so it never disagrees with the page it is embedded in by
384
+ * guessing a locale. That is a good default and a bad requirement, which is
385
+ * what this is for.
386
+ *
387
+ * ```js
388
+ * const rtf = new Intl.RelativeTimeFormat("de", { numeric: "auto" });
389
+ * chat.formatRelativeTime = (ts) =>
390
+ * rtf.format(Math.round((ts - Date.now()) / 60000), "minute");
391
+ * ```
392
+ */
393
+ formatRelativeTime: RelativeTimeFormatter | null = null;
394
+
266
395
  /** When true, destructive tools execute without a confirmation modal. */
267
396
  autoConfirm = false;
268
397
 
@@ -290,6 +419,23 @@ export class AgUiChat extends HTMLElement {
290
419
  */
291
420
  approvalRenderer: ApprovalRenderer | null = null;
292
421
 
422
+ /**
423
+ * Let the user edit a gated call's arguments before approving it.
424
+ *
425
+ * Off by default and **an assertion about your server**, not a negotiation:
426
+ * AG-UI carries `editedArgs` in the resume payload and gates it on the
427
+ * agent's own `approveWithEdits` capability, which this component never sees
428
+ * -- capabilities are not on the wire it reads. So the host says whether its
429
+ * agent honours them. Turned on against a server that does not, the user
430
+ * would edit arguments it silently discards, which is worse than not
431
+ * offering.
432
+ *
433
+ * Only affects calls whose arguments are known here: an interrupt names a
434
+ * `toolCallId`, and the tool card for that call is where the arguments still
435
+ * are. An interrupt naming no card gets the plain approve/deny.
436
+ */
437
+ approveWithEdits = false;
438
+
293
439
  /**
294
440
  * Optional per-call confirmation predicate. When set it is authoritative,
295
441
  * deciding from the tool name and args whether this particular call needs
@@ -395,6 +541,31 @@ export class AgUiChat extends HTMLElement {
395
541
  */
396
542
  toolSummaries: Record<string, string> = {};
397
543
 
544
+ /**
545
+ * Optional presentation hook for the two payload regions of a tool-call card
546
+ * -- the arguments and the result. Unset (the default) leaves both
547
+ * pretty-printed as JSON.
548
+ *
549
+ * The seam exists because a wide result has no good rendering as JSON: a
550
+ * thirty-field row is a wall of text where the host wanted a table, or a
551
+ * sentence. `ClientTool.render` cannot answer it -- it is handed the
552
+ * *arguments* only, and a server-side tool has no `ClientTool` at all, so the
553
+ * result region was the one part of the transcript a host could not reach.
554
+ *
555
+ * **Presentation, not translation.** The card and the model already read
556
+ * separate copies of a tool result: the model's is maintained by
557
+ * `@ag-ui/client` from the same event and persisted with the history, and the
558
+ * card has always shown that string reformatted. So a formatter changes what
559
+ * the person reads and nothing the agent reads -- which makes restyling safe
560
+ * and *rewording* a way to make the card disagree with the prose beside it.
561
+ * Rename a value on the server, where it reaches both.
562
+ *
563
+ * Read at render time rather than captured, so a host that sets it from a
564
+ * framework effect after the first card still formats the results that settle
565
+ * afterwards. See {@link ToolPayloadFormatter}.
566
+ */
567
+ formatToolPayload: ToolPayloadFormatter | null = null;
568
+
398
569
  /**
399
570
  * Localizable UI strings — a partial override merged over the English
400
571
  * {@link DEFAULT_UI_STRINGS}. Resolved once on connect (so set it before the
@@ -449,13 +620,32 @@ export class AgUiChat extends HTMLElement {
449
620
  readonly #toolRegistry = new ClientToolRegistry();
450
621
  /** Tool-call cards awaiting execution, keyed by call id. */
451
622
  readonly #toolCards = new Map<string, ToolCallCard>();
623
+ /**
624
+ * The live delegation panels, keyed by the **parent's** `delegate_task` call
625
+ * id — which is what the wire keys a sub-agent's progress on, so this map and
626
+ * {@link #toolCards} answer to the same key.
627
+ *
628
+ * Kept beside the cards rather than on them, so a card stays a card: the tool
629
+ * card holds the slot and this holds what went into it, the same division the
630
+ * approval prompt already uses.
631
+ */
632
+ readonly #subagentPanels = new Map<string, SubAgentPanel>();
452
633
  /**
453
634
  * Call ids whose card was already settled from a streamed server-side result
454
635
  * (`TOOL_CALL_RESULT`), so the post-run executeTool sweep doesn't overwrite
455
636
  * the real output with the generic "executed on the server" fallback.
456
637
  */
457
638
  /** Whether a server-pushed chart activity is drawn. Off unless asked for. */
458
- #chartActivity = false;
639
+ /**
640
+ * Which `activity_type`s this element can draw, by name.
641
+ *
642
+ * A registry rather than a branch because `activity_type` is an open string
643
+ * the protocol does not enumerate. The two built-ins go through it like any
644
+ * host registration, which is the test that the seam is real.
645
+ */
646
+ readonly #activityRenderers = new Map<string, ActivityRegistration>();
647
+ /** Types that arrived with nobody registered to draw them. See {@link unhandledActivityTypes}. */
648
+ readonly #unhandledActivityTypes = new Set<string>();
459
649
 
460
650
  /** Card elements by call id, so a rendering handler can find its own card. */
461
651
  readonly #cardElements = new Map<string, HTMLElement>();
@@ -470,7 +660,77 @@ export class AgUiChat extends HTMLElement {
470
660
  * Spans tool rounds and an approval interrupt; cleared when the event fires.
471
661
  */
472
662
  #runTools: { readonly id: string; readonly name: string }[] = [];
663
+ /**
664
+ * Keys announced during this interaction, de-duplicated in first-seen order.
665
+ *
666
+ * Per element, never module-level: a second mounted chat is a second run, and
667
+ * sharing this would tell one page to refetch on the other's writes. Reset by
668
+ * {@link AgUiChat.#dispatchRunFinished}, which is the one place that has read
669
+ * it.
670
+ */
671
+ /**
672
+ * Tool names the user waived confirmation for, for the life of this element.
673
+ *
674
+ * Per instance and never persisted: a session decision that outlived the tab
675
+ * would be a permanent grant made by one click, which is the thing
676
+ * `autoConfirm` already exists to say deliberately. Cleared with the element.
677
+ */
678
+ readonly #sessionApproved = new Set<string>();
679
+
680
+ /**
681
+ * The one action row currently carrying Retry, if any.
682
+ *
683
+ * Retry belongs to the **last** turn only: re-running an older one is
684
+ * branching, and for a page-driving agent editing a past turn is not neutral
685
+ * -- those turns clicked buttons, and re-running turn 3 does not un-save what
686
+ * turn 5 saved. Holding a single owner is what keeps exactly one offer on
687
+ * screen without per-bubble bookkeeping.
688
+ */
689
+ #retryOwner: HTMLElement | null = null;
690
+
691
+ #runInvalidated = new Set<string>();
473
692
  readonly #root: ShadowRoot;
693
+ /** Screen-reader-only status region -- see {@link AgUiChat.#announce}. */
694
+ readonly #announcer = document.createElement("div");
695
+ /** Return-to-foot affordance, shown only once something has been missed. */
696
+ readonly #jumpButton = document.createElement("button");
697
+ /**
698
+ * Offer to quote the current selection, floated beside it.
699
+ *
700
+ * Shares {@link AgUiChat.#messagesWrap} with the jump button for the same
701
+ * reason: it is positioned against the transcript, and must not scroll away
702
+ * with the words it is pointing at.
703
+ */
704
+ readonly #quoteButton = document.createElement("button");
705
+ /** What {@link AgUiChat.#quoteButton} would quote, while it is showing. */
706
+ #quoting = "";
707
+ /** The host-page offer, while one is attached; see {@link AgUiChat.offerQuoteInPage}. */
708
+ #pageQuote: PageQuoteOffer | null = null;
709
+ /**
710
+ * Positioning context for {@link AgUiChat.#jumpButton}.
711
+ *
712
+ * The button cannot live in the scrolling list -- it would scroll away with
713
+ * the content it is offering to scroll to -- and it cannot be positioned
714
+ * against the panel either: the panel's foot is below the composer, the skill
715
+ * chips and the footer, so `bottom` measured from there lands the button on
716
+ * top of the composer rather than over the transcript. This wrapper is the
717
+ * only box whose foot *is* the transcript's foot.
718
+ */
719
+ readonly #messagesWrap = document.createElement("div");
720
+ /** Follows the foot of the transcript, and stops when the reader scrolls away. */
721
+ #scroller!: StickToBottom;
722
+ /** Pending clear of {@link AgUiChat.#announcer}; see why it is cleared at all. */
723
+ #announceTimer: ReturnType<typeof setTimeout> | null = null;
724
+ /**
725
+ * Whether this turn already announced how it ended.
726
+ *
727
+ * `onSettled` is the terminal guarantee and fires however the run ended, so
728
+ * it is the only place that can promise the user hears *something*. But a
729
+ * stopped or failed run has already said the truer thing from `onCancelled`
730
+ * or `onError`, and "assistant answered" after "response stopped" is worse
731
+ * than silence.
732
+ */
733
+ #announcedOutcome = false;
474
734
  readonly #chat: HTMLDivElement;
475
735
  readonly #messages: HTMLDivElement;
476
736
  readonly #input: HTMLTextAreaElement;
@@ -590,6 +850,33 @@ export class AgUiChat extends HTMLElement {
590
850
  this.#launcher = document.createElement("button");
591
851
  this.#badge = document.createElement("span");
592
852
  this.#emptyWrap = document.createElement("div");
853
+ // The compaction notice is a registration, not a branch -- and going through
854
+ // the seam earns it two things it did not have: a reload puts it back (it is
855
+ // content, and content replays), and a server redrawing under the same id
856
+ // replaces it rather than adding a second notice for one event.
857
+ this.registerActivityRenderer({
858
+ type: COMPACTION_ACTIVITY_TYPE,
859
+ render: (content) => {
860
+ const removed = compactionRemoved(content);
861
+ return removed === null
862
+ ? null
863
+ : renderRunNotice(
864
+ "\u{1F5DC}",
865
+ this.#strings.historyCompacted.replace("{count}", String(removed)),
866
+ "compaction",
867
+ );
868
+ },
869
+ });
870
+ // Follow-up chips, registered through the same seam for the same reasons:
871
+ // a reload puts them back, and a server pushing a new set under a new id
872
+ // supersedes the old one rather than leaving two offers on screen.
873
+ this.registerActivityRenderer({
874
+ type: SUGGESTIONS_ACTIVITY_TYPE,
875
+ render: (content) =>
876
+ renderSuggestionChips(content, this.#strings, (prompt) => {
877
+ void this.sendMessage(prompt);
878
+ }),
879
+ });
593
880
  this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
594
881
  this.#drawer = new ThreadDrawer({
595
882
  onSelect: (threadId) => {
@@ -677,6 +964,9 @@ export class AgUiChat extends HTMLElement {
677
964
  /** Load the checkpoint panel with the runs that can actually be continued. */
678
965
  async #refreshCheckpoints(): Promise<void> {
679
966
  const index = this.#runs();
967
+ // Pushed at render rather than at connect: `formatRelativeTime` is a
968
+ // property, so a host may set it long after the element mounted.
969
+ this.#checkpoints.setRelativeTimeFormatter(this.formatRelativeTime);
680
970
  this.#checkpoints.setRuns(index === null ? [] : await index.continuable());
681
971
  }
682
972
 
@@ -930,7 +1220,7 @@ export class AgUiChat extends HTMLElement {
930
1220
  });
931
1221
  this.#confirmAbort = null;
932
1222
  this.#updateEmptyState();
933
- this.#messages.scrollTop = this.#messages.scrollHeight;
1223
+ this.#scroller.follow();
934
1224
  return answer;
935
1225
  }
936
1226
 
@@ -1242,8 +1532,17 @@ export class AgUiChat extends HTMLElement {
1242
1532
  this.#claimedNs = null;
1243
1533
  }
1244
1534
  this.#cancelRun();
1535
+ // The page offer listens on the host's document, not on anything of ours,
1536
+ // so nothing else would ever take it down.
1537
+ this.#pageQuote?.detach();
1538
+ this.#pageQuote = null;
1245
1539
  this.#attachTray?.dispose();
1246
1540
  this.#voice?.dispose();
1541
+ this.#scroller.dispose();
1542
+ if (this.#announceTimer !== null) {
1543
+ clearTimeout(this.#announceTimer);
1544
+ this.#announceTimer = null;
1545
+ }
1247
1546
  }
1248
1547
 
1249
1548
  /**
@@ -1369,6 +1668,162 @@ export class AgUiChat extends HTMLElement {
1369
1668
  this.#input.focus();
1370
1669
  }
1371
1670
 
1671
+ /**
1672
+ * Put `text` into the composer as a markdown quotation, and focus it.
1673
+ *
1674
+ * Deliberately **not** a send. Quoting is how a question narrows to one part
1675
+ * of an answer, so the quotation is the preamble and the question is what
1676
+ * comes next -- the caret is left after it, on its own line.
1677
+ *
1678
+ * This is also the seam for the half of this feature the component cannot
1679
+ * build: selection in the **host page**. A widget mounted beside a table can
1680
+ * be asked about a row, and nothing in a chat's own transcript can offer
1681
+ * that. A host reads its own selection, however it likes, and calls this.
1682
+ *
1683
+ * No-ops on text that is empty or only whitespace.
1684
+ */
1685
+ quote(text: string): void {
1686
+ const quoted = asQuote(text);
1687
+ if (quoted === "") {
1688
+ return;
1689
+ }
1690
+ // Appended after whatever is already typed, on a fresh paragraph: a second
1691
+ // quotation is a second thing being asked about, not a replacement for the
1692
+ // first. Trailing blank lines are dropped so repeated quoting does not
1693
+ // accumulate gaps.
1694
+ const current = this.#input.value.replace(/\s+$/, "");
1695
+ this.#input.value = current === "" ? quoted : `${current}\n\n${quoted}`;
1696
+ this.#autoGrow();
1697
+ this.#input.focus();
1698
+ const end = this.#input.value.length;
1699
+ this.#input.setSelectionRange(end, end);
1700
+ }
1701
+
1702
+ /**
1703
+ * Offer to quote what the user selects in the **host page**, not just in the
1704
+ * transcript. Returns a function that stops offering.
1705
+ *
1706
+ * The same select-then-offer gesture, over a table, a diff, a report -- the
1707
+ * surface the user actually works in, which is the half of quoting no hosted
1708
+ * chat can reach. Opt-in, because it listens on the host's document and that
1709
+ * is theirs to grant.
1710
+ *
1711
+ * Deliberately **not** a four-line recipe, which is how this shipped first
1712
+ * and was wrong: a page listener that quotes every settled selection appends
1713
+ * to the composer on every drag made to read, to copy or to fix a typo -- and
1714
+ * it cannot tell a selection in the page's prose from one inside the user's
1715
+ * own half-typed `<input>`, because Chrome reports a field's internal
1716
+ * selection as an ordinary range over the field's *wrapper*. See
1717
+ * {@link attachQuoteOffer} for the guards.
1718
+ *
1719
+ * Detached automatically when the element leaves the document; a host that
1720
+ * re-mounts it calls this again.
1721
+ */
1722
+ offerQuoteInPage(within: HTMLElement = document.body): () => void {
1723
+ this.#pageQuote?.detach();
1724
+ const offer = attachQuoteOffer({
1725
+ within,
1726
+ label: this.#strings.quoteSelection,
1727
+ exclude: this,
1728
+ onQuote: (text) => this.quote(text),
1729
+ });
1730
+ this.#pageQuote = offer;
1731
+ return () => {
1732
+ offer.detach();
1733
+ if (this.#pageQuote === offer) {
1734
+ this.#pageQuote = null;
1735
+ }
1736
+ };
1737
+ }
1738
+
1739
+ /** Whether the transcript offers to quote what the user selects. */
1740
+ #quoteEnabled(): boolean {
1741
+ return this.getAttribute("data-quote-selection") !== "false";
1742
+ }
1743
+
1744
+ /**
1745
+ * Offer to quote the settled selection, or retire the offer.
1746
+ *
1747
+ * `event` is passed for its coordinates and only those: they say which line
1748
+ * of a selection spanning several messages the offer should hang from. A
1749
+ * keyboard selection has none, and the first line is used instead.
1750
+ */
1751
+ #onSelectionSettled(event?: MouseEvent): void {
1752
+ if (!this.#quoteEnabled()) {
1753
+ return;
1754
+ }
1755
+ const near = event === undefined ? undefined : { x: event.clientX, y: event.clientY };
1756
+ const selected = quotableSelection(this.#messages, [this.#root], near);
1757
+ if (selected === null) {
1758
+ this.#hideQuote();
1759
+ return;
1760
+ }
1761
+ this.#quoting = selected.text;
1762
+ this.#placeQuote(selected.rect);
1763
+ }
1764
+
1765
+ /** Float the offer beside `rect`, kept inside the transcript's own box. */
1766
+ #placeQuote(rect: DOMRect): void {
1767
+ // Unhidden first: a hidden element measures zero, and its own size is what
1768
+ // decides whether it fits above the selection and how far to pull it left.
1769
+ this.#quoteButton.hidden = false;
1770
+ const wrap = this.#messagesWrap.getBoundingClientRect();
1771
+ const top = rect.top - wrap.top;
1772
+ // Above the selection by default, below it when there is no room --
1773
+ // selecting the first line of the transcript is the ordinary case, not an
1774
+ // edge one, and an offer clipped by the header is an offer nobody takes.
1775
+ const below = top < QUOTE_GAP + this.#quoteButton.offsetHeight;
1776
+ this.#quoteButton.dataset["below"] = String(below);
1777
+ this.#quoteButton.style.top = `${below ? rect.bottom - wrap.top + QUOTE_GAP : top - QUOTE_GAP}px`;
1778
+ // Centred on the selection, then pulled back by its own half-width so a
1779
+ // selection at either margin does not push the offer out of the panel.
1780
+ const half = this.#quoteButton.offsetWidth / 2;
1781
+ const centre = rect.left + rect.width / 2 - wrap.left;
1782
+ this.#quoteButton.style.left = `${Math.min(Math.max(centre, half), wrap.width - half)}px`;
1783
+ }
1784
+
1785
+ /** Retire the offer, and forget what it was pointing at. */
1786
+ #hideQuote(): void {
1787
+ this.#quoteButton.hidden = true;
1788
+ this.#quoting = "";
1789
+ }
1790
+
1791
+ /**
1792
+ * The tool-round budget from `data-max-tool-rounds`, for one send.
1793
+ *
1794
+ * Anything unparseable becomes `NaN`, which {@link AgUiClient} rejects along
1795
+ * with a bound below one -- so the two ways of setting this are validated in
1796
+ * one place rather than agreeing by coincidence.
1797
+ */
1798
+ #maxToolRounds(): number {
1799
+ const attr = this.getAttribute("data-max-tool-rounds");
1800
+ return attr === null ? MAX_TOOL_ROUNDS : Number.parseInt(attr, 10);
1801
+ }
1802
+
1803
+ /**
1804
+ * Which message actions a finished bubble offers, from
1805
+ * `data-message-actions`.
1806
+ *
1807
+ * Absent means all of them, so the attribute only ever subtracts: the row
1808
+ * shipped without an off switch and a host that never sets this must keep
1809
+ * exactly what it had. A value names the survivors, which makes
1810
+ * `data-message-actions="false"` -- the spelling its sibling
1811
+ * `data-quote-selection` uses -- an empty set by falling out of the same rule
1812
+ * rather than by a case of its own.
1813
+ */
1814
+ #messageActions(): ReadonlySet<string> {
1815
+ const attr = this.getAttribute("data-message-actions");
1816
+ if (attr === null) {
1817
+ return new Set(Object.values(MESSAGE_ACTIONS));
1818
+ }
1819
+ return new Set(
1820
+ attr
1821
+ .split(",")
1822
+ .map((token) => token.trim())
1823
+ .filter((token) => token !== ""),
1824
+ );
1825
+ }
1826
+
1372
1827
  /** The client-side upload size cap from `data-attachment-max-bytes`. */
1373
1828
  #attachmentMaxBytes(): number {
1374
1829
  const attr = this.getAttribute("data-attachment-max-bytes");
@@ -1986,6 +2441,18 @@ export class AgUiChat extends HTMLElement {
1986
2441
  /** Drop the in-memory run + transcript, leaving the thread id untouched. */
1987
2442
  #resetState(): void {
1988
2443
  this.#client = null;
2444
+ this.#clearTranscript();
2445
+ this.#initialMessages = [];
2446
+ }
2447
+
2448
+ /**
2449
+ * Wipe the rendered transcript and everything that indexes into it.
2450
+ *
2451
+ * Split from {@link #resetState} because a retry re-renders the transcript
2452
+ * while keeping the *client*: dropping the client there would take the
2453
+ * agent's message list with it, which is the thing being truncated.
2454
+ */
2455
+ #clearTranscript(): void {
1989
2456
  // Before the transcript goes: a render still queued would otherwise fire
1990
2457
  // against the wiped list and open a fresh bubble holding the discarded
1991
2458
  // conversation's last tokens.
@@ -1994,16 +2461,58 @@ export class AgUiChat extends HTMLElement {
1994
2461
  this.#thoughts = null;
1995
2462
  this.#hidePending();
1996
2463
  this.#toolCards.clear();
2464
+ // The panels go with the cards they hung off. Nothing restores them: the
2465
+ // progress rode the imperative carrier and was never persisted, which is
2466
+ // the correct half of that split -- a delegation that was live before this
2467
+ // transcript was wiped is not live now.
2468
+ this.#subagentPanels.clear();
1997
2469
  this.#serverSettled.clear();
1998
2470
  this.#cardElements.clear();
1999
2471
  this.#activityBlocks.clear();
2000
- this.#initialMessages = [];
2472
+ this.#retryOwner = null;
2001
2473
  this.#attachTray?.clear();
2002
2474
  // Keep the empty-state region; everything else clears.
2003
2475
  this.#messages.replaceChildren(this.#emptyWrap);
2004
2476
  this.#updateEmptyState();
2005
2477
  }
2006
2478
 
2479
+ /**
2480
+ * Ask the same question again and replace the answer.
2481
+ *
2482
+ * History is truncated to the most recent user message inclusive and the run
2483
+ * repeats, so the agent answers what it was asked rather than being told its
2484
+ * last answer was wrong. Returns `false` when there is nothing to retry or a
2485
+ * run is already in flight.
2486
+ *
2487
+ * Public because a host with its own message UI wants the same button, and
2488
+ * because the failed-run notice reaches it from outside the action row.
2489
+ *
2490
+ * **A retried turn re-runs its tools**, which for a page-driving agent is not
2491
+ * neutral: the previous attempt already clicked what it clicked, and this
2492
+ * does not undo it. Confirmation still applies, so a destructive tool asks
2493
+ * again -- unless the user waived it for this session.
2494
+ */
2495
+ async retryLastTurn(): Promise<boolean> {
2496
+ if (this.#running) {
2497
+ return false;
2498
+ }
2499
+ const client = this.#ensureClient();
2500
+ const kept = client.truncateToLastUser();
2501
+ if (kept === null) {
2502
+ return false;
2503
+ }
2504
+ // Re-render between the truncation and the run: the kept turns replay as
2505
+ // restored history (static, no entrance animation), and only the new answer
2506
+ // arrives live. Streaming into the old transcript would put the new answer
2507
+ // underneath the one it replaces.
2508
+ this.#clearTranscript();
2509
+ for (const message of kept) {
2510
+ this.#renderHistoricMessage(message);
2511
+ }
2512
+ await client.resume();
2513
+ return true;
2514
+ }
2515
+
2007
2516
  /** Switch the active conversation to an existing thread and replay it. */
2008
2517
  async #switchThread(threadId: string): Promise<void> {
2009
2518
  if (threadId === this.#threadId) {
@@ -2034,6 +2543,7 @@ export class AgUiChat extends HTMLElement {
2034
2543
 
2035
2544
  /** Reload the drawer's thread list, marking the active thread. */
2036
2545
  async #refreshDrawer(): Promise<void> {
2546
+ this.#drawer.setRelativeTimeFormatter(this.formatRelativeTime);
2037
2547
  this.#drawer.setThreads(await this.conversationStore.listThreads(), this.#threadId);
2038
2548
  }
2039
2549
 
@@ -2139,7 +2649,9 @@ export class AgUiChat extends HTMLElement {
2139
2649
  // transcript mounts at once, so animating every bubble's text in
2140
2650
  // parallel looks wrong. Mark it so the fade CSS skips it, and don't
2141
2651
  // wrap words.
2142
- this.appendMessage(MESSAGE_ROLE.ASSISTANT, text).classList.add("message--restored");
2652
+ const restoredBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, text);
2653
+ restoredBubble.classList.add("message--restored");
2654
+ this.#attachActions(restoredBubble);
2143
2655
  }
2144
2656
  // Narrowed rather than trusted, for the same reason `messageAttachments`
2145
2657
  // narrows the neighbouring field: anything that throws in this loop aborts
@@ -2178,8 +2690,8 @@ export class AgUiChat extends HTMLElement {
2178
2690
  // chart's data is in the transcript already and survives a reload. Only
2179
2691
  // the drawing had to be put back.
2180
2692
  const activity = message as unknown as { activityType?: unknown; content?: unknown };
2181
- if (activity.activityType === CHART_ACTIVITY_TYPE && this.#chartActivity) {
2182
- this.#drawActivityChart(message.id, activity.content);
2693
+ if (typeof activity.activityType === "string") {
2694
+ this.#drawActivity(message.id, activity.activityType, activity.content);
2183
2695
  }
2184
2696
  return;
2185
2697
  }
@@ -2254,7 +2766,14 @@ export class AgUiChat extends HTMLElement {
2254
2766
  this.#messages.appendChild(bubble);
2255
2767
  }
2256
2768
  this.#updateEmptyState();
2257
- this.#messages.scrollTop = this.#messages.scrollHeight;
2769
+ // A user bubble means someone just pressed Send, which is as deliberate as
2770
+ // pressing the jump button -- so it goes to the bottom even if they had
2771
+ // scrolled away to re-read something before typing.
2772
+ if (role === MESSAGE_ROLE.USER) {
2773
+ this.#scroller.jump();
2774
+ } else {
2775
+ this.#scroller.follow();
2776
+ }
2258
2777
  return bubble;
2259
2778
  }
2260
2779
 
@@ -2278,9 +2797,6 @@ export class AgUiChat extends HTMLElement {
2278
2797
  }
2279
2798
 
2280
2799
  #render(): void {
2281
- const style = document.createElement("style");
2282
- style.textContent = STYLES;
2283
-
2284
2800
  this.#chat.className = "chat";
2285
2801
  this.#chat.setAttribute("part", "panel");
2286
2802
 
@@ -2355,11 +2871,68 @@ export class AgUiChat extends HTMLElement {
2355
2871
 
2356
2872
  this.#messages.className = "messages";
2357
2873
  this.#messages.setAttribute("part", "messages");
2358
- // Screen readers announce streamed messages as they arrive.
2359
2874
  this.#messages.setAttribute("role", "log");
2360
- this.#messages.setAttribute("aria-live", "polite");
2875
+ // NOT a live region. The streaming bubble's innerHTML is replaced inside
2876
+ // this element on every animation frame, and `role="log"` already implies
2877
+ // polite announcement whose default `aria-relevant` includes text
2878
+ // additions -- so a screen reader was asked to re-announce the whole answer
2879
+ // tens of times as it streamed. `aria-live="off"` is an explicit override
2880
+ // of the role's implicit value, which is why the role can stay: the log
2881
+ // semantics are what let the transcript be navigated as one, and only the
2882
+ // announcing is the defect. Status goes to #announcer instead.
2883
+ this.#messages.setAttribute("aria-live", "off");
2361
2884
  this.#messages.setAttribute("aria-label", this.#strings.conversation);
2362
2885
 
2886
+ this.#jumpButton.className = "jump-latest";
2887
+ this.#jumpButton.type = "button";
2888
+ this.#jumpButton.setAttribute("part", "jump-latest");
2889
+ this.#jumpButton.textContent = this.#strings.jumpToLatest;
2890
+ this.#jumpButton.addEventListener("click", () => {
2891
+ this.#scroller.jump();
2892
+ });
2893
+
2894
+ this.#quoteButton.className = "quote-selection";
2895
+ this.#quoteButton.type = "button";
2896
+ this.#quoteButton.setAttribute("part", "quote-selection");
2897
+ this.#quoteButton.textContent = this.#strings.quoteSelection;
2898
+ this.#quoteButton.hidden = true;
2899
+ // `mousedown` rather than `click`: pressing anywhere else collapses the
2900
+ // selection first, and by the time a click lands there is nothing left to
2901
+ // quote. Preventing the default keeps the selection alive long enough to
2902
+ // read it.
2903
+ this.#quoteButton.addEventListener("mousedown", (event) => {
2904
+ event.preventDefault();
2905
+ });
2906
+ this.#quoteButton.addEventListener("click", () => {
2907
+ this.quote(this.#quoting);
2908
+ window.getSelection()?.removeAllRanges();
2909
+ this.#hideQuote();
2910
+ });
2911
+
2912
+ // A settled selection, by either input. `mouseup` rather than
2913
+ // `selectionchange` so the offer does not chase the pointer mid-drag; the
2914
+ // second half of the same gesture, `mousedown`, retires the previous offer
2915
+ // before the new selection exists.
2916
+ this.#messages.addEventListener("mouseup", (event) => this.#onSelectionSettled(event));
2917
+ this.#messages.addEventListener("keyup", () => this.#onSelectionSettled());
2918
+ this.#messages.addEventListener("mousedown", () => this.#hideQuote());
2919
+
2920
+ // Built here rather than at field initialisation: the viewport has to exist
2921
+ // and the observer has to have something to observe.
2922
+ this.#scroller = createStickToBottom({
2923
+ viewport: this.#messages,
2924
+ onMissedContent: (missed) => {
2925
+ this.#jumpButton.dataset["missed"] = String(missed);
2926
+ },
2927
+ });
2928
+
2929
+ this.#announcer.className = "sr-only";
2930
+ this.#announcer.setAttribute("role", "status");
2931
+ this.#announcer.setAttribute("aria-live", "polite");
2932
+ // Atomic: each announcement replaces the last and is read whole. Without
2933
+ // it a reader may announce only the changed words between two statuses.
2934
+ this.#announcer.setAttribute("aria-atomic", "true");
2935
+
2363
2936
  // Empty-state region: a host slot at the top of the list, hidden as soon as
2364
2937
  // anything renders.
2365
2938
  this.#emptyWrap.className = "empty";
@@ -2450,9 +3023,14 @@ export class AgUiChat extends HTMLElement {
2450
3023
  inputRow.append(composer, this.#fileInput);
2451
3024
  // Skill surfaces sit just above the input: palette (opens on `/`), chips,
2452
3025
  // the missing-placeholder hint, and the pending-attachments tray.
3026
+ this.#messagesWrap.className = "messages-wrap";
3027
+ // Sibling of the list inside a shared box, not a child of it: the
3028
+ // affordance offering to scroll must not scroll away with the content.
3029
+ this.#messagesWrap.append(this.#messages, this.#jumpButton, this.#quoteButton);
3030
+
2453
3031
  this.#chat.append(
2454
3032
  header,
2455
- this.#messages,
3033
+ this.#messagesWrap,
2456
3034
  this.#skillsMenu.palette,
2457
3035
  this.#skillsMenu.chips,
2458
3036
  this.#skillHint,
@@ -2517,7 +3095,68 @@ export class AgUiChat extends HTMLElement {
2517
3095
  label: this.#strings.resizePanel,
2518
3096
  }),
2519
3097
  );
2520
- this.#root.append(style, this.#chat, this.#launcher);
3098
+ this.#adoptStyles();
3099
+ this.#root.append(this.#announcer, this.#chat, this.#launcher);
3100
+ }
3101
+
3102
+ /**
3103
+ * Attach the stylesheet without an inline `<style>` element.
3104
+ *
3105
+ * A host with a strict `style-src` and no `'unsafe-inline'` drops an injected
3106
+ * `<style>` silently: the component mounts, functions, and renders completely
3107
+ * unstyled, with nothing in the console to point at. `adoptedStyleSheets`
3108
+ * carries no inline-style origin, so it is unaffected by that policy.
3109
+ *
3110
+ * The sheet is constructed **per instance** rather than shared at module
3111
+ * scope. A shared sheet would additionally avoid re-parsing the stylesheet
3112
+ * once per mounted element, which is what `adoptedStyleSheets` is usually
3113
+ * reached for -- but a module-level singleton is exactly what this package
3114
+ * forbids, and the CSP defect is fixed either way. Per instance is no worse
3115
+ * than the `<style>` element it replaces, which also parsed once per mount.
3116
+ *
3117
+ * No fallback: constructible `CSSStyleSheet` is Chrome 73, Firefox 101 and
3118
+ * Safari 16.4, all below this package's declared Safari 17 runtime target. A
3119
+ * guard here would be code no supported browser can reach, and the only way
3120
+ * to keep it would be to exempt it from the coverage gate.
3121
+ */
3122
+ /**
3123
+ * Say one short thing to a screen reader, without touching the transcript.
3124
+ *
3125
+ * The transcript cannot do this job. It is rewritten on every animation
3126
+ * frame while an answer streams, so as a live region it re-announced the
3127
+ * whole answer tens of times per turn -- not merely unhelpful but actively
3128
+ * hostile. The published fix for this exact bug (Microsoft's Bot Framework
3129
+ * WebChat #3236) is architectural rather than a matter of tuning attributes:
3130
+ * demote the visible transcript out of live-region duty and put one
3131
+ * synthesised status per event into a separate invisible region. MDN and
3132
+ * Scott O'Hara prescribe the same empty-region-then-inject shape.
3133
+ *
3134
+ * Roughly four calls land per turn -- responding, answered, a card is waiting,
3135
+ * stopped or failed -- so the user is told what happened and reads the answer
3136
+ * itself by navigating the log, at their own pace, rather than having it
3137
+ * shouted at them a token at a time.
3138
+ *
3139
+ * **The clear is load-bearing, twice.** A reader announces a live region when
3140
+ * its content *changes*, so setting the same string twice running -- two turns
3141
+ * in a row both starting -- is not a change and is silently not announced.
3142
+ * Emptying first makes the next set a change again. It also stops a stale
3143
+ * status being read out when a reader later lands on the region.
3144
+ */
3145
+ #announce(message: string): void {
3146
+ if (this.#announceTimer !== null) {
3147
+ clearTimeout(this.#announceTimer);
3148
+ }
3149
+ this.#announcer.textContent = message;
3150
+ this.#announceTimer = setTimeout(() => {
3151
+ this.#announceTimer = null;
3152
+ this.#announcer.textContent = "";
3153
+ }, ANNOUNCE_CLEAR_MS);
3154
+ }
3155
+
3156
+ #adoptStyles(): void {
3157
+ const sheet = new CSSStyleSheet();
3158
+ sheet.replaceSync(STYLES);
3159
+ this.#root.adoptedStyleSheets = [sheet];
2521
3160
  }
2522
3161
 
2523
3162
  /**
@@ -2860,6 +3499,7 @@ export class AgUiChat extends HTMLElement {
2860
3499
  onPersist: (messages) => this.conversationStore.saveMessages(this.#threadId, messages),
2861
3500
  onStateChanged: (state) => this.#onSharedStateChanged(state),
2862
3501
  connectionLostMessage: this.#strings.connectionLost,
3502
+ maxToolRounds: this.#maxToolRounds(),
2863
3503
  });
2864
3504
  }
2865
3505
  return this.#client;
@@ -2877,15 +3517,88 @@ export class AgUiChat extends HTMLElement {
2877
3517
  );
2878
3518
  }
2879
3519
 
2880
- /** Whether ``call`` should be gated behind the confirmation card. */
2881
- async #needsConfirmation(call: AgUiToolCall, tool: ClientTool): Promise<boolean> {
3520
+ /**
3521
+ * Give a finished assistant bubble its action row, and hand it Retry.
3522
+ *
3523
+ * Every finished bubble gets copy and feedback -- both are safe on a message
3524
+ * of any age. Retry moves to the newest, because it is the only one where
3525
+ * re-running answers the same question rather than rewriting history.
3526
+ *
3527
+ * `data-message-actions` subtracts from that. The row is built only when
3528
+ * something survives to go in it: an empty row still takes its margin, still
3529
+ * answers to the `message-actions` part, and still reads to a screen reader
3530
+ * as a group of actions with none in it.
3531
+ */
3532
+ #attachActions(bubble: HTMLDivElement, options: { rateable?: boolean } = {}): void {
3533
+ const enabled = this.#messageActions();
3534
+ const copyable = enabled.has(MESSAGE_ACTIONS.COPY);
3535
+ // A failed run is copyable -- error text is what people paste into a bug
3536
+ // report -- but not rateable: a rating is a statement about an *answer*,
3537
+ // and mixing "the connection dropped" into that signal makes the host's
3538
+ // feedback data say less than it did before.
3539
+ const rateable = options.rateable !== false && enabled.has(MESSAGE_ACTIONS.FEEDBACK);
3540
+ if (copyable || rateable) {
3541
+ attachMessageActions(bubble, {
3542
+ strings: this.#strings,
3543
+ // Read at click time, not captured: a bubble rendered from markdown
3544
+ // holds its text in the DOM, and that is what the user sees and means
3545
+ // to copy.
3546
+ ...(copyable ? { text: () => bubble.textContent as string } : {}),
3547
+ ...(rateable
3548
+ ? {
3549
+ onFeedback: (rating: "up" | "down") => {
3550
+ this.dispatchEvent(
3551
+ new CustomEvent<FeedbackDetail>(FEEDBACK_EVENT, {
3552
+ detail: { content: bubble.textContent as string, rating },
3553
+ bubbles: true,
3554
+ composed: true,
3555
+ }),
3556
+ );
3557
+ },
3558
+ }
3559
+ : {}),
3560
+ });
3561
+ }
3562
+ if (enabled.has(MESSAGE_ACTIONS.RETRY)) {
3563
+ this.#moveRetryTo(messageActionBar(bubble, this.#strings));
3564
+ }
3565
+ }
3566
+
3567
+ /** Move the Retry button onto `bar`, taking it off whoever held it. */
3568
+ #moveRetryTo(bar: HTMLElement): void {
3569
+ this.#retryOwner?.querySelector(".message-action--retry")?.remove();
3570
+ const retry = messageActionButton("retry", this.#strings.retryMessage, "\u21BB");
3571
+ retry.addEventListener("click", () => {
3572
+ void this.retryLastTurn();
3573
+ });
3574
+ // First in the row: it is the action a reader reaches for when the answer
3575
+ // was wrong, which is when they are least inclined to hunt for a control.
3576
+ bar.prepend(retry);
3577
+ this.#retryOwner = bar;
3578
+ }
3579
+
3580
+ /**
3581
+ * Which rule gates `call`, or `null` when it runs straight through.
3582
+ *
3583
+ * The rule, rather than a bare boolean, because it decides whether the user
3584
+ * may *waive* the prompt for the rest of the session. Only the default
3585
+ * `x-destructive` gate is waivable: `confirmPredicate` is documented as
3586
+ * authoritative, so letting one click retire it would silently defeat a host
3587
+ * policy — and the session allowlist is consulted on the same path it can
3588
+ * be added from, so the button is never offered where honouring it would be
3589
+ * refused.
3590
+ */
3591
+ async #confirmationRule(call: AgUiToolCall, tool: ClientTool): Promise<ConfirmationRule | null> {
2882
3592
  if (this.autoConfirm) {
2883
- return false;
3593
+ return null;
2884
3594
  }
2885
3595
  if (this.confirmPredicate !== null) {
2886
- return (await this.confirmPredicate(call.name, call.args)) === true;
3596
+ return (await this.confirmPredicate(call.name, call.args)) === true ? "predicate" : null;
3597
+ }
3598
+ if (this.#sessionApproved.has(call.name)) {
3599
+ return null;
2887
3600
  }
2888
- return isDestructive(tool.parameters);
3601
+ return isDestructive(tool.parameters) ? "destructive" : null;
2889
3602
  }
2890
3603
 
2891
3604
  async #executeTool(call: AgUiToolCall): Promise<ToolExecution | null> {
@@ -2944,7 +3657,8 @@ export class AgUiChat extends HTMLElement {
2944
3657
  this.#showPending();
2945
3658
  return { content: `Error: ${message}`, error: message };
2946
3659
  }
2947
- if (await this.#needsConfirmation(call, tool)) {
3660
+ const rule = await this.#confirmationRule(call, tool);
3661
+ if (rule !== null) {
2948
3662
  const request: ConfirmationRequest = { toolName: call.name, args: call.args };
2949
3663
  const confirmText = tool.parameters[X_CONFIRM_KEY];
2950
3664
  if (typeof confirmText === "string") {
@@ -2960,9 +3674,13 @@ export class AgUiChat extends HTMLElement {
2960
3674
  const decision = requestConfirmation(this.#ensureGroup(), request, {
2961
3675
  signal: this.#confirmAbort.signal,
2962
3676
  strings: this.#strings,
3677
+ // Offered only where it can be honoured -- see `#confirmationRule`.
3678
+ ...(rule === "destructive"
3679
+ ? { onAlwaysAllow: () => this.#sessionApproved.add(call.name) }
3680
+ : {}),
2963
3681
  });
2964
3682
  this.#updateEmptyState();
2965
- this.#messages.scrollTop = this.#messages.scrollHeight;
3683
+ this.#scroller.follow();
2966
3684
  const accepted = await decision;
2967
3685
  this.#confirmAbort = null;
2968
3686
  card.recordDecision(accepted ? "approved" : "declined");
@@ -3044,6 +3762,13 @@ export class AgUiChat extends HTMLElement {
3044
3762
  ): Promise<Record<string, InterruptResponse>> {
3045
3763
  // One controller covers the whole batch: a single Stop denies all of them.
3046
3764
  this.#confirmAbort = new AbortController();
3765
+ // The run has stopped and is waiting on a person. Nothing else on screen
3766
+ // says so to a screen reader: the cards appear inside the transcript, which
3767
+ // is deliberately not a live region, so without this the run simply goes
3768
+ // quiet and the user has no reason to go looking.
3769
+ this.#announce(
3770
+ this.#strings.announceAwaitingDecision.replace("{count}", String(interrupts.length)),
3771
+ );
3047
3772
  this.#hidePending();
3048
3773
  const signal = this.#confirmAbort.signal;
3049
3774
  const answered = await Promise.all(
@@ -3061,6 +3786,14 @@ export class AgUiChat extends HTMLElement {
3061
3786
  if (toolName !== null && toolName !== undefined) {
3062
3787
  request.toolName = toolName;
3063
3788
  }
3789
+ // Offered only where it can be honoured: the host has said its agent
3790
+ // accepts `editedArgs`, and this interrupt named a call whose arguments
3791
+ // we still hold.
3792
+ let editedArgs: Record<string, unknown> | undefined;
3793
+ const editable = this.approveWithEdits && card !== undefined;
3794
+ if (editable) {
3795
+ request.args = card.args;
3796
+ }
3064
3797
  card?.mark(TOOL_CALL_STATUS.DEFERRED);
3065
3798
  // A host-supplied renderer takes full control of the approval UI. The
3066
3799
  // built-in card renders into the gated call's own card, falling back to
@@ -3071,6 +3804,13 @@ export class AgUiChat extends HTMLElement {
3071
3804
  : await requestApproval(card?.approvalSlot ?? this.#ensureGroup(), request, {
3072
3805
  signal,
3073
3806
  strings: this.#strings,
3807
+ ...(editable
3808
+ ? {
3809
+ onEdit: (args: Record<string, unknown>) => {
3810
+ editedArgs = args;
3811
+ },
3812
+ }
3813
+ : {}),
3074
3814
  });
3075
3815
  // Same annotation as the client-side confirmation gate. Without it the
3076
3816
  // two gates read differently for the same act: a locally-confirmed call
@@ -3085,16 +3825,21 @@ export class AgUiChat extends HTMLElement {
3085
3825
  // now rather than leaving it hanging until the onSettled sweep.
3086
3826
  card?.settle(TOOL_CALL_STATUS.DECLINED, this.#strings.declinedAction);
3087
3827
  }
3088
- return { id: interrupt.id, approved };
3828
+ return { id: interrupt.id, approved, editedArgs };
3089
3829
  }),
3090
3830
  );
3091
3831
  this.#updateEmptyState();
3092
- this.#messages.scrollTop = this.#messages.scrollHeight;
3832
+ this.#scroller.follow();
3093
3833
  this.#confirmAbort = null;
3094
3834
  const responses: Record<string, InterruptResponse> = {};
3095
- for (const { id, approved } of answered) {
3835
+ for (const { id, approved, editedArgs } of answered) {
3836
+ // `editedArgs` rides only when the user actually changed something, so a
3837
+ // server can tell "approved as proposed" from "approved, but like this".
3096
3838
  responses[id] = approved
3097
- ? { status: "resolved", payload: { approved: true } }
3839
+ ? {
3840
+ status: "resolved",
3841
+ payload: editedArgs === undefined ? { approved: true } : { approved: true, editedArgs },
3842
+ }
3098
3843
  : { status: "cancelled" };
3099
3844
  }
3100
3845
  return responses;
@@ -3103,6 +3848,12 @@ export class AgUiChat extends HTMLElement {
3103
3848
  #handlers(): AgUiClientHandlers {
3104
3849
  return {
3105
3850
  onRunStart: () => {
3851
+ // Per *round*, so guard on the turn: a run that calls three tools fires
3852
+ // this three times and the user needs telling once.
3853
+ if (!this.#running) {
3854
+ this.#announcedOutcome = false;
3855
+ this.#announce(this.#strings.announceResponding);
3856
+ }
3106
3857
  this.#setRunning(true);
3107
3858
  // Open the answer group on the turn's first run so the pending
3108
3859
  // indicator (and everything after) lands inside the well. Idempotent:
@@ -3143,6 +3894,7 @@ export class AgUiChat extends HTMLElement {
3143
3894
  this.#revealWords(bubble);
3144
3895
  }
3145
3896
  attachCopyButtons(bubble, this.#strings);
3897
+ this.#attachActions(bubble);
3146
3898
  this.#endStream();
3147
3899
  this.#noteUnread();
3148
3900
  },
@@ -3162,25 +3914,51 @@ export class AgUiChat extends HTMLElement {
3162
3914
  this.#cardFor(call);
3163
3915
  },
3164
3916
  onActivity: (activityType, content, messageId) => {
3165
- if (activityType === CHART_ACTIVITY_TYPE) {
3166
- if (this.#chartActivity) {
3167
- this.#drawActivityChart(messageId, content);
3168
- }
3169
- return;
3170
- }
3171
- if (activityType !== COMPACTION_ACTIVITY_TYPE) {
3917
+ this.#drawActivity(messageId, activityType, content);
3918
+ },
3919
+ onCustomEvent: (name, value) => {
3920
+ if (name === INVALIDATE_CUSTOM_NAME) {
3921
+ this.#dispatchInvalidation(value);
3172
3922
  return;
3173
3923
  }
3174
- const removed = compactionRemoved(content);
3175
- if (removed === null) {
3924
+ if (name === SUBAGENT_CUSTOM_NAME) {
3925
+ this.#reportSubAgent(value);
3176
3926
  return;
3177
3927
  }
3178
- this.#appendNotice(
3179
- "🗜",
3180
- this.#strings.historyCompacted.replace("{count}", String(removed)),
3181
- "compaction",
3928
+ // Straight out to the host page, uninterpreted. This is the imperative
3929
+ // carrier: whatever it means, it means it to the page, not to the
3930
+ // transcript -- so it is dispatched and deliberately not rendered,
3931
+ // persisted or replayed. A host that does not know the name simply has
3932
+ // no listener, which is the graceful outcome the open field is for.
3933
+ this.dispatchEvent(
3934
+ new CustomEvent<CustomAgentDetail>(CUSTOM_AGENT_EVENT, {
3935
+ detail: { name, value },
3936
+ bubbles: true,
3937
+ composed: true,
3938
+ }),
3182
3939
  );
3183
3940
  },
3941
+ onMessagesSnapshot: () => {
3942
+ // Honoured for persistence and announced, not re-rendered.
3943
+ //
3944
+ // The store follows the server, because the server is authoritative
3945
+ // about what the conversation *is* -- and it would follow it anyway:
3946
+ // `@ag-ui/client` replaces `agent.messages` before any subscriber runs,
3947
+ // and the run loop persists `agent.messages`. What was wrong was that
3948
+ // it happened in silence, so the screen and the store disagreed and
3949
+ // nobody found out until a reload served a transcript they had never
3950
+ // seen. That is not reportable as a bug; it is reportable as "the chat
3951
+ // lost my messages".
3952
+ //
3953
+ // Re-rendering from the snapshot was the other candidate and is
3954
+ // declined: a snapshot can land mid-run, and rebuilding the transcript
3955
+ // then would destroy the in-flight run's own UI state -- the streaming
3956
+ // bubble, the open answer group, and every tool card keyed by call id,
3957
+ // some of which are still waiting on results. Telling the reader costs
3958
+ // none of that, and this is the same answer the same question already
3959
+ // got for compaction, one handler up.
3960
+ this.#appendNotice("\u{1F504}", this.#strings.historyReplaced, "history-replaced");
3961
+ },
3184
3962
  onToolResult: (toolCallId, content) => {
3185
3963
  const card = this.#toolCards.get(toolCallId);
3186
3964
  if (card === undefined) {
@@ -3204,9 +3982,7 @@ export class AgUiChat extends HTMLElement {
3204
3982
  this.#showPending();
3205
3983
  },
3206
3984
  onActivityChanged: (messageId, activityType, content) => {
3207
- if (activityType === CHART_ACTIVITY_TYPE && this.#chartActivity) {
3208
- this.#drawActivityChart(messageId, content);
3209
- }
3985
+ this.#drawActivity(messageId, activityType, content);
3210
3986
  },
3211
3987
  onRunEnd: () => {
3212
3988
  // Per-round end; the button stays on Stop until the whole interaction
@@ -3215,19 +3991,38 @@ export class AgUiChat extends HTMLElement {
3215
3991
  this.#endStream();
3216
3992
  },
3217
3993
  onError: (message) => {
3994
+ this.#announcedOutcome = true;
3995
+ this.#announce(this.#strings.announceFailed);
3218
3996
  this.#hidePending();
3219
- this.#revealWords(this.appendMessage(MESSAGE_ROLE.ASSISTANT, `⚠️ ${message}`));
3997
+ const bubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, `⚠️ ${message}`);
3998
+ bubble.classList.add("message--failed");
3999
+ // A failure is the one message whose action row is only worth having
4000
+ // for Retry: there is nothing here worth copying and nothing to rate.
4001
+ // A dropped connection with no way back was the whole of the gap --
4002
+ // uploads had a retry and runs did not.
4003
+ //
4004
+ // Not a `run-notice`: that element's contract is that it "never
4005
+ // settles, takes no action, and carries no controls", and is explicitly
4006
+ // "distinct from an error, which is a failure". This is a failure, so
4007
+ // it stays an error and gains the control instead.
4008
+ this.#attachActions(bubble, { rateable: false });
4009
+ this.#revealWords(bubble);
3220
4010
  this.#endStream();
3221
4011
  },
3222
4012
  onCancelled: () => {
3223
4013
  // Deliberate stop, not a failure: keep whatever partial text already
3224
4014
  // streamed and add a muted note instead of an error bubble.
4015
+ this.#announcedOutcome = true;
4016
+ this.#announce(this.#strings.announceStopped);
3225
4017
  this.#hidePending();
3226
4018
  this.#appendStoppedNote();
3227
4019
  this.#endStream();
3228
4020
  },
3229
4021
  onSettled: () => {
3230
4022
  // Terminal guarantee: whatever path ended the run, return to rest.
4023
+ if (!this.#announcedOutcome) {
4024
+ this.#announce(this.#strings.announceAnswerReady);
4025
+ }
3231
4026
  this.#hidePending();
3232
4027
  this.#setRunning(false);
3233
4028
  this.#endStream();
@@ -3268,15 +4063,100 @@ export class AgUiChat extends HTMLElement {
3268
4063
  side: this.#serverSettled.has(id) ? "server" : "client",
3269
4064
  }));
3270
4065
  this.#runTools = [];
4066
+ const invalidated = [...this.#runInvalidated];
4067
+ this.#runInvalidated = new Set<string>();
3271
4068
  this.dispatchEvent(
3272
4069
  new CustomEvent<RunFinishedDetail>(RUN_FINISHED_EVENT, {
3273
- detail: { tools },
4070
+ detail: { tools, invalidated },
4071
+ bubbles: true,
4072
+ composed: true,
4073
+ }),
4074
+ );
4075
+ }
4076
+
4077
+ /**
4078
+ * Route one invalidation to the host, and remember it for the run summary.
4079
+ *
4080
+ * Dispatched immediately rather than only at the end, because that is what
4081
+ * makes a long multi-step run feel live -- the list refreshes as the third of
4082
+ * eight writes lands. The accumulated set rides
4083
+ * {@link RUN_FINISHED_EVENT} as well, so a host that would rather refetch once
4084
+ * upgrades by reading one extra field instead of adding a listener.
4085
+ *
4086
+ * Nothing is rendered, persisted or replayed. An invalidation is an
4087
+ * imperative: it has no place in the transcript and no meaning once acted on,
4088
+ * and replaying one on every thread load would be a refetch storm. That is the
4089
+ * whole reason the server sends it as `CUSTOM` rather than as an activity.
4090
+ */
4091
+ #dispatchInvalidation(value: unknown): void {
4092
+ const payload = (value ?? {}) as { keys?: unknown; reason?: unknown };
4093
+ // Defensive about the payload, not about the name: `value` is typed
4094
+ // `unknown` by the protocol, so a server can put anything there, and a
4095
+ // malformed announcement must not take the run down with it.
4096
+ const keys = Array.isArray(payload.keys)
4097
+ ? payload.keys.filter((key): key is string => typeof key === "string")
4098
+ : [];
4099
+ if (keys.length === 0) {
4100
+ return;
4101
+ }
4102
+ for (const key of keys) {
4103
+ this.#runInvalidated.add(key);
4104
+ }
4105
+ this.dispatchEvent(
4106
+ new CustomEvent<InvalidateDetail>(INVALIDATE_EVENT, {
4107
+ detail: { keys, reason: typeof payload.reason === "string" ? payload.reason : null },
3274
4108
  bubbles: true,
3275
4109
  composed: true,
3276
4110
  }),
3277
4111
  );
3278
4112
  }
3279
4113
 
4114
+ /**
4115
+ * Draw one step of a delegated sub-agent's progress, on the card that
4116
+ * delegated.
4117
+ *
4118
+ * `delegationId` is the parent's own `delegate_task` tool-call id, so the
4119
+ * attachment point is a card this element already drew on `TOOL_CALL_START`.
4120
+ * That is the whole design: a run that hands work to a sub-agent used to read
4121
+ * as a stall -- the card sat at "running…" for the child's entire duration --
4122
+ * and the fix is to narrate *into* the thing that was already standing there,
4123
+ * rather than to float a second element with the same identity.
4124
+ *
4125
+ * A progress event for a call this client never drew is dropped. It has no
4126
+ * card to attach to, and inventing a floating one is precisely the alternative
4127
+ * that was rejected: parent and child interleave in the transcript with
4128
+ * nothing marking whose is whose, and the persisted transcript -- which never
4129
+ * held the progress at all -- would not match what was on screen.
4130
+ *
4131
+ * Nothing here writes to the conversation store. `CUSTOM` never enters
4132
+ * `agent.messages`, so a reload mid-run leaves the tool card and loses the
4133
+ * nested detail, which is the intended behaviour rather than a gap.
4134
+ */
4135
+ #reportSubAgent(value: unknown): void {
4136
+ const update = subAgentUpdate(value);
4137
+ if (update === null) {
4138
+ return;
4139
+ }
4140
+ const card = this.#toolCards.get(update.delegationId);
4141
+ if (card === undefined) {
4142
+ return;
4143
+ }
4144
+ let panel = this.#subagentPanels.get(update.delegationId);
4145
+ if (panel === undefined) {
4146
+ // Created on whichever phase arrives first rather than only on `started`.
4147
+ // The contract says exactly one opens a delegation, and a client that
4148
+ // insisted on it would answer a server that dropped one frame by showing
4149
+ // nothing at all for the rest of the run.
4150
+ panel = new SubAgentPanel(this.#strings);
4151
+ this.#subagentPanels.set(update.delegationId, panel);
4152
+ card.subagentSlot.appendChild(panel.element);
4153
+ }
4154
+ panel.report(update);
4155
+ // The card grew, and the transcript is usually pinned to the foot while a
4156
+ // run is in flight.
4157
+ this.#scroller.follow();
4158
+ }
4159
+
3280
4160
  /** A muted "⏹ Stopped" line in the transcript (distinct from the ⚠️ error bubble). */
3281
4161
  #appendStoppedNote(): void {
3282
4162
  const note = document.createElement("div");
@@ -3286,7 +4166,7 @@ export class AgUiChat extends HTMLElement {
3286
4166
  note.textContent = this.#strings.stopped;
3287
4167
  this.#ensureGroup().appendChild(note);
3288
4168
  this.#updateEmptyState();
3289
- this.#messages.scrollTop = this.#messages.scrollHeight;
4169
+ this.#scroller.follow();
3290
4170
  }
3291
4171
 
3292
4172
  /**
@@ -3311,7 +4191,7 @@ export class AgUiChat extends HTMLElement {
3311
4191
  this.#pending = pending;
3312
4192
  this.#ensureGroup().appendChild(pending);
3313
4193
  this.#updateEmptyState();
3314
- this.#messages.scrollTop = this.#messages.scrollHeight;
4194
+ this.#scroller.follow();
3315
4195
  }
3316
4196
 
3317
4197
  /** Remove the pending indicator if shown. */
@@ -3331,7 +4211,7 @@ export class AgUiChat extends HTMLElement {
3331
4211
  const group = this.#ensureGroup();
3332
4212
  group.insertBefore(this.#thoughts.element, group.firstChild);
3333
4213
  this.#updateEmptyState();
3334
- this.#messages.scrollTop = this.#messages.scrollHeight;
4214
+ this.#scroller.follow();
3335
4215
  }
3336
4216
  return this.#thoughts;
3337
4217
  }
@@ -3388,7 +4268,7 @@ export class AgUiChat extends HTMLElement {
3388
4268
  this.#streamBuffer = buffer;
3389
4269
  const bubble = this.#openStream();
3390
4270
  bubble.innerHTML = renderMarkdown(buffer, { allowImages: this.allowImages });
3391
- this.#messages.scrollTop = this.#messages.scrollHeight;
4271
+ this.#scroller.follow();
3392
4272
  return bubble;
3393
4273
  }
3394
4274
 
@@ -3439,7 +4319,7 @@ export class AgUiChat extends HTMLElement {
3439
4319
  #appendNotice(icon: string, text: string, kind: string): void {
3440
4320
  this.#ensureGroup().appendChild(renderRunNotice(icon, text, kind));
3441
4321
  this.#updateEmptyState();
3442
- this.#messages.scrollTop = this.#messages.scrollHeight;
4322
+ this.#scroller.follow();
3443
4323
  }
3444
4324
 
3445
4325
  /**
@@ -3461,9 +4341,20 @@ export class AgUiChat extends HTMLElement {
3461
4341
  * arrives is not something to switch on for everybody.
3462
4342
  */
3463
4343
  enableCharts(routes: readonly ("tool" | "activity")[] = ["tool", "activity"]): void {
3464
- const first = !this.#chartActivity && !this.#toolRegistry.has(CHART_TOOL_NAME);
4344
+ const first =
4345
+ !this.#activityRenderers.has(CHART_ACTIVITY_TYPE) && !this.#toolRegistry.has(CHART_TOOL_NAME);
3465
4346
  if (routes.includes("activity")) {
3466
- this.#chartActivity = true;
4347
+ // The chart is a registration like any host's, not a privileged branch.
4348
+ // If the built-in cannot be expressed through the seam, the seam is not
4349
+ // one -- so this is the test as much as the feature.
4350
+ this.registerActivityRenderer({
4351
+ type: CHART_ACTIVITY_TYPE,
4352
+ render: (content) => {
4353
+ const spec = chartSpecFrom(content);
4354
+ return spec === null ? null : renderChart(spec);
4355
+ },
4356
+ removedNotice: this.#strings.chartUndrawable,
4357
+ });
3467
4358
  }
3468
4359
  if (routes.includes("tool")) {
3469
4360
  this.registerTool(createChartTool());
@@ -3511,37 +4402,140 @@ export class AgUiChat extends HTMLElement {
3511
4402
  this.#afterTranscriptGrew();
3512
4403
  }
3513
4404
 
3514
- /** Draw, or redraw in place, the chart for one activity message. */
3515
- #drawActivityChart(messageId: string, content: unknown): void {
3516
- const spec = chartSpecFrom(content);
3517
- const block = spec === null ? null : renderChart(spec);
3518
- if (block === null) {
3519
- // The server superseded this chart with something undrawable. Leaving the
3520
- // old one up is the worst available answer: it shows numbers that have
3521
- // been retracted, reading as current, and a reload then drops the chart
3522
- // entirely because the *stored* content is the version we could not draw.
3523
- // Live and reload should agree, and both should say "gone" rather than
3524
- // one of them lying.
3525
- this.#activityBlocks.get(messageId)?.remove();
3526
- this.#activityBlocks.delete(messageId);
4405
+ /**
4406
+ * Teach this element to draw one kind of AG-UI activity.
4407
+ *
4408
+ * `activity_type` is one of exactly two fields the protocol leaves an open
4409
+ * string, and it is the **content** one: an activity is materialised into a
4410
+ * message, persisted with the thread, and replayed on every restore. Its
4411
+ * sibling `CUSTOM` carries an imperative and is dispatched to the page
4412
+ * instead ({@link CUSTOM_AGENT_EVENT}).
4413
+ *
4414
+ * That asymmetry decides which carrier a server should use. Content has a
4415
+ * place in the conversation and should come back; an imperative has no place
4416
+ * and no meaning once acted on.
4417
+ *
4418
+ * ```js
4419
+ * chat.registerActivityRenderer({
4420
+ * type: "build_status",
4421
+ * render: (content) => {
4422
+ * const el = document.createElement("div");
4423
+ * el.textContent = `Build ${content.status}`;
4424
+ * return el;
4425
+ * },
4426
+ * });
4427
+ * ```
4428
+ *
4429
+ * Registering a type twice replaces the earlier renderer, so a host can
4430
+ * override a built-in -- `chart` and `compaction` are registrations like any
4431
+ * other, not privileged branches.
4432
+ *
4433
+ * ⚠ `render` runs again on every thread load. See {@link ActivityRenderer}
4434
+ * for what that requires of it.
4435
+ */
4436
+ registerActivityRenderer(registration: ActivityRegistration): void {
4437
+ this.#activityRenderers.set(registration.type, registration);
4438
+ this.#unhandledActivityTypes.delete(registration.type);
4439
+ }
4440
+
4441
+ /**
4442
+ * Activity types that arrived with nobody registered to draw them.
4443
+ *
4444
+ * Deliberately the only trace an unhandled activity leaves. Ignoring an
4445
+ * unknown name is the protocol's own answer and the whole point of an open
4446
+ * field, so warning would fire on every forward-compatible server -- but
4447
+ * "nothing happened and nothing was said" is impossible to debug, so the set
4448
+ * is readable. Accumulates for the element's lifetime, across threads.
4449
+ */
4450
+ get unhandledActivityTypes(): readonly string[] {
4451
+ return [...this.#unhandledActivityTypes];
4452
+ }
4453
+
4454
+ /**
4455
+ * Draw, replace or remove one activity, whatever kind it is.
4456
+ *
4457
+ * The single path for all three routes an activity arrives by -- pushed
4458
+ * (`onActivity`), patched (`onActivityChanged`) and replayed from history --
4459
+ * which is why the renderer contract has to be pure: the same content is
4460
+ * drawn again on every thread load.
4461
+ *
4462
+ * An unregistered type draws nothing and says nothing. That is the protocol's
4463
+ * own answer -- a client that does not know a name ignores the event -- and a
4464
+ * warning here would fire on every well-behaved forward-compatible server,
4465
+ * while a placeholder would put the protocol's growth in the user's face.
4466
+ * {@link unhandledActivityTypes} is the way to find out what arrived.
4467
+ */
4468
+ #drawActivity(messageId: string, activityType: string, content: unknown): void {
4469
+ const registration = this.#activityRenderers.get(activityType);
4470
+ if (registration === undefined) {
4471
+ this.#unhandledActivityTypes.add(activityType);
4472
+ return;
4473
+ }
4474
+ let node: Node | null;
4475
+ try {
4476
+ node = registration.render(content);
4477
+ } catch (error) {
4478
+ // `render` is consumer code and this runs inside the history replay,
4479
+ // where a throw abandons the loop and takes every later turn of the
4480
+ // transcript with it -- silently, and again on every reload. One activity
4481
+ // that fails to draw is worth losing; the rest of the conversation is not.
4482
+ console.warn(`ag-ui-chat: render failed for activity ${activityType}`, error);
4483
+ node = null;
4484
+ }
4485
+ if (node === null) {
4486
+ this.#removeActivity(messageId, activityType, registration.removedNotice, content);
3527
4487
  return;
3528
4488
  }
3529
4489
  const existing = this.#activityBlocks.get(messageId);
3530
4490
  if (existing === undefined) {
3531
- this.#ensureGroup().appendChild(block);
4491
+ this.#ensureGroup().appendChild(node as HTMLElement);
3532
4492
  } else {
3533
- // Replaced rather than appended: a server redrawing a chart under the same
3534
- // id means *this chart changed*, and a second copy below the first would
3535
- // read as two measurements instead of one that moved.
3536
- existing.replaceWith(block);
4493
+ // Replaced rather than appended: a server redrawing under the same id
4494
+ // means *this one changed*, and a second copy below the first would read
4495
+ // as two measurements instead of one that moved.
4496
+ existing.replaceWith(node);
3537
4497
  }
3538
- this.#activityBlocks.set(messageId, block);
4498
+ this.#activityBlocks.set(messageId, node as HTMLElement);
3539
4499
  this.#afterTranscriptGrew();
3540
4500
  }
3541
4501
 
4502
+ /**
4503
+ * Take away an activity whose content stopped being drawable.
4504
+ *
4505
+ * Leaving the old one up is the worst available answer: it shows values that
4506
+ * have been retracted, reading as current, and a reload drops it anyway
4507
+ * because the *stored* content is the version that could not be drawn. Live
4508
+ * and reload should agree, and both should say "gone".
4509
+ *
4510
+ * Removing is right; doing it in silence was not. A chart that had been drawn
4511
+ * simply disappeared, with no `console` call anywhere on the path -- which
4512
+ * nobody reports as a bug, they report as "the charts are flaky".
4513
+ */
4514
+ #removeActivity(
4515
+ messageId: string,
4516
+ activityType: string,
4517
+ notice: string | undefined,
4518
+ content: unknown,
4519
+ ): void {
4520
+ const had = this.#activityBlocks.has(messageId);
4521
+ this.#activityBlocks.get(messageId)?.remove();
4522
+ this.#activityBlocks.delete(messageId);
4523
+ console.warn(
4524
+ `ag-ui-chat: activity ${messageId} (${activityType}) was not drawable and has been ` +
4525
+ "removed. A chart's points must each be a finite JSON number; a numeric column " +
4526
+ "serialised as a string (a Decimal, typically) is rejected rather than coerced.",
4527
+ content,
4528
+ );
4529
+ // Only when something was on screen: content that never drew has no
4530
+ // disappearance to explain, and a notice for every rejected push is noise.
4531
+ if (had && notice !== undefined) {
4532
+ this.#appendNotice("\u{1F4C9}", notice, "chart-undrawable");
4533
+ }
4534
+ }
4535
+
3542
4536
  #afterTranscriptGrew(): void {
3543
4537
  this.#updateEmptyState();
3544
- this.#messages.scrollTop = this.#messages.scrollHeight;
4538
+ this.#scroller.follow();
3545
4539
  }
3546
4540
 
3547
4541
  #cardFor(call: AgUiToolCall): ToolCallCard {
@@ -3559,11 +4553,16 @@ export class AgUiChat extends HTMLElement {
3559
4553
  : (this.toolSummaries[call.name] ??
3560
4554
  this.#toolCatalog[call.name]?.summary ??
3561
4555
  prettifyToolName(call.name));
3562
- const card = new ToolCallCard(call.name, call.args, summary, this.#strings);
4556
+ const card = new ToolCallCard(call.name, call.args, summary, this.#strings, {
4557
+ // A thunk over the live property, not the property itself: the card keeps
4558
+ // this for the life of the call, and the result region is filled when the
4559
+ // tool settles -- which can be long after a host set the hook.
4560
+ formatPayload: (payload) => this.formatToolPayload?.(payload) ?? null,
4561
+ });
3563
4562
  this.#toolCards.set(call.id, card);
3564
4563
  this.#ensureGroup().appendChild(card.element);
3565
4564
  this.#updateEmptyState();
3566
- this.#messages.scrollTop = this.#messages.scrollHeight;
4565
+ this.#scroller.follow();
3567
4566
  return card;
3568
4567
  }
3569
4568
  }
@@ -3588,6 +4587,15 @@ function confirmPhrase(interrupt: Interrupt): string | undefined {
3588
4587
  }
3589
4588
 
3590
4589
  /** One tool call as a restored assistant message carries it. */
4590
+ /**
4591
+ * Why a client tool call is gated behind the confirmation card.
4592
+ *
4593
+ * Only `"destructive"` -- the default `x-destructive` gate -- may be waived for
4594
+ * the session. `confirmPredicate` is documented as authoritative, so a call it
4595
+ * gates keeps asking.
4596
+ */
4597
+ type ConfirmationRule = "destructive" | "predicate";
4598
+
3591
4599
  interface RestoredToolCall {
3592
4600
  readonly id: string;
3593
4601
  readonly function: { readonly name: string; readonly arguments?: unknown };