@artooi/ag-ui-web-component 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +417 -1
  2. package/README.md +371 -5
  3. package/dist/ag-ui-web-component.bundle.js +308 -50
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +69 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +207 -0
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +38 -0
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/index.d.ts +7 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1516 -76
  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 +46 -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/suggestion_chips.d.ts +29 -0
  34. package/dist/ui/suggestion_chips.d.ts.map +1 -0
  35. package/dist/ui/thread_drawer.d.ts +10 -0
  36. package/dist/ui/thread_drawer.d.ts.map +1 -1
  37. package/dist/ui/tool_call_card.d.ts +8 -0
  38. package/dist/ui/tool_call_card.d.ts.map +1 -1
  39. package/dist/ui/ui_strings.d.ts +40 -0
  40. package/dist/ui/ui_strings.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/constants.ts +75 -0
  43. package/src/core/ag_ui_chat.ts +927 -73
  44. package/src/core/agui_client.ts +63 -0
  45. package/src/index.ts +39 -0
  46. package/src/ui/approval_card.ts +90 -2
  47. package/src/ui/checkpoint_menu.ts +22 -5
  48. package/src/ui/confirmation_card.ts +29 -1
  49. package/src/ui/message_actions.ts +158 -0
  50. package/src/ui/page_quote_offer.ts +215 -0
  51. package/src/ui/quote_selection.ts +345 -0
  52. package/src/ui/relative_time.ts +11 -0
  53. package/src/ui/stick_to_bottom.ts +126 -0
  54. package/src/ui/styles.ts +227 -0
  55. package/src/ui/suggestion_chips.ts +73 -0
  56. package/src/ui/thread_drawer.ts +22 -2
  57. package/src/ui/tool_call_card.ts +9 -0
  58. package/src/ui/ui_strings.ts +60 -0
  59. package/src/version.ts +1 -1
@@ -1,20 +1,26 @@
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,
13
18
  MESSAGE_ROLE,
14
19
  READ_PAGE_TOOL,
15
20
  RUN_FINISHED_EVENT,
16
21
  STATE_EVENT,
17
22
  SUBMIT_EVENT,
23
+ SUGGESTIONS_ACTIVITY_TYPE,
18
24
  TOGGLE_EVENT,
19
25
  TOOL_CALL_STATUS,
20
26
  TOOL_DISPLAY,
@@ -47,12 +53,20 @@ import { chartSpecFrom } from "../ui/chart_spec_from.js";
47
53
  import { CHART_TOOL_NAME, createChartTool } from "../ui/chart_tool.js";
48
54
  import { CheckpointMenu, type CheckpointVerb } from "../ui/checkpoint_menu.js";
49
55
  import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
56
+ import {
57
+ attachMessageActions,
58
+ messageActionBar,
59
+ messageActionButton,
60
+ } from "../ui/message_actions.js";
61
+ import { attachQuoteOffer, type PageQuoteOffer } from "../ui/page_quote_offer.js";
50
62
  import { prettifyToolName } from "../ui/prettify_tool_name.js";
51
63
  import {
52
64
  type QuestionRenderer,
53
65
  type QuestionRequest,
54
66
  requestQuestion,
55
67
  } from "../ui/question_card.js";
68
+ import { asQuote, quotableSelection } from "../ui/quote_selection.js";
69
+ import type { RelativeTimeFormatter } from "../ui/relative_time.js";
56
70
  import { renderMarkdown } from "../ui/render_markdown.js";
57
71
  import {
58
72
  createResizeHandle,
@@ -63,7 +77,9 @@ import {
63
77
  import { wrapWords } from "../ui/reveal_words.js";
64
78
  import { renderRunNotice } from "../ui/run_notice.js";
65
79
  import { SkillsMenu } from "../ui/skills_menu.js";
80
+ import { createStickToBottom, type StickToBottom } from "../ui/stick_to_bottom.js";
66
81
  import { STYLES } from "../ui/styles.js";
82
+ import { renderSuggestionChips } from "../ui/suggestion_chips.js";
67
83
  import { ThoughtsBlock } from "../ui/thoughts_block.js";
68
84
  import { ThreadDrawer } from "../ui/thread_drawer.js";
69
85
  import { ToolCallCard, type ToolDisplayMode } from "../ui/tool_call_card.js";
@@ -109,6 +125,13 @@ export interface AttachmentsDetail {
109
125
  }
110
126
 
111
127
  /** `detail` shape of the {@link STATE_EVENT} CustomEvent. */
128
+ /** {@link FEEDBACK_EVENT} detail: what was rated, and how. */
129
+ export interface FeedbackDetail {
130
+ /** The rated message's text, as rendered. */
131
+ readonly content: string;
132
+ readonly rating: "up" | "down";
133
+ }
134
+
112
135
  export interface StateDetail {
113
136
  readonly state: Readonly<Record<string, unknown>>;
114
137
  }
@@ -128,6 +151,83 @@ export interface ToolRun {
128
151
  export interface RunFinishedDetail {
129
152
  /** In settle order. Empty when the interaction called no tools. */
130
153
  readonly tools: readonly ToolRun[];
154
+ /**
155
+ * Every key announced during the interaction, de-duplicated, first-seen order.
156
+ *
157
+ * **This is the field that makes adoption one line** for a host already
158
+ * listening here, and the `else` is the whole compatibility story:
159
+ *
160
+ * ```js
161
+ * if (detail.invalidated.length > 0) refetchOnly(detail.invalidated);
162
+ * else if (detail.tools.some((t) => t.side === "server")) refetchEverything();
163
+ * ```
164
+ *
165
+ * Empty against a server that announces nothing, so an old server and a new
166
+ * client fall through to the coarse refetch that shipped before either.
167
+ */
168
+ readonly invalidated: readonly string[];
169
+ }
170
+
171
+ /**
172
+ * Draw one activity, from its content alone.
173
+ *
174
+ * The contract is {@link ClientTool.render}'s, and for the same reason rather
175
+ * than by analogy. An activity is materialised into a `role: "activity"`
176
+ * message, persisted with the transcript, and re-fired on every restore -- so a
177
+ * renderer that writes to the page instead of returning DOM fires again on
178
+ * every thread load, which is exactly the bug the tool registry's purity rule
179
+ * was written to make unmakeable.
180
+ *
181
+ * - a pure function of `content` -- no host state, no network, no clock;
182
+ * - deterministic, so a reload reproduces what was there before;
183
+ * - free of effects outside the node it returns, which the component places.
184
+ *
185
+ * Return `null` for content that says nothing worth drawing. Anything already
186
+ * drawn under that message id is then removed: live and reload should agree,
187
+ * and the stored content is the version that could not be drawn.
188
+ */
189
+ export type ActivityRenderer = (content: unknown) => Node | null;
190
+
191
+ /** One `activity_type` a host can draw. See {@link AgUiChat.registerActivityRenderer}. */
192
+ export interface ActivityRegistration {
193
+ /**
194
+ * The AG-UI `activity_type` this draws, matched exactly.
195
+ *
196
+ * An open string the protocol does not enumerate -- which is the whole reason
197
+ * this is a registry rather than a branch.
198
+ */
199
+ readonly type: string;
200
+ readonly render: ActivityRenderer;
201
+ /**
202
+ * Shown in the transcript when something already drawn under this type stops
203
+ * being renderable. Omit for an activity whose disappearance needs no
204
+ * explanation.
205
+ */
206
+ readonly removedNotice?: string;
207
+ }
208
+
209
+ /** `detail` shape of the {@link CUSTOM_AGENT_EVENT} CustomEvent. */
210
+ export interface CustomAgentDetail {
211
+ /** The `CUSTOM` event's `name`, verbatim. An open string; never interpreted here. */
212
+ readonly name: string;
213
+ /** Its `value`, verbatim and unparsed. `unknown` because the protocol says nothing about it. */
214
+ readonly value: unknown;
215
+ }
216
+
217
+ /** `detail` shape of the {@link INVALIDATE_EVENT} CustomEvent. */
218
+ export interface InvalidateDetail {
219
+ /**
220
+ * The resources that moved, as the server named them.
221
+ *
222
+ * **Opaque strings, and matching is exact.** `orders/42` does not imply
223
+ * `orders` -- a prefix rule would be this component guessing at a scheme it
224
+ * does not own, and `orders/1` would match `orders/11`. A server that wants
225
+ * the collection refreshed names it. Your own matching may be hierarchical,
226
+ * because in your vocabulary the scheme is known.
227
+ */
228
+ readonly keys: readonly string[];
229
+ /** What caused the write -- usually the tool's name. `null` when unstated. */
230
+ readonly reason: string | null;
131
231
  }
132
232
 
133
233
  /** `detail` shape of the {@link TOGGLE_EVENT} CustomEvent. */
@@ -190,6 +290,9 @@ const SIZE_KEY = "ag-ui-chat:size";
190
290
  /** Per-tab persistence key for the built-in theme toggle. */
191
291
  const THEME_KEY = "ag-ui-chat:theme";
192
292
 
293
+ /** Pixels between a selection and the offer to quote it. */
294
+ const QUOTE_GAP = 6;
295
+
193
296
  /**
194
297
  * Storage namespaces already spoken for in this document.
195
298
  *
@@ -263,6 +366,23 @@ export class AgUiChat extends HTMLElement {
263
366
  */
264
367
  allowImages = false;
265
368
 
369
+ /**
370
+ * Replace the relative timestamps in the thread drawer and the checkpoint
371
+ * panel -- `"5m ago"`, `"2d ago"` -- with the host's own formatting.
372
+ *
373
+ * The built-in is locale-neutral on purpose: there is no `Intl` anywhere in
374
+ * this component, so it never disagrees with the page it is embedded in by
375
+ * guessing a locale. That is a good default and a bad requirement, which is
376
+ * what this is for.
377
+ *
378
+ * ```js
379
+ * const rtf = new Intl.RelativeTimeFormat("de", { numeric: "auto" });
380
+ * chat.formatRelativeTime = (ts) =>
381
+ * rtf.format(Math.round((ts - Date.now()) / 60000), "minute");
382
+ * ```
383
+ */
384
+ formatRelativeTime: RelativeTimeFormatter | null = null;
385
+
266
386
  /** When true, destructive tools execute without a confirmation modal. */
267
387
  autoConfirm = false;
268
388
 
@@ -290,6 +410,23 @@ export class AgUiChat extends HTMLElement {
290
410
  */
291
411
  approvalRenderer: ApprovalRenderer | null = null;
292
412
 
413
+ /**
414
+ * Let the user edit a gated call's arguments before approving it.
415
+ *
416
+ * Off by default and **an assertion about your server**, not a negotiation:
417
+ * AG-UI carries `editedArgs` in the resume payload and gates it on the
418
+ * agent's own `approveWithEdits` capability, which this component never sees
419
+ * -- capabilities are not on the wire it reads. So the host says whether its
420
+ * agent honours them. Turned on against a server that does not, the user
421
+ * would edit arguments it silently discards, which is worse than not
422
+ * offering.
423
+ *
424
+ * Only affects calls whose arguments are known here: an interrupt names a
425
+ * `toolCallId`, and the tool card for that call is where the arguments still
426
+ * are. An interrupt naming no card gets the plain approve/deny.
427
+ */
428
+ approveWithEdits = false;
429
+
293
430
  /**
294
431
  * Optional per-call confirmation predicate. When set it is authoritative,
295
432
  * deciding from the tool name and args whether this particular call needs
@@ -455,7 +592,16 @@ export class AgUiChat extends HTMLElement {
455
592
  * the real output with the generic "executed on the server" fallback.
456
593
  */
457
594
  /** Whether a server-pushed chart activity is drawn. Off unless asked for. */
458
- #chartActivity = false;
595
+ /**
596
+ * Which `activity_type`s this element can draw, by name.
597
+ *
598
+ * A registry rather than a branch because `activity_type` is an open string
599
+ * the protocol does not enumerate. The two built-ins go through it like any
600
+ * host registration, which is the test that the seam is real.
601
+ */
602
+ readonly #activityRenderers = new Map<string, ActivityRegistration>();
603
+ /** Types that arrived with nobody registered to draw them. See {@link unhandledActivityTypes}. */
604
+ readonly #unhandledActivityTypes = new Set<string>();
459
605
 
460
606
  /** Card elements by call id, so a rendering handler can find its own card. */
461
607
  readonly #cardElements = new Map<string, HTMLElement>();
@@ -470,7 +616,77 @@ export class AgUiChat extends HTMLElement {
470
616
  * Spans tool rounds and an approval interrupt; cleared when the event fires.
471
617
  */
472
618
  #runTools: { readonly id: string; readonly name: string }[] = [];
619
+ /**
620
+ * Keys announced during this interaction, de-duplicated in first-seen order.
621
+ *
622
+ * Per element, never module-level: a second mounted chat is a second run, and
623
+ * sharing this would tell one page to refetch on the other's writes. Reset by
624
+ * {@link AgUiChat.#dispatchRunFinished}, which is the one place that has read
625
+ * it.
626
+ */
627
+ /**
628
+ * Tool names the user waived confirmation for, for the life of this element.
629
+ *
630
+ * Per instance and never persisted: a session decision that outlived the tab
631
+ * would be a permanent grant made by one click, which is the thing
632
+ * `autoConfirm` already exists to say deliberately. Cleared with the element.
633
+ */
634
+ readonly #sessionApproved = new Set<string>();
635
+
636
+ /**
637
+ * The one action row currently carrying Retry, if any.
638
+ *
639
+ * Retry belongs to the **last** turn only: re-running an older one is
640
+ * branching, and for a page-driving agent editing a past turn is not neutral
641
+ * -- those turns clicked buttons, and re-running turn 3 does not un-save what
642
+ * turn 5 saved. Holding a single owner is what keeps exactly one offer on
643
+ * screen without per-bubble bookkeeping.
644
+ */
645
+ #retryOwner: HTMLElement | null = null;
646
+
647
+ #runInvalidated = new Set<string>();
473
648
  readonly #root: ShadowRoot;
649
+ /** Screen-reader-only status region -- see {@link AgUiChat.#announce}. */
650
+ readonly #announcer = document.createElement("div");
651
+ /** Return-to-foot affordance, shown only once something has been missed. */
652
+ readonly #jumpButton = document.createElement("button");
653
+ /**
654
+ * Offer to quote the current selection, floated beside it.
655
+ *
656
+ * Shares {@link AgUiChat.#messagesWrap} with the jump button for the same
657
+ * reason: it is positioned against the transcript, and must not scroll away
658
+ * with the words it is pointing at.
659
+ */
660
+ readonly #quoteButton = document.createElement("button");
661
+ /** What {@link AgUiChat.#quoteButton} would quote, while it is showing. */
662
+ #quoting = "";
663
+ /** The host-page offer, while one is attached; see {@link AgUiChat.offerQuoteInPage}. */
664
+ #pageQuote: PageQuoteOffer | null = null;
665
+ /**
666
+ * Positioning context for {@link AgUiChat.#jumpButton}.
667
+ *
668
+ * The button cannot live in the scrolling list -- it would scroll away with
669
+ * the content it is offering to scroll to -- and it cannot be positioned
670
+ * against the panel either: the panel's foot is below the composer, the skill
671
+ * chips and the footer, so `bottom` measured from there lands the button on
672
+ * top of the composer rather than over the transcript. This wrapper is the
673
+ * only box whose foot *is* the transcript's foot.
674
+ */
675
+ readonly #messagesWrap = document.createElement("div");
676
+ /** Follows the foot of the transcript, and stops when the reader scrolls away. */
677
+ #scroller!: StickToBottom;
678
+ /** Pending clear of {@link AgUiChat.#announcer}; see why it is cleared at all. */
679
+ #announceTimer: ReturnType<typeof setTimeout> | null = null;
680
+ /**
681
+ * Whether this turn already announced how it ended.
682
+ *
683
+ * `onSettled` is the terminal guarantee and fires however the run ended, so
684
+ * it is the only place that can promise the user hears *something*. But a
685
+ * stopped or failed run has already said the truer thing from `onCancelled`
686
+ * or `onError`, and "assistant answered" after "response stopped" is worse
687
+ * than silence.
688
+ */
689
+ #announcedOutcome = false;
474
690
  readonly #chat: HTMLDivElement;
475
691
  readonly #messages: HTMLDivElement;
476
692
  readonly #input: HTMLTextAreaElement;
@@ -590,6 +806,33 @@ export class AgUiChat extends HTMLElement {
590
806
  this.#launcher = document.createElement("button");
591
807
  this.#badge = document.createElement("span");
592
808
  this.#emptyWrap = document.createElement("div");
809
+ // The compaction notice is a registration, not a branch -- and going through
810
+ // the seam earns it two things it did not have: a reload puts it back (it is
811
+ // content, and content replays), and a server redrawing under the same id
812
+ // replaces it rather than adding a second notice for one event.
813
+ this.registerActivityRenderer({
814
+ type: COMPACTION_ACTIVITY_TYPE,
815
+ render: (content) => {
816
+ const removed = compactionRemoved(content);
817
+ return removed === null
818
+ ? null
819
+ : renderRunNotice(
820
+ "\u{1F5DC}",
821
+ this.#strings.historyCompacted.replace("{count}", String(removed)),
822
+ "compaction",
823
+ );
824
+ },
825
+ });
826
+ // Follow-up chips, registered through the same seam for the same reasons:
827
+ // a reload puts them back, and a server pushing a new set under a new id
828
+ // supersedes the old one rather than leaving two offers on screen.
829
+ this.registerActivityRenderer({
830
+ type: SUGGESTIONS_ACTIVITY_TYPE,
831
+ render: (content) =>
832
+ renderSuggestionChips(content, this.#strings, (prompt) => {
833
+ void this.sendMessage(prompt);
834
+ }),
835
+ });
593
836
  this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
594
837
  this.#drawer = new ThreadDrawer({
595
838
  onSelect: (threadId) => {
@@ -677,6 +920,9 @@ export class AgUiChat extends HTMLElement {
677
920
  /** Load the checkpoint panel with the runs that can actually be continued. */
678
921
  async #refreshCheckpoints(): Promise<void> {
679
922
  const index = this.#runs();
923
+ // Pushed at render rather than at connect: `formatRelativeTime` is a
924
+ // property, so a host may set it long after the element mounted.
925
+ this.#checkpoints.setRelativeTimeFormatter(this.formatRelativeTime);
680
926
  this.#checkpoints.setRuns(index === null ? [] : await index.continuable());
681
927
  }
682
928
 
@@ -930,7 +1176,7 @@ export class AgUiChat extends HTMLElement {
930
1176
  });
931
1177
  this.#confirmAbort = null;
932
1178
  this.#updateEmptyState();
933
- this.#messages.scrollTop = this.#messages.scrollHeight;
1179
+ this.#scroller.follow();
934
1180
  return answer;
935
1181
  }
936
1182
 
@@ -1242,8 +1488,17 @@ export class AgUiChat extends HTMLElement {
1242
1488
  this.#claimedNs = null;
1243
1489
  }
1244
1490
  this.#cancelRun();
1491
+ // The page offer listens on the host's document, not on anything of ours,
1492
+ // so nothing else would ever take it down.
1493
+ this.#pageQuote?.detach();
1494
+ this.#pageQuote = null;
1245
1495
  this.#attachTray?.dispose();
1246
1496
  this.#voice?.dispose();
1497
+ this.#scroller.dispose();
1498
+ if (this.#announceTimer !== null) {
1499
+ clearTimeout(this.#announceTimer);
1500
+ this.#announceTimer = null;
1501
+ }
1247
1502
  }
1248
1503
 
1249
1504
  /**
@@ -1369,6 +1624,126 @@ export class AgUiChat extends HTMLElement {
1369
1624
  this.#input.focus();
1370
1625
  }
1371
1626
 
1627
+ /**
1628
+ * Put `text` into the composer as a markdown quotation, and focus it.
1629
+ *
1630
+ * Deliberately **not** a send. Quoting is how a question narrows to one part
1631
+ * of an answer, so the quotation is the preamble and the question is what
1632
+ * comes next -- the caret is left after it, on its own line.
1633
+ *
1634
+ * This is also the seam for the half of this feature the component cannot
1635
+ * build: selection in the **host page**. A widget mounted beside a table can
1636
+ * be asked about a row, and nothing in a chat's own transcript can offer
1637
+ * that. A host reads its own selection, however it likes, and calls this.
1638
+ *
1639
+ * No-ops on text that is empty or only whitespace.
1640
+ */
1641
+ quote(text: string): void {
1642
+ const quoted = asQuote(text);
1643
+ if (quoted === "") {
1644
+ return;
1645
+ }
1646
+ // Appended after whatever is already typed, on a fresh paragraph: a second
1647
+ // quotation is a second thing being asked about, not a replacement for the
1648
+ // first. Trailing blank lines are dropped so repeated quoting does not
1649
+ // accumulate gaps.
1650
+ const current = this.#input.value.replace(/\s+$/, "");
1651
+ this.#input.value = current === "" ? quoted : `${current}\n\n${quoted}`;
1652
+ this.#autoGrow();
1653
+ this.#input.focus();
1654
+ const end = this.#input.value.length;
1655
+ this.#input.setSelectionRange(end, end);
1656
+ }
1657
+
1658
+ /**
1659
+ * Offer to quote what the user selects in the **host page**, not just in the
1660
+ * transcript. Returns a function that stops offering.
1661
+ *
1662
+ * The same select-then-offer gesture, over a table, a diff, a report -- the
1663
+ * surface the user actually works in, which is the half of quoting no hosted
1664
+ * chat can reach. Opt-in, because it listens on the host's document and that
1665
+ * is theirs to grant.
1666
+ *
1667
+ * Deliberately **not** a four-line recipe, which is how this shipped first
1668
+ * and was wrong: a page listener that quotes every settled selection appends
1669
+ * to the composer on every drag made to read, to copy or to fix a typo -- and
1670
+ * it cannot tell a selection in the page's prose from one inside the user's
1671
+ * own half-typed `<input>`, because Chrome reports a field's internal
1672
+ * selection as an ordinary range over the field's *wrapper*. See
1673
+ * {@link attachQuoteOffer} for the guards.
1674
+ *
1675
+ * Detached automatically when the element leaves the document; a host that
1676
+ * re-mounts it calls this again.
1677
+ */
1678
+ offerQuoteInPage(within: HTMLElement = document.body): () => void {
1679
+ this.#pageQuote?.detach();
1680
+ const offer = attachQuoteOffer({
1681
+ within,
1682
+ label: this.#strings.quoteSelection,
1683
+ exclude: this,
1684
+ onQuote: (text) => this.quote(text),
1685
+ });
1686
+ this.#pageQuote = offer;
1687
+ return () => {
1688
+ offer.detach();
1689
+ if (this.#pageQuote === offer) {
1690
+ this.#pageQuote = null;
1691
+ }
1692
+ };
1693
+ }
1694
+
1695
+ /** Whether the transcript offers to quote what the user selects. */
1696
+ #quoteEnabled(): boolean {
1697
+ return this.getAttribute("data-quote-selection") !== "false";
1698
+ }
1699
+
1700
+ /**
1701
+ * Offer to quote the settled selection, or retire the offer.
1702
+ *
1703
+ * `event` is passed for its coordinates and only those: they say which line
1704
+ * of a selection spanning several messages the offer should hang from. A
1705
+ * keyboard selection has none, and the first line is used instead.
1706
+ */
1707
+ #onSelectionSettled(event?: MouseEvent): void {
1708
+ if (!this.#quoteEnabled()) {
1709
+ return;
1710
+ }
1711
+ const near = event === undefined ? undefined : { x: event.clientX, y: event.clientY };
1712
+ const selected = quotableSelection(this.#messages, [this.#root], near);
1713
+ if (selected === null) {
1714
+ this.#hideQuote();
1715
+ return;
1716
+ }
1717
+ this.#quoting = selected.text;
1718
+ this.#placeQuote(selected.rect);
1719
+ }
1720
+
1721
+ /** Float the offer beside `rect`, kept inside the transcript's own box. */
1722
+ #placeQuote(rect: DOMRect): void {
1723
+ // Unhidden first: a hidden element measures zero, and its own size is what
1724
+ // decides whether it fits above the selection and how far to pull it left.
1725
+ this.#quoteButton.hidden = false;
1726
+ const wrap = this.#messagesWrap.getBoundingClientRect();
1727
+ const top = rect.top - wrap.top;
1728
+ // Above the selection by default, below it when there is no room --
1729
+ // selecting the first line of the transcript is the ordinary case, not an
1730
+ // edge one, and an offer clipped by the header is an offer nobody takes.
1731
+ const below = top < QUOTE_GAP + this.#quoteButton.offsetHeight;
1732
+ this.#quoteButton.dataset["below"] = String(below);
1733
+ this.#quoteButton.style.top = `${below ? rect.bottom - wrap.top + QUOTE_GAP : top - QUOTE_GAP}px`;
1734
+ // Centred on the selection, then pulled back by its own half-width so a
1735
+ // selection at either margin does not push the offer out of the panel.
1736
+ const half = this.#quoteButton.offsetWidth / 2;
1737
+ const centre = rect.left + rect.width / 2 - wrap.left;
1738
+ this.#quoteButton.style.left = `${Math.min(Math.max(centre, half), wrap.width - half)}px`;
1739
+ }
1740
+
1741
+ /** Retire the offer, and forget what it was pointing at. */
1742
+ #hideQuote(): void {
1743
+ this.#quoteButton.hidden = true;
1744
+ this.#quoting = "";
1745
+ }
1746
+
1372
1747
  /** The client-side upload size cap from `data-attachment-max-bytes`. */
1373
1748
  #attachmentMaxBytes(): number {
1374
1749
  const attr = this.getAttribute("data-attachment-max-bytes");
@@ -1986,6 +2361,18 @@ export class AgUiChat extends HTMLElement {
1986
2361
  /** Drop the in-memory run + transcript, leaving the thread id untouched. */
1987
2362
  #resetState(): void {
1988
2363
  this.#client = null;
2364
+ this.#clearTranscript();
2365
+ this.#initialMessages = [];
2366
+ }
2367
+
2368
+ /**
2369
+ * Wipe the rendered transcript and everything that indexes into it.
2370
+ *
2371
+ * Split from {@link #resetState} because a retry re-renders the transcript
2372
+ * while keeping the *client*: dropping the client there would take the
2373
+ * agent's message list with it, which is the thing being truncated.
2374
+ */
2375
+ #clearTranscript(): void {
1989
2376
  // Before the transcript goes: a render still queued would otherwise fire
1990
2377
  // against the wiped list and open a fresh bubble holding the discarded
1991
2378
  // conversation's last tokens.
@@ -1997,13 +2384,50 @@ export class AgUiChat extends HTMLElement {
1997
2384
  this.#serverSettled.clear();
1998
2385
  this.#cardElements.clear();
1999
2386
  this.#activityBlocks.clear();
2000
- this.#initialMessages = [];
2387
+ this.#retryOwner = null;
2001
2388
  this.#attachTray?.clear();
2002
2389
  // Keep the empty-state region; everything else clears.
2003
2390
  this.#messages.replaceChildren(this.#emptyWrap);
2004
2391
  this.#updateEmptyState();
2005
2392
  }
2006
2393
 
2394
+ /**
2395
+ * Ask the same question again and replace the answer.
2396
+ *
2397
+ * History is truncated to the most recent user message inclusive and the run
2398
+ * repeats, so the agent answers what it was asked rather than being told its
2399
+ * last answer was wrong. Returns `false` when there is nothing to retry or a
2400
+ * run is already in flight.
2401
+ *
2402
+ * Public because a host with its own message UI wants the same button, and
2403
+ * because the failed-run notice reaches it from outside the action row.
2404
+ *
2405
+ * **A retried turn re-runs its tools**, which for a page-driving agent is not
2406
+ * neutral: the previous attempt already clicked what it clicked, and this
2407
+ * does not undo it. Confirmation still applies, so a destructive tool asks
2408
+ * again -- unless the user waived it for this session.
2409
+ */
2410
+ async retryLastTurn(): Promise<boolean> {
2411
+ if (this.#running) {
2412
+ return false;
2413
+ }
2414
+ const client = this.#ensureClient();
2415
+ const kept = client.truncateToLastUser();
2416
+ if (kept === null) {
2417
+ return false;
2418
+ }
2419
+ // Re-render between the truncation and the run: the kept turns replay as
2420
+ // restored history (static, no entrance animation), and only the new answer
2421
+ // arrives live. Streaming into the old transcript would put the new answer
2422
+ // underneath the one it replaces.
2423
+ this.#clearTranscript();
2424
+ for (const message of kept) {
2425
+ this.#renderHistoricMessage(message);
2426
+ }
2427
+ await client.resume();
2428
+ return true;
2429
+ }
2430
+
2007
2431
  /** Switch the active conversation to an existing thread and replay it. */
2008
2432
  async #switchThread(threadId: string): Promise<void> {
2009
2433
  if (threadId === this.#threadId) {
@@ -2034,6 +2458,7 @@ export class AgUiChat extends HTMLElement {
2034
2458
 
2035
2459
  /** Reload the drawer's thread list, marking the active thread. */
2036
2460
  async #refreshDrawer(): Promise<void> {
2461
+ this.#drawer.setRelativeTimeFormatter(this.formatRelativeTime);
2037
2462
  this.#drawer.setThreads(await this.conversationStore.listThreads(), this.#threadId);
2038
2463
  }
2039
2464
 
@@ -2139,7 +2564,9 @@ export class AgUiChat extends HTMLElement {
2139
2564
  // transcript mounts at once, so animating every bubble's text in
2140
2565
  // parallel looks wrong. Mark it so the fade CSS skips it, and don't
2141
2566
  // wrap words.
2142
- this.appendMessage(MESSAGE_ROLE.ASSISTANT, text).classList.add("message--restored");
2567
+ const restoredBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, text);
2568
+ restoredBubble.classList.add("message--restored");
2569
+ this.#attachActions(restoredBubble);
2143
2570
  }
2144
2571
  // Narrowed rather than trusted, for the same reason `messageAttachments`
2145
2572
  // narrows the neighbouring field: anything that throws in this loop aborts
@@ -2178,8 +2605,8 @@ export class AgUiChat extends HTMLElement {
2178
2605
  // chart's data is in the transcript already and survives a reload. Only
2179
2606
  // the drawing had to be put back.
2180
2607
  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);
2608
+ if (typeof activity.activityType === "string") {
2609
+ this.#drawActivity(message.id, activity.activityType, activity.content);
2183
2610
  }
2184
2611
  return;
2185
2612
  }
@@ -2254,7 +2681,14 @@ export class AgUiChat extends HTMLElement {
2254
2681
  this.#messages.appendChild(bubble);
2255
2682
  }
2256
2683
  this.#updateEmptyState();
2257
- this.#messages.scrollTop = this.#messages.scrollHeight;
2684
+ // A user bubble means someone just pressed Send, which is as deliberate as
2685
+ // pressing the jump button -- so it goes to the bottom even if they had
2686
+ // scrolled away to re-read something before typing.
2687
+ if (role === MESSAGE_ROLE.USER) {
2688
+ this.#scroller.jump();
2689
+ } else {
2690
+ this.#scroller.follow();
2691
+ }
2258
2692
  return bubble;
2259
2693
  }
2260
2694
 
@@ -2278,9 +2712,6 @@ export class AgUiChat extends HTMLElement {
2278
2712
  }
2279
2713
 
2280
2714
  #render(): void {
2281
- const style = document.createElement("style");
2282
- style.textContent = STYLES;
2283
-
2284
2715
  this.#chat.className = "chat";
2285
2716
  this.#chat.setAttribute("part", "panel");
2286
2717
 
@@ -2355,11 +2786,68 @@ export class AgUiChat extends HTMLElement {
2355
2786
 
2356
2787
  this.#messages.className = "messages";
2357
2788
  this.#messages.setAttribute("part", "messages");
2358
- // Screen readers announce streamed messages as they arrive.
2359
2789
  this.#messages.setAttribute("role", "log");
2360
- this.#messages.setAttribute("aria-live", "polite");
2790
+ // NOT a live region. The streaming bubble's innerHTML is replaced inside
2791
+ // this element on every animation frame, and `role="log"` already implies
2792
+ // polite announcement whose default `aria-relevant` includes text
2793
+ // additions -- so a screen reader was asked to re-announce the whole answer
2794
+ // tens of times as it streamed. `aria-live="off"` is an explicit override
2795
+ // of the role's implicit value, which is why the role can stay: the log
2796
+ // semantics are what let the transcript be navigated as one, and only the
2797
+ // announcing is the defect. Status goes to #announcer instead.
2798
+ this.#messages.setAttribute("aria-live", "off");
2361
2799
  this.#messages.setAttribute("aria-label", this.#strings.conversation);
2362
2800
 
2801
+ this.#jumpButton.className = "jump-latest";
2802
+ this.#jumpButton.type = "button";
2803
+ this.#jumpButton.setAttribute("part", "jump-latest");
2804
+ this.#jumpButton.textContent = this.#strings.jumpToLatest;
2805
+ this.#jumpButton.addEventListener("click", () => {
2806
+ this.#scroller.jump();
2807
+ });
2808
+
2809
+ this.#quoteButton.className = "quote-selection";
2810
+ this.#quoteButton.type = "button";
2811
+ this.#quoteButton.setAttribute("part", "quote-selection");
2812
+ this.#quoteButton.textContent = this.#strings.quoteSelection;
2813
+ this.#quoteButton.hidden = true;
2814
+ // `mousedown` rather than `click`: pressing anywhere else collapses the
2815
+ // selection first, and by the time a click lands there is nothing left to
2816
+ // quote. Preventing the default keeps the selection alive long enough to
2817
+ // read it.
2818
+ this.#quoteButton.addEventListener("mousedown", (event) => {
2819
+ event.preventDefault();
2820
+ });
2821
+ this.#quoteButton.addEventListener("click", () => {
2822
+ this.quote(this.#quoting);
2823
+ window.getSelection()?.removeAllRanges();
2824
+ this.#hideQuote();
2825
+ });
2826
+
2827
+ // A settled selection, by either input. `mouseup` rather than
2828
+ // `selectionchange` so the offer does not chase the pointer mid-drag; the
2829
+ // second half of the same gesture, `mousedown`, retires the previous offer
2830
+ // before the new selection exists.
2831
+ this.#messages.addEventListener("mouseup", (event) => this.#onSelectionSettled(event));
2832
+ this.#messages.addEventListener("keyup", () => this.#onSelectionSettled());
2833
+ this.#messages.addEventListener("mousedown", () => this.#hideQuote());
2834
+
2835
+ // Built here rather than at field initialisation: the viewport has to exist
2836
+ // and the observer has to have something to observe.
2837
+ this.#scroller = createStickToBottom({
2838
+ viewport: this.#messages,
2839
+ onMissedContent: (missed) => {
2840
+ this.#jumpButton.dataset["missed"] = String(missed);
2841
+ },
2842
+ });
2843
+
2844
+ this.#announcer.className = "sr-only";
2845
+ this.#announcer.setAttribute("role", "status");
2846
+ this.#announcer.setAttribute("aria-live", "polite");
2847
+ // Atomic: each announcement replaces the last and is read whole. Without
2848
+ // it a reader may announce only the changed words between two statuses.
2849
+ this.#announcer.setAttribute("aria-atomic", "true");
2850
+
2363
2851
  // Empty-state region: a host slot at the top of the list, hidden as soon as
2364
2852
  // anything renders.
2365
2853
  this.#emptyWrap.className = "empty";
@@ -2450,9 +2938,14 @@ export class AgUiChat extends HTMLElement {
2450
2938
  inputRow.append(composer, this.#fileInput);
2451
2939
  // Skill surfaces sit just above the input: palette (opens on `/`), chips,
2452
2940
  // the missing-placeholder hint, and the pending-attachments tray.
2941
+ this.#messagesWrap.className = "messages-wrap";
2942
+ // Sibling of the list inside a shared box, not a child of it: the
2943
+ // affordance offering to scroll must not scroll away with the content.
2944
+ this.#messagesWrap.append(this.#messages, this.#jumpButton, this.#quoteButton);
2945
+
2453
2946
  this.#chat.append(
2454
2947
  header,
2455
- this.#messages,
2948
+ this.#messagesWrap,
2456
2949
  this.#skillsMenu.palette,
2457
2950
  this.#skillsMenu.chips,
2458
2951
  this.#skillHint,
@@ -2517,7 +3010,68 @@ export class AgUiChat extends HTMLElement {
2517
3010
  label: this.#strings.resizePanel,
2518
3011
  }),
2519
3012
  );
2520
- this.#root.append(style, this.#chat, this.#launcher);
3013
+ this.#adoptStyles();
3014
+ this.#root.append(this.#announcer, this.#chat, this.#launcher);
3015
+ }
3016
+
3017
+ /**
3018
+ * Attach the stylesheet without an inline `<style>` element.
3019
+ *
3020
+ * A host with a strict `style-src` and no `'unsafe-inline'` drops an injected
3021
+ * `<style>` silently: the component mounts, functions, and renders completely
3022
+ * unstyled, with nothing in the console to point at. `adoptedStyleSheets`
3023
+ * carries no inline-style origin, so it is unaffected by that policy.
3024
+ *
3025
+ * The sheet is constructed **per instance** rather than shared at module
3026
+ * scope. A shared sheet would additionally avoid re-parsing the stylesheet
3027
+ * once per mounted element, which is what `adoptedStyleSheets` is usually
3028
+ * reached for -- but a module-level singleton is exactly what this package
3029
+ * forbids, and the CSP defect is fixed either way. Per instance is no worse
3030
+ * than the `<style>` element it replaces, which also parsed once per mount.
3031
+ *
3032
+ * No fallback: constructible `CSSStyleSheet` is Chrome 73, Firefox 101 and
3033
+ * Safari 16.4, all below this package's declared Safari 17 runtime target. A
3034
+ * guard here would be code no supported browser can reach, and the only way
3035
+ * to keep it would be to exempt it from the coverage gate.
3036
+ */
3037
+ /**
3038
+ * Say one short thing to a screen reader, without touching the transcript.
3039
+ *
3040
+ * The transcript cannot do this job. It is rewritten on every animation
3041
+ * frame while an answer streams, so as a live region it re-announced the
3042
+ * whole answer tens of times per turn -- not merely unhelpful but actively
3043
+ * hostile. The published fix for this exact bug (Microsoft's Bot Framework
3044
+ * WebChat #3236) is architectural rather than a matter of tuning attributes:
3045
+ * demote the visible transcript out of live-region duty and put one
3046
+ * synthesised status per event into a separate invisible region. MDN and
3047
+ * Scott O'Hara prescribe the same empty-region-then-inject shape.
3048
+ *
3049
+ * Roughly four calls land per turn -- responding, answered, a card is waiting,
3050
+ * stopped or failed -- so the user is told what happened and reads the answer
3051
+ * itself by navigating the log, at their own pace, rather than having it
3052
+ * shouted at them a token at a time.
3053
+ *
3054
+ * **The clear is load-bearing, twice.** A reader announces a live region when
3055
+ * its content *changes*, so setting the same string twice running -- two turns
3056
+ * in a row both starting -- is not a change and is silently not announced.
3057
+ * Emptying first makes the next set a change again. It also stops a stale
3058
+ * status being read out when a reader later lands on the region.
3059
+ */
3060
+ #announce(message: string): void {
3061
+ if (this.#announceTimer !== null) {
3062
+ clearTimeout(this.#announceTimer);
3063
+ }
3064
+ this.#announcer.textContent = message;
3065
+ this.#announceTimer = setTimeout(() => {
3066
+ this.#announceTimer = null;
3067
+ this.#announcer.textContent = "";
3068
+ }, ANNOUNCE_CLEAR_MS);
3069
+ }
3070
+
3071
+ #adoptStyles(): void {
3072
+ const sheet = new CSSStyleSheet();
3073
+ sheet.replaceSync(STYLES);
3074
+ this.#root.adoptedStyleSheets = [sheet];
2521
3075
  }
2522
3076
 
2523
3077
  /**
@@ -2877,15 +3431,75 @@ export class AgUiChat extends HTMLElement {
2877
3431
  );
2878
3432
  }
2879
3433
 
2880
- /** Whether ``call`` should be gated behind the confirmation card. */
2881
- async #needsConfirmation(call: AgUiToolCall, tool: ClientTool): Promise<boolean> {
3434
+ /**
3435
+ * Give a finished assistant bubble its action row, and hand it Retry.
3436
+ *
3437
+ * Every finished bubble gets copy and feedback -- both are safe on a message
3438
+ * of any age. Retry moves to the newest, because it is the only one where
3439
+ * re-running answers the same question rather than rewriting history.
3440
+ */
3441
+ #attachActions(bubble: HTMLDivElement, options: { rateable?: boolean } = {}): void {
3442
+ attachMessageActions(bubble, {
3443
+ strings: this.#strings,
3444
+ // Read at click time, not captured: a bubble rendered from markdown holds
3445
+ // its text in the DOM, and that is what the user sees and means to copy.
3446
+ text: () => bubble.textContent as string,
3447
+ // A failed run is copyable -- error text is what people paste into a bug
3448
+ // report -- but not rateable: a rating is a statement about an *answer*,
3449
+ // and mixing "the connection dropped" into that signal makes the host's
3450
+ // feedback data say less than it did before.
3451
+ ...(options.rateable === false
3452
+ ? {}
3453
+ : {
3454
+ onFeedback: (rating: "up" | "down") => {
3455
+ this.dispatchEvent(
3456
+ new CustomEvent<FeedbackDetail>(FEEDBACK_EVENT, {
3457
+ detail: { content: bubble.textContent as string, rating },
3458
+ bubbles: true,
3459
+ composed: true,
3460
+ }),
3461
+ );
3462
+ },
3463
+ }),
3464
+ });
3465
+ this.#moveRetryTo(messageActionBar(bubble, this.#strings));
3466
+ }
3467
+
3468
+ /** Move the Retry button onto `bar`, taking it off whoever held it. */
3469
+ #moveRetryTo(bar: HTMLElement): void {
3470
+ this.#retryOwner?.querySelector(".message-action--retry")?.remove();
3471
+ const retry = messageActionButton("retry", this.#strings.retryMessage, "\u21BB");
3472
+ retry.addEventListener("click", () => {
3473
+ void this.retryLastTurn();
3474
+ });
3475
+ // First in the row: it is the action a reader reaches for when the answer
3476
+ // was wrong, which is when they are least inclined to hunt for a control.
3477
+ bar.prepend(retry);
3478
+ this.#retryOwner = bar;
3479
+ }
3480
+
3481
+ /**
3482
+ * Which rule gates `call`, or `null` when it runs straight through.
3483
+ *
3484
+ * The rule, rather than a bare boolean, because it decides whether the user
3485
+ * may *waive* the prompt for the rest of the session. Only the default
3486
+ * `x-destructive` gate is waivable: `confirmPredicate` is documented as
3487
+ * authoritative, so letting one click retire it would silently defeat a host
3488
+ * policy — and the session allowlist is consulted on the same path it can
3489
+ * be added from, so the button is never offered where honouring it would be
3490
+ * refused.
3491
+ */
3492
+ async #confirmationRule(call: AgUiToolCall, tool: ClientTool): Promise<ConfirmationRule | null> {
2882
3493
  if (this.autoConfirm) {
2883
- return false;
3494
+ return null;
2884
3495
  }
2885
3496
  if (this.confirmPredicate !== null) {
2886
- return (await this.confirmPredicate(call.name, call.args)) === true;
3497
+ return (await this.confirmPredicate(call.name, call.args)) === true ? "predicate" : null;
3498
+ }
3499
+ if (this.#sessionApproved.has(call.name)) {
3500
+ return null;
2887
3501
  }
2888
- return isDestructive(tool.parameters);
3502
+ return isDestructive(tool.parameters) ? "destructive" : null;
2889
3503
  }
2890
3504
 
2891
3505
  async #executeTool(call: AgUiToolCall): Promise<ToolExecution | null> {
@@ -2944,7 +3558,8 @@ export class AgUiChat extends HTMLElement {
2944
3558
  this.#showPending();
2945
3559
  return { content: `Error: ${message}`, error: message };
2946
3560
  }
2947
- if (await this.#needsConfirmation(call, tool)) {
3561
+ const rule = await this.#confirmationRule(call, tool);
3562
+ if (rule !== null) {
2948
3563
  const request: ConfirmationRequest = { toolName: call.name, args: call.args };
2949
3564
  const confirmText = tool.parameters[X_CONFIRM_KEY];
2950
3565
  if (typeof confirmText === "string") {
@@ -2960,9 +3575,13 @@ export class AgUiChat extends HTMLElement {
2960
3575
  const decision = requestConfirmation(this.#ensureGroup(), request, {
2961
3576
  signal: this.#confirmAbort.signal,
2962
3577
  strings: this.#strings,
3578
+ // Offered only where it can be honoured -- see `#confirmationRule`.
3579
+ ...(rule === "destructive"
3580
+ ? { onAlwaysAllow: () => this.#sessionApproved.add(call.name) }
3581
+ : {}),
2963
3582
  });
2964
3583
  this.#updateEmptyState();
2965
- this.#messages.scrollTop = this.#messages.scrollHeight;
3584
+ this.#scroller.follow();
2966
3585
  const accepted = await decision;
2967
3586
  this.#confirmAbort = null;
2968
3587
  card.recordDecision(accepted ? "approved" : "declined");
@@ -3044,6 +3663,13 @@ export class AgUiChat extends HTMLElement {
3044
3663
  ): Promise<Record<string, InterruptResponse>> {
3045
3664
  // One controller covers the whole batch: a single Stop denies all of them.
3046
3665
  this.#confirmAbort = new AbortController();
3666
+ // The run has stopped and is waiting on a person. Nothing else on screen
3667
+ // says so to a screen reader: the cards appear inside the transcript, which
3668
+ // is deliberately not a live region, so without this the run simply goes
3669
+ // quiet and the user has no reason to go looking.
3670
+ this.#announce(
3671
+ this.#strings.announceAwaitingDecision.replace("{count}", String(interrupts.length)),
3672
+ );
3047
3673
  this.#hidePending();
3048
3674
  const signal = this.#confirmAbort.signal;
3049
3675
  const answered = await Promise.all(
@@ -3061,6 +3687,14 @@ export class AgUiChat extends HTMLElement {
3061
3687
  if (toolName !== null && toolName !== undefined) {
3062
3688
  request.toolName = toolName;
3063
3689
  }
3690
+ // Offered only where it can be honoured: the host has said its agent
3691
+ // accepts `editedArgs`, and this interrupt named a call whose arguments
3692
+ // we still hold.
3693
+ let editedArgs: Record<string, unknown> | undefined;
3694
+ const editable = this.approveWithEdits && card !== undefined;
3695
+ if (editable) {
3696
+ request.args = card.args;
3697
+ }
3064
3698
  card?.mark(TOOL_CALL_STATUS.DEFERRED);
3065
3699
  // A host-supplied renderer takes full control of the approval UI. The
3066
3700
  // built-in card renders into the gated call's own card, falling back to
@@ -3071,6 +3705,13 @@ export class AgUiChat extends HTMLElement {
3071
3705
  : await requestApproval(card?.approvalSlot ?? this.#ensureGroup(), request, {
3072
3706
  signal,
3073
3707
  strings: this.#strings,
3708
+ ...(editable
3709
+ ? {
3710
+ onEdit: (args: Record<string, unknown>) => {
3711
+ editedArgs = args;
3712
+ },
3713
+ }
3714
+ : {}),
3074
3715
  });
3075
3716
  // Same annotation as the client-side confirmation gate. Without it the
3076
3717
  // two gates read differently for the same act: a locally-confirmed call
@@ -3085,16 +3726,21 @@ export class AgUiChat extends HTMLElement {
3085
3726
  // now rather than leaving it hanging until the onSettled sweep.
3086
3727
  card?.settle(TOOL_CALL_STATUS.DECLINED, this.#strings.declinedAction);
3087
3728
  }
3088
- return { id: interrupt.id, approved };
3729
+ return { id: interrupt.id, approved, editedArgs };
3089
3730
  }),
3090
3731
  );
3091
3732
  this.#updateEmptyState();
3092
- this.#messages.scrollTop = this.#messages.scrollHeight;
3733
+ this.#scroller.follow();
3093
3734
  this.#confirmAbort = null;
3094
3735
  const responses: Record<string, InterruptResponse> = {};
3095
- for (const { id, approved } of answered) {
3736
+ for (const { id, approved, editedArgs } of answered) {
3737
+ // `editedArgs` rides only when the user actually changed something, so a
3738
+ // server can tell "approved as proposed" from "approved, but like this".
3096
3739
  responses[id] = approved
3097
- ? { status: "resolved", payload: { approved: true } }
3740
+ ? {
3741
+ status: "resolved",
3742
+ payload: editedArgs === undefined ? { approved: true } : { approved: true, editedArgs },
3743
+ }
3098
3744
  : { status: "cancelled" };
3099
3745
  }
3100
3746
  return responses;
@@ -3103,6 +3749,12 @@ export class AgUiChat extends HTMLElement {
3103
3749
  #handlers(): AgUiClientHandlers {
3104
3750
  return {
3105
3751
  onRunStart: () => {
3752
+ // Per *round*, so guard on the turn: a run that calls three tools fires
3753
+ // this three times and the user needs telling once.
3754
+ if (!this.#running) {
3755
+ this.#announcedOutcome = false;
3756
+ this.#announce(this.#strings.announceResponding);
3757
+ }
3106
3758
  this.#setRunning(true);
3107
3759
  // Open the answer group on the turn's first run so the pending
3108
3760
  // indicator (and everything after) lands inside the well. Idempotent:
@@ -3143,6 +3795,7 @@ export class AgUiChat extends HTMLElement {
3143
3795
  this.#revealWords(bubble);
3144
3796
  }
3145
3797
  attachCopyButtons(bubble, this.#strings);
3798
+ this.#attachActions(bubble);
3146
3799
  this.#endStream();
3147
3800
  this.#noteUnread();
3148
3801
  },
@@ -3162,25 +3815,47 @@ export class AgUiChat extends HTMLElement {
3162
3815
  this.#cardFor(call);
3163
3816
  },
3164
3817
  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) {
3172
- return;
3173
- }
3174
- const removed = compactionRemoved(content);
3175
- if (removed === null) {
3818
+ this.#drawActivity(messageId, activityType, content);
3819
+ },
3820
+ onCustomEvent: (name, value) => {
3821
+ if (name === INVALIDATE_CUSTOM_NAME) {
3822
+ this.#dispatchInvalidation(value);
3176
3823
  return;
3177
3824
  }
3178
- this.#appendNotice(
3179
- "🗜",
3180
- this.#strings.historyCompacted.replace("{count}", String(removed)),
3181
- "compaction",
3825
+ // Straight out to the host page, uninterpreted. This is the imperative
3826
+ // carrier: whatever it means, it means it to the page, not to the
3827
+ // transcript -- so it is dispatched and deliberately not rendered,
3828
+ // persisted or replayed. A host that does not know the name simply has
3829
+ // no listener, which is the graceful outcome the open field is for.
3830
+ this.dispatchEvent(
3831
+ new CustomEvent<CustomAgentDetail>(CUSTOM_AGENT_EVENT, {
3832
+ detail: { name, value },
3833
+ bubbles: true,
3834
+ composed: true,
3835
+ }),
3182
3836
  );
3183
3837
  },
3838
+ onMessagesSnapshot: () => {
3839
+ // Honoured for persistence and announced, not re-rendered.
3840
+ //
3841
+ // The store follows the server, because the server is authoritative
3842
+ // about what the conversation *is* -- and it would follow it anyway:
3843
+ // `@ag-ui/client` replaces `agent.messages` before any subscriber runs,
3844
+ // and the run loop persists `agent.messages`. What was wrong was that
3845
+ // it happened in silence, so the screen and the store disagreed and
3846
+ // nobody found out until a reload served a transcript they had never
3847
+ // seen. That is not reportable as a bug; it is reportable as "the chat
3848
+ // lost my messages".
3849
+ //
3850
+ // Re-rendering from the snapshot was the other candidate and is
3851
+ // declined: a snapshot can land mid-run, and rebuilding the transcript
3852
+ // then would destroy the in-flight run's own UI state -- the streaming
3853
+ // bubble, the open answer group, and every tool card keyed by call id,
3854
+ // some of which are still waiting on results. Telling the reader costs
3855
+ // none of that, and this is the same answer the same question already
3856
+ // got for compaction, one handler up.
3857
+ this.#appendNotice("\u{1F504}", this.#strings.historyReplaced, "history-replaced");
3858
+ },
3184
3859
  onToolResult: (toolCallId, content) => {
3185
3860
  const card = this.#toolCards.get(toolCallId);
3186
3861
  if (card === undefined) {
@@ -3204,9 +3879,7 @@ export class AgUiChat extends HTMLElement {
3204
3879
  this.#showPending();
3205
3880
  },
3206
3881
  onActivityChanged: (messageId, activityType, content) => {
3207
- if (activityType === CHART_ACTIVITY_TYPE && this.#chartActivity) {
3208
- this.#drawActivityChart(messageId, content);
3209
- }
3882
+ this.#drawActivity(messageId, activityType, content);
3210
3883
  },
3211
3884
  onRunEnd: () => {
3212
3885
  // Per-round end; the button stays on Stop until the whole interaction
@@ -3215,19 +3888,38 @@ export class AgUiChat extends HTMLElement {
3215
3888
  this.#endStream();
3216
3889
  },
3217
3890
  onError: (message) => {
3891
+ this.#announcedOutcome = true;
3892
+ this.#announce(this.#strings.announceFailed);
3218
3893
  this.#hidePending();
3219
- this.#revealWords(this.appendMessage(MESSAGE_ROLE.ASSISTANT, `⚠️ ${message}`));
3894
+ const bubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, `⚠️ ${message}`);
3895
+ bubble.classList.add("message--failed");
3896
+ // A failure is the one message whose action row is only worth having
3897
+ // for Retry: there is nothing here worth copying and nothing to rate.
3898
+ // A dropped connection with no way back was the whole of the gap --
3899
+ // uploads had a retry and runs did not.
3900
+ //
3901
+ // Not a `run-notice`: that element's contract is that it "never
3902
+ // settles, takes no action, and carries no controls", and is explicitly
3903
+ // "distinct from an error, which is a failure". This is a failure, so
3904
+ // it stays an error and gains the control instead.
3905
+ this.#attachActions(bubble, { rateable: false });
3906
+ this.#revealWords(bubble);
3220
3907
  this.#endStream();
3221
3908
  },
3222
3909
  onCancelled: () => {
3223
3910
  // Deliberate stop, not a failure: keep whatever partial text already
3224
3911
  // streamed and add a muted note instead of an error bubble.
3912
+ this.#announcedOutcome = true;
3913
+ this.#announce(this.#strings.announceStopped);
3225
3914
  this.#hidePending();
3226
3915
  this.#appendStoppedNote();
3227
3916
  this.#endStream();
3228
3917
  },
3229
3918
  onSettled: () => {
3230
3919
  // Terminal guarantee: whatever path ended the run, return to rest.
3920
+ if (!this.#announcedOutcome) {
3921
+ this.#announce(this.#strings.announceAnswerReady);
3922
+ }
3231
3923
  this.#hidePending();
3232
3924
  this.#setRunning(false);
3233
3925
  this.#endStream();
@@ -3268,9 +3960,48 @@ export class AgUiChat extends HTMLElement {
3268
3960
  side: this.#serverSettled.has(id) ? "server" : "client",
3269
3961
  }));
3270
3962
  this.#runTools = [];
3963
+ const invalidated = [...this.#runInvalidated];
3964
+ this.#runInvalidated = new Set<string>();
3271
3965
  this.dispatchEvent(
3272
3966
  new CustomEvent<RunFinishedDetail>(RUN_FINISHED_EVENT, {
3273
- detail: { tools },
3967
+ detail: { tools, invalidated },
3968
+ bubbles: true,
3969
+ composed: true,
3970
+ }),
3971
+ );
3972
+ }
3973
+
3974
+ /**
3975
+ * Route one invalidation to the host, and remember it for the run summary.
3976
+ *
3977
+ * Dispatched immediately rather than only at the end, because that is what
3978
+ * makes a long multi-step run feel live -- the list refreshes as the third of
3979
+ * eight writes lands. The accumulated set rides
3980
+ * {@link RUN_FINISHED_EVENT} as well, so a host that would rather refetch once
3981
+ * upgrades by reading one extra field instead of adding a listener.
3982
+ *
3983
+ * Nothing is rendered, persisted or replayed. An invalidation is an
3984
+ * imperative: it has no place in the transcript and no meaning once acted on,
3985
+ * and replaying one on every thread load would be a refetch storm. That is the
3986
+ * whole reason the server sends it as `CUSTOM` rather than as an activity.
3987
+ */
3988
+ #dispatchInvalidation(value: unknown): void {
3989
+ const payload = (value ?? {}) as { keys?: unknown; reason?: unknown };
3990
+ // Defensive about the payload, not about the name: `value` is typed
3991
+ // `unknown` by the protocol, so a server can put anything there, and a
3992
+ // malformed announcement must not take the run down with it.
3993
+ const keys = Array.isArray(payload.keys)
3994
+ ? payload.keys.filter((key): key is string => typeof key === "string")
3995
+ : [];
3996
+ if (keys.length === 0) {
3997
+ return;
3998
+ }
3999
+ for (const key of keys) {
4000
+ this.#runInvalidated.add(key);
4001
+ }
4002
+ this.dispatchEvent(
4003
+ new CustomEvent<InvalidateDetail>(INVALIDATE_EVENT, {
4004
+ detail: { keys, reason: typeof payload.reason === "string" ? payload.reason : null },
3274
4005
  bubbles: true,
3275
4006
  composed: true,
3276
4007
  }),
@@ -3286,7 +4017,7 @@ export class AgUiChat extends HTMLElement {
3286
4017
  note.textContent = this.#strings.stopped;
3287
4018
  this.#ensureGroup().appendChild(note);
3288
4019
  this.#updateEmptyState();
3289
- this.#messages.scrollTop = this.#messages.scrollHeight;
4020
+ this.#scroller.follow();
3290
4021
  }
3291
4022
 
3292
4023
  /**
@@ -3311,7 +4042,7 @@ export class AgUiChat extends HTMLElement {
3311
4042
  this.#pending = pending;
3312
4043
  this.#ensureGroup().appendChild(pending);
3313
4044
  this.#updateEmptyState();
3314
- this.#messages.scrollTop = this.#messages.scrollHeight;
4045
+ this.#scroller.follow();
3315
4046
  }
3316
4047
 
3317
4048
  /** Remove the pending indicator if shown. */
@@ -3331,7 +4062,7 @@ export class AgUiChat extends HTMLElement {
3331
4062
  const group = this.#ensureGroup();
3332
4063
  group.insertBefore(this.#thoughts.element, group.firstChild);
3333
4064
  this.#updateEmptyState();
3334
- this.#messages.scrollTop = this.#messages.scrollHeight;
4065
+ this.#scroller.follow();
3335
4066
  }
3336
4067
  return this.#thoughts;
3337
4068
  }
@@ -3388,7 +4119,7 @@ export class AgUiChat extends HTMLElement {
3388
4119
  this.#streamBuffer = buffer;
3389
4120
  const bubble = this.#openStream();
3390
4121
  bubble.innerHTML = renderMarkdown(buffer, { allowImages: this.allowImages });
3391
- this.#messages.scrollTop = this.#messages.scrollHeight;
4122
+ this.#scroller.follow();
3392
4123
  return bubble;
3393
4124
  }
3394
4125
 
@@ -3439,7 +4170,7 @@ export class AgUiChat extends HTMLElement {
3439
4170
  #appendNotice(icon: string, text: string, kind: string): void {
3440
4171
  this.#ensureGroup().appendChild(renderRunNotice(icon, text, kind));
3441
4172
  this.#updateEmptyState();
3442
- this.#messages.scrollTop = this.#messages.scrollHeight;
4173
+ this.#scroller.follow();
3443
4174
  }
3444
4175
 
3445
4176
  /**
@@ -3461,9 +4192,20 @@ export class AgUiChat extends HTMLElement {
3461
4192
  * arrives is not something to switch on for everybody.
3462
4193
  */
3463
4194
  enableCharts(routes: readonly ("tool" | "activity")[] = ["tool", "activity"]): void {
3464
- const first = !this.#chartActivity && !this.#toolRegistry.has(CHART_TOOL_NAME);
4195
+ const first =
4196
+ !this.#activityRenderers.has(CHART_ACTIVITY_TYPE) && !this.#toolRegistry.has(CHART_TOOL_NAME);
3465
4197
  if (routes.includes("activity")) {
3466
- this.#chartActivity = true;
4198
+ // The chart is a registration like any host's, not a privileged branch.
4199
+ // If the built-in cannot be expressed through the seam, the seam is not
4200
+ // one -- so this is the test as much as the feature.
4201
+ this.registerActivityRenderer({
4202
+ type: CHART_ACTIVITY_TYPE,
4203
+ render: (content) => {
4204
+ const spec = chartSpecFrom(content);
4205
+ return spec === null ? null : renderChart(spec);
4206
+ },
4207
+ removedNotice: this.#strings.chartUndrawable,
4208
+ });
3467
4209
  }
3468
4210
  if (routes.includes("tool")) {
3469
4211
  this.registerTool(createChartTool());
@@ -3511,37 +4253,140 @@ export class AgUiChat extends HTMLElement {
3511
4253
  this.#afterTranscriptGrew();
3512
4254
  }
3513
4255
 
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);
4256
+ /**
4257
+ * Teach this element to draw one kind of AG-UI activity.
4258
+ *
4259
+ * `activity_type` is one of exactly two fields the protocol leaves an open
4260
+ * string, and it is the **content** one: an activity is materialised into a
4261
+ * message, persisted with the thread, and replayed on every restore. Its
4262
+ * sibling `CUSTOM` carries an imperative and is dispatched to the page
4263
+ * instead ({@link CUSTOM_AGENT_EVENT}).
4264
+ *
4265
+ * That asymmetry decides which carrier a server should use. Content has a
4266
+ * place in the conversation and should come back; an imperative has no place
4267
+ * and no meaning once acted on.
4268
+ *
4269
+ * ```js
4270
+ * chat.registerActivityRenderer({
4271
+ * type: "build_status",
4272
+ * render: (content) => {
4273
+ * const el = document.createElement("div");
4274
+ * el.textContent = `Build ${content.status}`;
4275
+ * return el;
4276
+ * },
4277
+ * });
4278
+ * ```
4279
+ *
4280
+ * Registering a type twice replaces the earlier renderer, so a host can
4281
+ * override a built-in -- `chart` and `compaction` are registrations like any
4282
+ * other, not privileged branches.
4283
+ *
4284
+ * ⚠ `render` runs again on every thread load. See {@link ActivityRenderer}
4285
+ * for what that requires of it.
4286
+ */
4287
+ registerActivityRenderer(registration: ActivityRegistration): void {
4288
+ this.#activityRenderers.set(registration.type, registration);
4289
+ this.#unhandledActivityTypes.delete(registration.type);
4290
+ }
4291
+
4292
+ /**
4293
+ * Activity types that arrived with nobody registered to draw them.
4294
+ *
4295
+ * Deliberately the only trace an unhandled activity leaves. Ignoring an
4296
+ * unknown name is the protocol's own answer and the whole point of an open
4297
+ * field, so warning would fire on every forward-compatible server -- but
4298
+ * "nothing happened and nothing was said" is impossible to debug, so the set
4299
+ * is readable. Accumulates for the element's lifetime, across threads.
4300
+ */
4301
+ get unhandledActivityTypes(): readonly string[] {
4302
+ return [...this.#unhandledActivityTypes];
4303
+ }
4304
+
4305
+ /**
4306
+ * Draw, replace or remove one activity, whatever kind it is.
4307
+ *
4308
+ * The single path for all three routes an activity arrives by -- pushed
4309
+ * (`onActivity`), patched (`onActivityChanged`) and replayed from history --
4310
+ * which is why the renderer contract has to be pure: the same content is
4311
+ * drawn again on every thread load.
4312
+ *
4313
+ * An unregistered type draws nothing and says nothing. That is the protocol's
4314
+ * own answer -- a client that does not know a name ignores the event -- and a
4315
+ * warning here would fire on every well-behaved forward-compatible server,
4316
+ * while a placeholder would put the protocol's growth in the user's face.
4317
+ * {@link unhandledActivityTypes} is the way to find out what arrived.
4318
+ */
4319
+ #drawActivity(messageId: string, activityType: string, content: unknown): void {
4320
+ const registration = this.#activityRenderers.get(activityType);
4321
+ if (registration === undefined) {
4322
+ this.#unhandledActivityTypes.add(activityType);
4323
+ return;
4324
+ }
4325
+ let node: Node | null;
4326
+ try {
4327
+ node = registration.render(content);
4328
+ } catch (error) {
4329
+ // `render` is consumer code and this runs inside the history replay,
4330
+ // where a throw abandons the loop and takes every later turn of the
4331
+ // transcript with it -- silently, and again on every reload. One activity
4332
+ // that fails to draw is worth losing; the rest of the conversation is not.
4333
+ console.warn(`ag-ui-chat: render failed for activity ${activityType}`, error);
4334
+ node = null;
4335
+ }
4336
+ if (node === null) {
4337
+ this.#removeActivity(messageId, activityType, registration.removedNotice, content);
3527
4338
  return;
3528
4339
  }
3529
4340
  const existing = this.#activityBlocks.get(messageId);
3530
4341
  if (existing === undefined) {
3531
- this.#ensureGroup().appendChild(block);
4342
+ this.#ensureGroup().appendChild(node as HTMLElement);
3532
4343
  } 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);
4344
+ // Replaced rather than appended: a server redrawing under the same id
4345
+ // means *this one changed*, and a second copy below the first would read
4346
+ // as two measurements instead of one that moved.
4347
+ existing.replaceWith(node);
3537
4348
  }
3538
- this.#activityBlocks.set(messageId, block);
4349
+ this.#activityBlocks.set(messageId, node as HTMLElement);
3539
4350
  this.#afterTranscriptGrew();
3540
4351
  }
3541
4352
 
4353
+ /**
4354
+ * Take away an activity whose content stopped being drawable.
4355
+ *
4356
+ * Leaving the old one up is the worst available answer: it shows values that
4357
+ * have been retracted, reading as current, and a reload drops it anyway
4358
+ * because the *stored* content is the version that could not be drawn. Live
4359
+ * and reload should agree, and both should say "gone".
4360
+ *
4361
+ * Removing is right; doing it in silence was not. A chart that had been drawn
4362
+ * simply disappeared, with no `console` call anywhere on the path -- which
4363
+ * nobody reports as a bug, they report as "the charts are flaky".
4364
+ */
4365
+ #removeActivity(
4366
+ messageId: string,
4367
+ activityType: string,
4368
+ notice: string | undefined,
4369
+ content: unknown,
4370
+ ): void {
4371
+ const had = this.#activityBlocks.has(messageId);
4372
+ this.#activityBlocks.get(messageId)?.remove();
4373
+ this.#activityBlocks.delete(messageId);
4374
+ console.warn(
4375
+ `ag-ui-chat: activity ${messageId} (${activityType}) was not drawable and has been ` +
4376
+ "removed. A chart's points must each be a finite JSON number; a numeric column " +
4377
+ "serialised as a string (a Decimal, typically) is rejected rather than coerced.",
4378
+ content,
4379
+ );
4380
+ // Only when something was on screen: content that never drew has no
4381
+ // disappearance to explain, and a notice for every rejected push is noise.
4382
+ if (had && notice !== undefined) {
4383
+ this.#appendNotice("\u{1F4C9}", notice, "chart-undrawable");
4384
+ }
4385
+ }
4386
+
3542
4387
  #afterTranscriptGrew(): void {
3543
4388
  this.#updateEmptyState();
3544
- this.#messages.scrollTop = this.#messages.scrollHeight;
4389
+ this.#scroller.follow();
3545
4390
  }
3546
4391
 
3547
4392
  #cardFor(call: AgUiToolCall): ToolCallCard {
@@ -3563,7 +4408,7 @@ export class AgUiChat extends HTMLElement {
3563
4408
  this.#toolCards.set(call.id, card);
3564
4409
  this.#ensureGroup().appendChild(card.element);
3565
4410
  this.#updateEmptyState();
3566
- this.#messages.scrollTop = this.#messages.scrollHeight;
4411
+ this.#scroller.follow();
3567
4412
  return card;
3568
4413
  }
3569
4414
  }
@@ -3588,6 +4433,15 @@ function confirmPhrase(interrupt: Interrupt): string | undefined {
3588
4433
  }
3589
4434
 
3590
4435
  /** One tool call as a restored assistant message carries it. */
4436
+ /**
4437
+ * Why a client tool call is gated behind the confirmation card.
4438
+ *
4439
+ * Only `"destructive"` -- the default `x-destructive` gate -- may be waived for
4440
+ * the session. `confirmPredicate` is documented as authoritative, so a call it
4441
+ * gates keeps asking.
4442
+ */
4443
+ type ConfirmationRule = "destructive" | "predicate";
4444
+
3591
4445
  interface RestoredToolCall {
3592
4446
  readonly id: string;
3593
4447
  readonly function: { readonly name: string; readonly arguments?: unknown };