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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +663 -1
  2. package/README.md +557 -11
  3. package/dist/ag-ui-web-component.bundle.js +294 -36
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +69 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +262 -1
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +46 -1
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/core/conversation_store.d.ts +43 -1
  12. package/dist/core/conversation_store.d.ts.map +1 -1
  13. package/dist/core/create_http_agent.d.ts +13 -0
  14. package/dist/core/create_http_agent.d.ts.map +1 -1
  15. package/dist/core/remote_conversation_store.d.ts +23 -1
  16. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  17. package/dist/core/utils.d.ts +28 -0
  18. package/dist/core/utils.d.ts.map +1 -1
  19. package/dist/index.d.ts +7 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2104 -194
  22. package/dist/index.js.map +4 -4
  23. package/dist/tools/is_destructive.d.ts +8 -2
  24. package/dist/tools/is_destructive.d.ts.map +1 -1
  25. package/dist/tools/parse_tool_catalog.d.ts +11 -4
  26. package/dist/tools/parse_tool_catalog.d.ts.map +1 -1
  27. package/dist/ui/approval_card.d.ts +18 -0
  28. package/dist/ui/approval_card.d.ts.map +1 -1
  29. package/dist/ui/checkpoint_menu.d.ts +10 -0
  30. package/dist/ui/checkpoint_menu.d.ts.map +1 -1
  31. package/dist/ui/confirmation_card.d.ts +16 -0
  32. package/dist/ui/confirmation_card.d.ts.map +1 -1
  33. package/dist/ui/message_actions.d.ts +46 -0
  34. package/dist/ui/message_actions.d.ts.map +1 -0
  35. package/dist/ui/page_quote_offer.d.ts +33 -0
  36. package/dist/ui/page_quote_offer.d.ts.map +1 -0
  37. package/dist/ui/quote_selection.d.ts +66 -0
  38. package/dist/ui/quote_selection.d.ts.map +1 -0
  39. package/dist/ui/relative_time.d.ts +10 -0
  40. package/dist/ui/relative_time.d.ts.map +1 -1
  41. package/dist/ui/render_markdown.d.ts +23 -5
  42. package/dist/ui/render_markdown.d.ts.map +1 -1
  43. package/dist/ui/resize_handle.d.ts +5 -1
  44. package/dist/ui/resize_handle.d.ts.map +1 -1
  45. package/dist/ui/stick_to_bottom.d.ts +55 -0
  46. package/dist/ui/stick_to_bottom.d.ts.map +1 -0
  47. package/dist/ui/styles.d.ts +1 -1
  48. package/dist/ui/styles.d.ts.map +1 -1
  49. package/dist/ui/suggestion_chips.d.ts +29 -0
  50. package/dist/ui/suggestion_chips.d.ts.map +1 -0
  51. package/dist/ui/thread_drawer.d.ts +10 -0
  52. package/dist/ui/thread_drawer.d.ts.map +1 -1
  53. package/dist/ui/tool_call_card.d.ts +8 -0
  54. package/dist/ui/tool_call_card.d.ts.map +1 -1
  55. package/dist/ui/ui_strings.d.ts +53 -7
  56. package/dist/ui/ui_strings.d.ts.map +1 -1
  57. package/dist/ui/voice_input.d.ts.map +1 -1
  58. package/package.json +1 -1
  59. package/src/constants.ts +75 -0
  60. package/src/core/ag_ui_chat.ts +1357 -113
  61. package/src/core/agui_client.ts +81 -1
  62. package/src/core/conversation_store.ts +128 -42
  63. package/src/core/create_http_agent.ts +24 -2
  64. package/src/core/remote_conversation_store.ts +35 -2
  65. package/src/core/utils.ts +58 -0
  66. package/src/index.ts +39 -0
  67. package/src/tools/is_destructive.ts +8 -2
  68. package/src/tools/parse_tool_catalog.ts +18 -6
  69. package/src/ui/approval_card.ts +90 -2
  70. package/src/ui/checkpoint_menu.ts +22 -5
  71. package/src/ui/confirmation_card.ts +29 -1
  72. package/src/ui/message_actions.ts +158 -0
  73. package/src/ui/page_quote_offer.ts +215 -0
  74. package/src/ui/quote_selection.ts +345 -0
  75. package/src/ui/relative_time.ts +11 -0
  76. package/src/ui/render_markdown.ts +111 -21
  77. package/src/ui/resize_handle.ts +32 -2
  78. package/src/ui/stick_to_bottom.ts +126 -0
  79. package/src/ui/styles.ts +227 -0
  80. package/src/ui/suggestion_chips.ts +73 -0
  81. package/src/ui/thread_drawer.ts +22 -2
  82. package/src/ui/tool_call_card.ts +9 -0
  83. package/src/ui/ui_strings.ts +79 -8
  84. package/src/ui/voice_input.ts +43 -0
  85. package/src/version.ts +1 -1
@@ -1,19 +1,26 @@
1
+ import { randomUUID } from "@ag-ui/client";
1
2
  import type { Context, Interrupt, Message, Tool } from "@ag-ui/core";
2
3
  import {
4
+ ANNOUNCE_CLEAR_MS,
3
5
  ATTACHMENT_EVENT,
4
6
  CHART_ACTIVITY_TYPE,
5
7
  COMPACTION_ACTIVITY_TYPE,
8
+ CUSTOM_AGENT_EVENT,
6
9
  DEFAULT_ATTACHMENT_MAX_BYTES,
10
+ FEEDBACK_EVENT,
7
11
  ICON_ATTACH,
8
12
  ICON_LAUNCHER,
9
13
  ICON_SEND,
10
14
  ICON_STOP,
15
+ INVALIDATE_CUSTOM_NAME,
16
+ INVALIDATE_EVENT,
11
17
  LOAD_CAPABILITY_TOOL,
12
18
  MESSAGE_ROLE,
13
19
  READ_PAGE_TOOL,
14
20
  RUN_FINISHED_EVENT,
15
21
  STATE_EVENT,
16
22
  SUBMIT_EVENT,
23
+ SUGGESTIONS_ACTIVITY_TYPE,
17
24
  TOGGLE_EVENT,
18
25
  TOOL_CALL_STATUS,
19
26
  TOOL_DISPLAY,
@@ -31,7 +38,7 @@ import { isNavigates } from "../tools/is_navigates.js";
31
38
  import { createPageActionTools, type ResolvePageTarget } from "../tools/page_action_tools.js";
32
39
  import { createPageMapContext, type PageMap } from "../tools/page_map.js";
33
40
  import { createPageStateTools, type PageState } from "../tools/page_state.js";
34
- import { parseToolCatalog } from "../tools/parse_tool_catalog.js";
41
+ import { parseToolCatalog, type ToolCatalogEntry } from "../tools/parse_tool_catalog.js";
35
42
  import { createRouteTools, type RouteMap } from "../tools/route_map.js";
36
43
  import {
37
44
  type ApprovalRenderer,
@@ -46,12 +53,20 @@ import { chartSpecFrom } from "../ui/chart_spec_from.js";
46
53
  import { CHART_TOOL_NAME, createChartTool } from "../ui/chart_tool.js";
47
54
  import { CheckpointMenu, type CheckpointVerb } from "../ui/checkpoint_menu.js";
48
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";
49
62
  import { prettifyToolName } from "../ui/prettify_tool_name.js";
50
63
  import {
51
64
  type QuestionRenderer,
52
65
  type QuestionRequest,
53
66
  requestQuestion,
54
67
  } from "../ui/question_card.js";
68
+ import { asQuote, quotableSelection } from "../ui/quote_selection.js";
69
+ import type { RelativeTimeFormatter } from "../ui/relative_time.js";
55
70
  import { renderMarkdown } from "../ui/render_markdown.js";
56
71
  import {
57
72
  createResizeHandle,
@@ -62,7 +77,9 @@ import {
62
77
  import { wrapWords } from "../ui/reveal_words.js";
63
78
  import { renderRunNotice } from "../ui/run_notice.js";
64
79
  import { SkillsMenu } from "../ui/skills_menu.js";
80
+ import { createStickToBottom, type StickToBottom } from "../ui/stick_to_bottom.js";
65
81
  import { STYLES } from "../ui/styles.js";
82
+ import { renderSuggestionChips } from "../ui/suggestion_chips.js";
66
83
  import { ThoughtsBlock } from "../ui/thoughts_block.js";
67
84
  import { ThreadDrawer } from "../ui/thread_drawer.js";
68
85
  import { ToolCallCard, type ToolDisplayMode } from "../ui/tool_call_card.js";
@@ -80,13 +97,14 @@ import {
80
97
  type ClientConversationStore,
81
98
  type NavigationCheckpoint,
82
99
  SessionStorageStore,
100
+ writeStoredItem,
83
101
  } from "./conversation_store.js";
84
102
  import { type AgentFactory, createHttpAgent } from "./create_http_agent.js";
85
103
  import { RemoteConversationStore } from "./remote_conversation_store.js";
86
104
  import { RunIndex } from "./run_index.js";
87
105
  import { type TranscribeHandler, transcribeAudio } from "./transcribe_audio.js";
88
106
  import { type UploadHandler, uploadAttachment } from "./upload_attachment.js";
89
- import { mintThread, withCredentials } from "./utils.js";
107
+ import { mintThread, warnOnCrossOriginCredentials, withCredentials } from "./utils.js";
90
108
 
91
109
  /** The role a rendered chat message takes. */
92
110
  export type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE];
@@ -107,6 +125,13 @@ export interface AttachmentsDetail {
107
125
  }
108
126
 
109
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
+
110
135
  export interface StateDetail {
111
136
  readonly state: Readonly<Record<string, unknown>>;
112
137
  }
@@ -126,6 +151,83 @@ export interface ToolRun {
126
151
  export interface RunFinishedDetail {
127
152
  /** In settle order. Empty when the interaction called no tools. */
128
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;
129
231
  }
130
232
 
131
233
  /** `detail` shape of the {@link TOGGLE_EVENT} CustomEvent. */
@@ -156,6 +258,7 @@ const CONNECT_TIME_ATTRIBUTES = [
156
258
  "data-attachment-max-bytes",
157
259
  "data-transcribe-url",
158
260
  "data-threads-url",
261
+ "data-threads-cache",
159
262
  "data-tools-url",
160
263
  "data-skills-url",
161
264
  "data-skills",
@@ -187,6 +290,20 @@ const SIZE_KEY = "ag-ui-chat:size";
187
290
  /** Per-tab persistence key for the built-in theme toggle. */
188
291
  const THEME_KEY = "ag-ui-chat:theme";
189
292
 
293
+ /** Pixels between a selection and the offer to quote it. */
294
+ const QUOTE_GAP = 6;
295
+
296
+ /**
297
+ * Storage namespaces already spoken for in this document.
298
+ *
299
+ * Per document rather than per origin, and released on disconnect, because the
300
+ * question it answers is "is another element on this page using these keys right
301
+ * now" — not "has anything ever used them". A registry that never released would
302
+ * turn every remount, and every framework re-render that moves the node, into a
303
+ * false collision that costs the element its own conversation.
304
+ */
305
+ const CLAIMED_NAMESPACES = new Set<string>();
306
+
190
307
  /**
191
308
  * `<ag-ui-chat>` — a framework-free chat sidebar Web Component over AG-UI.
192
309
  *
@@ -224,6 +341,23 @@ export class AgUiChat extends HTMLElement {
224
341
  */
225
342
  getHeaders: (() => Record<string, string>) | null = null;
226
343
 
344
+ /**
345
+ * Origins, besides the page's own, that this element may send {@link headers}
346
+ * and {@link getHeaders} credentials to without saying so on the console.
347
+ *
348
+ * Seven attributes name a URL, and every one of them carries these headers.
349
+ * They are plain HTML, so a page that builds one from a query parameter or
350
+ * from tenant-authored configuration has handed an attacker the destination,
351
+ * and the token leaves on the element's first request. Naming the origins you
352
+ * expect turns that from silent into either confirmed or reported.
353
+ *
354
+ * A notice rather than a refusal: a cross-origin agent is a documented
355
+ * deployment, so refusing would break working installations to defend against
356
+ * a page that is already interpolating untrusted data into its own markup.
357
+ * Leaving this empty costs nothing but one console line per foreign origin.
358
+ */
359
+ trustedOrigins: readonly string[] = [];
360
+
227
361
  /**
228
362
  * Permit `<img>` in rendered assistant markdown. **Off by default**: a
229
363
  * model-controlled image URL is fetched with no user interaction, which
@@ -232,6 +366,23 @@ export class AgUiChat extends HTMLElement {
232
366
  */
233
367
  allowImages = false;
234
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
+
235
386
  /** When true, destructive tools execute without a confirmation modal. */
236
387
  autoConfirm = false;
237
388
 
@@ -259,6 +410,23 @@ export class AgUiChat extends HTMLElement {
259
410
  */
260
411
  approvalRenderer: ApprovalRenderer | null = null;
261
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
+
262
430
  /**
263
431
  * Optional per-call confirmation predicate. When set it is authoritative,
264
432
  * deciding from the tool name and args whether this particular call needs
@@ -381,14 +549,40 @@ export class AgUiChat extends HTMLElement {
381
549
  resolvePageTarget: ResolvePageTarget = (target) => document.querySelector<HTMLElement>(target);
382
550
 
383
551
  /**
384
- * Card labels fetched from a server tool catalog (`data-tools-url`), keyed by
385
- * tool name. The base layer behind {@link toolSummaries}: an explicit entry in
386
- * `toolSummaries` wins, this fills the rest. Populated once on connect.
552
+ * The server tool catalog fetched from `data-tools-url`, keyed by tool
553
+ * name. Cards label themselves from each entry's `summary`, the base
554
+ * layer behind {@link toolSummaries}: an explicit entry in `toolSummaries`
555
+ * wins, this fills the rest. Held as whole entries rather than labels so a
556
+ * field the server sent is not lost on the way in. Populated once on connect.
387
557
  */
388
- #toolCatalog: Record<string, string> = {};
558
+ #toolCatalog: Record<string, ToolCatalogEntry> = {};
559
+
560
+ /**
561
+ * Foreign origins already reported, so the notice is once per origin per
562
+ * element rather than once per request. Per-element rather than module-level,
563
+ * because two elements on one page are two separate configurations.
564
+ */
565
+ #warnedOrigins = new Set<string>();
389
566
  /** The resolved string table (defaults ← `data-strings` ← `strings`). */
390
567
  #strings: UiStrings = DEFAULT_UI_STRINGS;
391
568
 
569
+ /**
570
+ * The tool names the current round handed the agent, captured as the catalog
571
+ * went out.
572
+ *
573
+ * The registry is mount-wide but {@link getTools} is per-run, so a host is
574
+ * free to scope what a given page offers — and a call naming a tool this run
575
+ * withheld must not reach the handler that is merely still registered.
576
+ * Snapshotted rather than re-asked at dispatch: a provider is a function, and
577
+ * calling it again asks a question the run already answered, which is exactly
578
+ * the window a scoped catalog exists to close.
579
+ *
580
+ * Empty until the first round advertises, which cannot precede a call: the
581
+ * client builds `RunAgentInput.tools` at the top of every round, before the
582
+ * calls that round produces are executed.
583
+ */
584
+ #advertisedTools: ReadonlySet<string> = new Set();
585
+
392
586
  readonly #toolRegistry = new ClientToolRegistry();
393
587
  /** Tool-call cards awaiting execution, keyed by call id. */
394
588
  readonly #toolCards = new Map<string, ToolCallCard>();
@@ -398,7 +592,16 @@ export class AgUiChat extends HTMLElement {
398
592
  * the real output with the generic "executed on the server" fallback.
399
593
  */
400
594
  /** Whether a server-pushed chart activity is drawn. Off unless asked for. */
401
- #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>();
402
605
 
403
606
  /** Card elements by call id, so a rendering handler can find its own card. */
404
607
  readonly #cardElements = new Map<string, HTMLElement>();
@@ -413,7 +616,77 @@ export class AgUiChat extends HTMLElement {
413
616
  * Spans tool rounds and an approval interrupt; cleared when the event fires.
414
617
  */
415
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>();
416
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;
417
690
  readonly #chat: HTMLDivElement;
418
691
  readonly #messages: HTMLDivElement;
419
692
  readonly #input: HTMLTextAreaElement;
@@ -470,6 +743,12 @@ export class AgUiChat extends HTMLElement {
470
743
  // revealed progressively as it streamed, so the word reveal must not re-animate
471
744
  // it; ≤1 ⇒ it arrived at once and the word reveal is appropriate.
472
745
  #streamDeltas = 0;
746
+ // The accumulated answer the next render will draw. Deltas overwrite it
747
+ // (each one carries the whole answer), so a frame always draws the latest.
748
+ #streamBuffer = "";
749
+ // The frame that render is queued on, or `null` when nothing is queued —
750
+ // also the flag saying a delta is still undrawn.
751
+ #streamFrame: number | null = null;
473
752
  #pending: HTMLDivElement | null = null;
474
753
  // The current assistant turn's grouping container. One `.answer`
475
754
  // wraps everything a single answer produces — streamed text, tool cards, the
@@ -484,9 +763,23 @@ export class AgUiChat extends HTMLElement {
484
763
  #thoughts: ThoughtsBlock | null = null;
485
764
  #threadId = "";
486
765
  // Per-instance suffix for the origin-scoped storage keys (collapsed / theme /
487
- // active thread), so two instances on one origin don't clobber each other.
488
- // Empty ⇒ the pre-namespacing global keys (back-compat). Resolved on connect.
766
+ // size), so two instances on one origin don't clobber each other. Empty ⇒ the
767
+ // pre-namespacing global keys (back-compat). Resolved on connect; the
768
+ // conversation adds `user-key` on top of it, see #conversationNs.
489
769
  #storageNs = "";
770
+ // The entry this element put in CLAIMED_NAMESPACES, to take back out on
771
+ // disconnect. `null` when it claimed nothing (no id, no endpoint, or it lost
772
+ // the claim to an element that mounted first).
773
+ #claimedNs: string | null = null;
774
+ // The fallback namespace minted when the preferred one was already claimed,
775
+ // with the preferred value it was minted for — so the element keeps it across
776
+ // remounts, but re-resolves if the host answers the warning with an `id`.
777
+ #generatedNs = "";
778
+ #generatedFor = "";
779
+ // The `sessionStorage`-backed store, which the element may therefore re-scope
780
+ // on a principal change. `null` when the host injected a store of its own
781
+ // kind, whose keying the element does not know and must not guess at.
782
+ #builtinStore: SessionStorageStore | null = null;
490
783
  // Bumped on every #rehydrate; a replay whose generation is stale (a newer
491
784
  // thread switch started while it awaited a slow store) drops its result.
492
785
  #rehydrateGeneration = 0;
@@ -513,6 +806,33 @@ export class AgUiChat extends HTMLElement {
513
806
  this.#launcher = document.createElement("button");
514
807
  this.#badge = document.createElement("span");
515
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
+ });
516
836
  this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
517
837
  this.#drawer = new ThreadDrawer({
518
838
  onSelect: (threadId) => {
@@ -544,7 +864,7 @@ export class AgUiChat extends HTMLElement {
544
864
  if (this.#runIndex === null) {
545
865
  this.#runIndex = new RunIndex(
546
866
  url,
547
- () => this.#requestHeaders(),
867
+ () => this.#headersFor(url),
548
868
  () => this.#requestCredentials(),
549
869
  );
550
870
  }
@@ -579,6 +899,7 @@ export class AgUiChat extends HTMLElement {
579
899
  endpoint,
580
900
  headers: this.#requestHeaders(),
581
901
  getHeaders: () => this.#requestHeaders(),
902
+ trustedOrigins: this.trustedOrigins,
582
903
  ...this.#credentialsOption(),
583
904
  threadId: this.#threadId,
584
905
  // The seed the endpoints assume: nothing. The snapshot is the history.
@@ -587,7 +908,7 @@ export class AgUiChat extends HTMLElement {
587
908
  const client = new AgUiClient({
588
909
  agent,
589
910
  handlers: this.#handlers(),
590
- getTools: () => this.getTools(),
911
+ getTools: () => this.#advertiseTools(),
591
912
  getContext: () => this.#buildContext(),
592
913
  executeTool: (call) => this.#executeTool(call),
593
914
  resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
@@ -599,12 +920,15 @@ export class AgUiChat extends HTMLElement {
599
920
  /** Load the checkpoint panel with the runs that can actually be continued. */
600
921
  async #refreshCheckpoints(): Promise<void> {
601
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);
602
926
  this.#checkpoints.setRuns(index === null ? [] : await index.continuable());
603
927
  }
604
928
 
605
929
  /** Attributes the element reacts to after it has been connected. */
606
930
  static get observedAttributes(): string[] {
607
- return ["title-text", "placement", "credentials", ...CONNECT_TIME_ATTRIBUTES];
931
+ return ["title-text", "placement", "credentials", "user-key", ...CONNECT_TIME_ATTRIBUTES];
608
932
  }
609
933
 
610
934
  attributeChangedCallback(name: string, previous: string | null, value: string | null): void {
@@ -638,6 +962,16 @@ export class AgUiChat extends HTMLElement {
638
962
  this.#title.textContent = value ?? this.#strings.title;
639
963
  return;
640
964
  }
965
+ if (name === "user-key") {
966
+ // Before connect there is nothing to move: connectedCallback resolves the
967
+ // namespace from the attribute as it stands by then. An absent attribute
968
+ // and an empty one name the same (unnamed) principal, so neither is a
969
+ // change worth acting on.
970
+ if (this.#connected && (previous ?? "") !== (value ?? "")) {
971
+ this.#changePrincipal(previous ?? "", value ?? "");
972
+ }
973
+ return;
974
+ }
641
975
  // Everything else here is read once, in connectedCallback, to build chrome
642
976
  // that then exists or does not. A later change is silently ignored and the
643
977
  // symptom is an affordance that never appears, which reads as a broken
@@ -657,7 +991,18 @@ export class AgUiChat extends HTMLElement {
657
991
  );
658
992
  }
659
993
 
660
- /** Declare a frontend tool the agent may call. */
994
+ /**
995
+ * Declare a frontend tool the agent may call.
996
+ *
997
+ * **A handler's thrown message leaves the browser.** When a handler rejects,
998
+ * its `Error.message` is posted back as that call's tool result — into the
999
+ * conversation, on to the AG-UI endpoint, persisted server-side, and
1000
+ * forwarded to the model provider on every later round. That is deliberate,
1001
+ * since it is what lets the agent recover from a failure it caused; but it
1002
+ * means an internal hostname, a signed URL or a stack-derived path in a
1003
+ * rethrown error is disclosed to parties the host never chose. Throw the
1004
+ * message you would be content for the model to read, and log the detail.
1005
+ */
661
1006
  registerTool(tool: ClientTool): void {
662
1007
  this.#toolRegistry.register(tool);
663
1008
  }
@@ -831,10 +1176,23 @@ export class AgUiChat extends HTMLElement {
831
1176
  });
832
1177
  this.#confirmAbort = null;
833
1178
  this.#updateEmptyState();
834
- this.#messages.scrollTop = this.#messages.scrollHeight;
1179
+ this.#scroller.follow();
835
1180
  return answer;
836
1181
  }
837
1182
 
1183
+ /**
1184
+ * The catalog for the round about to start, remembering what it offered.
1185
+ *
1186
+ * Every path to a frontend tool goes through here first — the client asks
1187
+ * for `RunAgentInput.tools` at the top of each round — so this is the one
1188
+ * place that can know what the agent was actually told about.
1189
+ */
1190
+ #advertiseTools(): Tool[] {
1191
+ const tools = this.getTools();
1192
+ this.#advertisedTools = new Set(tools.map((tool) => tool.name));
1193
+ return tools;
1194
+ }
1195
+
838
1196
  /** Resolve a tool by name: built-in tools first, then the registry. */
839
1197
  #resolveTool(name: string): ClientTool | null {
840
1198
  const builtin = this.#builtinTools().find((t) => t.name === name);
@@ -856,6 +1214,39 @@ export class AgUiChat extends HTMLElement {
856
1214
  this.setAttribute("endpoint", value);
857
1215
  }
858
1216
 
1217
+ /**
1218
+ * Who the stored conversation belongs to, from the `user-key` attribute.
1219
+ *
1220
+ * Set it to whatever identifies the signed-in principal — a user id, an
1221
+ * account id, a hash of one. The value joins the storage namespace, so two
1222
+ * principals in the same tab cannot read each other's transcript, and
1223
+ * **changing it purges what the previous one left behind**.
1224
+ *
1225
+ * That purge is the reason this is a live attribute rather than a
1226
+ * connect-time one. `sessionStorage` survives same-tab navigation, so it
1227
+ * survives a logout; and a single-page app signs out through its own router
1228
+ * without remounting anything, so there is no other moment at which the
1229
+ * element could find out. The host naming the new principal — or dropping the
1230
+ * attribute — is the signal.
1231
+ *
1232
+ * Absent means exactly today's behaviour, which is why nothing breaks by
1233
+ * leaving it off: the conversation is scoped to the element and to nobody in
1234
+ * particular, and on a shared workstation it carries into whoever signs in
1235
+ * next in the same tab.
1236
+ *
1237
+ * The first value to arrive is treated as a host naming the user who was
1238
+ * already there, not as a handover: the conversation in progress moves into
1239
+ * the principal's namespace rather than being destroyed, so an element
1240
+ * configured by an async auth handshake keeps what is on screen.
1241
+ */
1242
+ get userKey(): string {
1243
+ return this.getAttribute("user-key") ?? "";
1244
+ }
1245
+
1246
+ set userKey(value: string) {
1247
+ this.setAttribute("user-key", value);
1248
+ }
1249
+
859
1250
  /**
860
1251
  * Cookie policy for **every** request this element makes, as `fetch`'s own
861
1252
  * `credentials` mode (`"omit"` / `"same-origin"` / `"include"`). Mirrored to
@@ -907,6 +1298,25 @@ export class AgUiChat extends HTMLElement {
907
1298
  return { ...this.headers, ...this.getHeaders?.() };
908
1299
  }
909
1300
 
1301
+ /**
1302
+ * The request headers, having first reported the destination if it is foreign.
1303
+ *
1304
+ * Every caller that sends these headers knows its URL, and `#requestHeaders`
1305
+ * does not -- so the check lives here, on the path that has both, rather than
1306
+ * being repeated at each call site with a chance to be forgotten at the next
1307
+ * one added.
1308
+ */
1309
+ #headersFor(url: string): Record<string, string> {
1310
+ const headers = this.#requestHeaders();
1311
+ warnOnCrossOriginCredentials(
1312
+ url,
1313
+ Object.keys(headers),
1314
+ this.trustedOrigins,
1315
+ this.#warnedOrigins,
1316
+ );
1317
+ return headers;
1318
+ }
1319
+
910
1320
  /** The configured cookie policy as `fetch` spells it; `undefined` when unset. */
911
1321
  #requestCredentials(): RequestCredentials | undefined {
912
1322
  return this.credentials ?? undefined;
@@ -927,8 +1337,8 @@ export class AgUiChat extends HTMLElement {
927
1337
  }
928
1338
 
929
1339
  /** The `fetch` init for the element's own plain GETs (catalogs). */
930
- #fetchInit(): RequestInit | undefined {
931
- return withCredentials({ headers: this.#requestHeaders() }, this.#requestCredentials());
1340
+ #fetchInit(url: string): RequestInit | undefined {
1341
+ return withCredentials({ headers: this.#headersFor(url) }, this.#requestCredentials());
932
1342
  }
933
1343
 
934
1344
  /**
@@ -956,10 +1366,10 @@ export class AgUiChat extends HTMLElement {
956
1366
  }
957
1367
 
958
1368
  connectedCallback(): void {
959
- // Resolve the per-instance storage namespace (id, else endpoint) before any
960
- // key read/write, so this instance doesn't share collapsed/theme/thread
961
- // state with another on the same origin.
962
- this.#storageNs = this.id !== "" ? this.id : this.endpoint;
1369
+ // Resolve the per-instance storage namespace before any key read/write, so
1370
+ // this instance doesn't share collapsed/theme/thread state with another on
1371
+ // the same origin.
1372
+ this.#storageNs = this.#claimNamespace();
963
1373
  // Restore a dragged size before the panel paints, so it does not snap from
964
1374
  // the placement default to the user's width on the first frame.
965
1375
  this.#applySize(this.#readSize());
@@ -989,8 +1399,13 @@ export class AgUiChat extends HTMLElement {
989
1399
  this.#initSkills();
990
1400
  // Namespace the built-in default store too (a host-injected store is used
991
1401
  // verbatim). Must precede #wireThreadStore, which wraps the current store.
992
- if (this.#storageNs !== "" && this.conversationStore instanceof SessionStorageStore) {
993
- this.conversationStore = new SessionStorageStore(this.#storageNs);
1402
+ if (this.conversationStore instanceof SessionStorageStore) {
1403
+ const namespace = this.#conversationNs();
1404
+ // Remembered either way: this is the element's own store, so a later
1405
+ // `user-key` change may move it to another namespace.
1406
+ this.#builtinStore =
1407
+ namespace === "" ? this.conversationStore : new SessionStorageStore(namespace);
1408
+ this.conversationStore = this.#builtinStore;
994
1409
  }
995
1410
  this.#wireThreadStore();
996
1411
  this.#wireAttachments();
@@ -1064,9 +1479,26 @@ export class AgUiChat extends HTMLElement {
1064
1479
  */
1065
1480
  disconnectedCallback(): void {
1066
1481
  this.#connected = false;
1482
+ // Give the namespace back. A disconnect is not necessarily a farewell — a
1483
+ // DOM move and a framework re-render both look like one — and an element
1484
+ // that could not reclaim its own namespace on the way back in would lose
1485
+ // its conversation to a false collision.
1486
+ if (this.#claimedNs !== null) {
1487
+ CLAIMED_NAMESPACES.delete(this.#claimedNs);
1488
+ this.#claimedNs = null;
1489
+ }
1067
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;
1068
1495
  this.#attachTray?.dispose();
1069
1496
  this.#voice?.dispose();
1497
+ this.#scroller.dispose();
1498
+ if (this.#announceTimer !== null) {
1499
+ clearTimeout(this.#announceTimer);
1500
+ this.#announceTimer = null;
1501
+ }
1070
1502
  }
1071
1503
 
1072
1504
  /**
@@ -1143,7 +1575,7 @@ export class AgUiChat extends HTMLElement {
1143
1575
  return (file, onProgress, signal) =>
1144
1576
  uploadAttachment(file, {
1145
1577
  url,
1146
- headers: this.#requestHeaders(),
1578
+ headers: this.#headersFor(url),
1147
1579
  ...this.#credentialsOption(),
1148
1580
  onProgress,
1149
1581
  signal,
@@ -1179,7 +1611,7 @@ export class AgUiChat extends HTMLElement {
1179
1611
  return (audio) =>
1180
1612
  transcribeAudio(audio, {
1181
1613
  url,
1182
- headers: this.#requestHeaders(),
1614
+ headers: this.#headersFor(url),
1183
1615
  ...this.#credentialsOption(),
1184
1616
  });
1185
1617
  }
@@ -1192,6 +1624,126 @@ export class AgUiChat extends HTMLElement {
1192
1624
  this.#input.focus();
1193
1625
  }
1194
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
+
1195
1747
  /** The client-side upload size cap from `data-attachment-max-bytes`. */
1196
1748
  #attachmentMaxBytes(): number {
1197
1749
  const attr = this.getAttribute("data-attachment-max-bytes");
@@ -1240,15 +1792,21 @@ export class AgUiChat extends HTMLElement {
1240
1792
  * delete through that server endpoint (wrapping the current store as the
1241
1793
  * client-only fallback), so the history drawer shows durable, cross-device
1242
1794
  * threads. Without it, the client store's per-tab threads are used.
1795
+ *
1796
+ * `data-threads-cache="false"` drops the local copy of the message bodies —
1797
+ * for the deployment that pointed history at a server precisely so that
1798
+ * transcripts do not sit in the browser. The client-only concerns (the active
1799
+ * thread id, the navigation checkpoint) keep their local store either way.
1243
1800
  */
1244
1801
  #wireThreadStore(): void {
1245
1802
  const url = this.getAttribute("data-threads-url");
1246
1803
  if (url !== null) {
1247
1804
  this.conversationStore = new RemoteConversationStore(
1248
1805
  url,
1249
- () => this.#requestHeaders(),
1806
+ () => this.#headersFor(url),
1250
1807
  this.conversationStore,
1251
1808
  () => this.#requestCredentials(),
1809
+ this.getAttribute("data-threads-cache") !== "false",
1252
1810
  );
1253
1811
  }
1254
1812
  }
@@ -1260,7 +1818,7 @@ export class AgUiChat extends HTMLElement {
1260
1818
  return;
1261
1819
  }
1262
1820
  try {
1263
- const response = await fetch(url, this.#fetchInit());
1821
+ const response = await fetch(url, this.#fetchInit(url));
1264
1822
  this.#toolCatalog = parseToolCatalog(await response.json());
1265
1823
  } catch {
1266
1824
  // Network/parse failure: cards fall back to toolSummaries / raw names.
@@ -1308,7 +1866,7 @@ export class AgUiChat extends HTMLElement {
1308
1866
  return;
1309
1867
  }
1310
1868
  try {
1311
- const response = await fetch(url, this.#fetchInit());
1869
+ const response = await fetch(url, this.#fetchInit(url));
1312
1870
  this.#backendSkills = parseSkills(await response.json());
1313
1871
  this.#recomputeSkills();
1314
1872
  } catch {
@@ -1409,7 +1967,7 @@ export class AgUiChat extends HTMLElement {
1409
1967
  } else {
1410
1968
  this.removeAttribute("collapsed");
1411
1969
  }
1412
- sessionStorage.setItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
1970
+ writeStoredItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
1413
1971
  // Expanding is what marks the waiting answers read; collapsing starts a
1414
1972
  // fresh count. Either way the badge is cleared and the host told.
1415
1973
  this.#setUnread(0);
@@ -1446,7 +2004,7 @@ export class AgUiChat extends HTMLElement {
1446
2004
  toggleTheme(): void {
1447
2005
  const next = this.getAttribute("theme") === "dark" ? "light" : "dark";
1448
2006
  this.setAttribute("theme", next);
1449
- sessionStorage.setItem(this.#storageKey(THEME_KEY), next);
2007
+ writeStoredItem(this.#storageKey(THEME_KEY), next);
1450
2008
  this.#syncThemeGlyph();
1451
2009
  }
1452
2010
 
@@ -1557,7 +2115,7 @@ export class AgUiChat extends HTMLElement {
1557
2115
  /** Persist a dragged size per tab, alongside the collapsed/theme preferences. */
1558
2116
  #persistSize(size: ResizeSize): void {
1559
2117
  const stored = { ...this.#readSize(), ...size };
1560
- sessionStorage.setItem(this.#storageKey(SIZE_KEY), JSON.stringify(stored));
2118
+ writeStoredItem(this.#storageKey(SIZE_KEY), JSON.stringify(stored));
1561
2119
  }
1562
2120
 
1563
2121
  /** The persisted size for this instance, or an empty record. */
@@ -1576,6 +2134,130 @@ export class AgUiChat extends HTMLElement {
1576
2134
  }
1577
2135
  }
1578
2136
 
2137
+ /**
2138
+ * Claim this element's storage namespace: its `id`, else its `endpoint`.
2139
+ *
2140
+ * The endpoint fallback exists so a lone widget restores its conversation
2141
+ * across reloads with nothing asked of the page author. It stops working the
2142
+ * moment there are two of them — a docked support panel and an inline page
2143
+ * assistant against one agent mount, neither carrying an `id`, which nothing
2144
+ * requires — because both resolve to the same string and then share a thread
2145
+ * pointer, a drawer index and every message key. Whichever mounts second
2146
+ * adopts the first's active thread and rehydrates its transcript into its own
2147
+ * panel: one conversation's content inside another, on the same page.
2148
+ *
2149
+ * So the namespace is claimed by the first element to mount under it, and a
2150
+ * second is given one of its own plus a warning naming the fix. The first
2151
+ * element keeps the endpoint namespace, which is what leaves the ordinary
2152
+ * single-element case exactly as it was.
2153
+ *
2154
+ * The generated namespace is random rather than derived from mount order.
2155
+ * That costs the second element its history across reloads — the warning says
2156
+ * so, and an `id` fixes it — which is the honest trade against an order-based
2157
+ * name that would silently hand a stored conversation to whichever element
2158
+ * happened to mount second on the next load.
2159
+ */
2160
+ #claimNamespace(): string {
2161
+ const preferred = this.id !== "" ? this.id : this.endpoint;
2162
+ // Nothing to key on. The pre-namespacing global keys, as before: an element
2163
+ // with neither an id nor an endpoint cannot send anything, so what it would
2164
+ // be claiming is an empty conversation.
2165
+ if (preferred === "") {
2166
+ return "";
2167
+ }
2168
+ // Already lost this claim once. Keep the fallback rather than drifting back
2169
+ // onto a namespace the other element may since have released, which would
2170
+ // swap this panel's conversation for that one's.
2171
+ if (this.#generatedFor === preferred) {
2172
+ return this.#generatedNs;
2173
+ }
2174
+ if (!CLAIMED_NAMESPACES.has(preferred)) {
2175
+ CLAIMED_NAMESPACES.add(preferred);
2176
+ this.#claimedNs = preferred;
2177
+ return preferred;
2178
+ }
2179
+ this.#generatedFor = preferred;
2180
+ this.#generatedNs = `${preferred}~${randomUUID()}`;
2181
+ console.warn(
2182
+ `<ag-ui-chat>: another element on this page already stores its ` +
2183
+ `conversation under "${preferred}", so this one has been given a ` +
2184
+ "throwaway namespace of its own — the two would otherwise share a " +
2185
+ "thread pointer, a history drawer and every message. Give each " +
2186
+ "<ag-ui-chat> its own id to keep them apart and let this one restore " +
2187
+ "its conversation across reloads.",
2188
+ );
2189
+ return this.#generatedNs;
2190
+ }
2191
+
2192
+ /**
2193
+ * The conversation store's namespace: this element's, scoped to the principal
2194
+ * {@link userKey} names.
2195
+ *
2196
+ * Only the conversation is principal-scoped. The panel's own collapsed / size
2197
+ * / theme preferences stay on `#storageNs`, because they are this element's
2198
+ * UI state rather than anyone's data — they carry no word of what was said —
2199
+ * and because they are read once while connecting, so re-scoping them under a
2200
+ * live element would rearrange the panel around a user who had only just
2201
+ * signed in.
2202
+ */
2203
+ #conversationNs(key: string = this.userKey): string {
2204
+ return key === "" ? this.#storageNs : `${this.#storageNs}#${key}`;
2205
+ }
2206
+
2207
+ /**
2208
+ * Move the element's client state from one principal to another.
2209
+ *
2210
+ * The whole reason {@link userKey} is live: `sessionStorage` outlives a
2211
+ * logout, because a logout is a navigation (or, in a single-page app, not
2212
+ * even that) rather than a tab close. Nothing remounts, so the host naming
2213
+ * the new principal is the only signal the element will ever get.
2214
+ */
2215
+ #changePrincipal(previousKey: string, nextKey: string): void {
2216
+ const previous = this.#conversationNs(previousKey);
2217
+ const next = this.#conversationNs(nextKey);
2218
+ if (previousKey === "") {
2219
+ // Absent to present is not a handover. It is the documented late
2220
+ // configuration shape — the element mounts, an auth handshake resolves,
2221
+ // and only then is the user known — so the conversation already on screen
2222
+ // belongs to this principal and moves with them. Moving rather than
2223
+ // copying also matters: a copy left behind under the unscoped namespace
2224
+ // is a transcript the next key-less mount would happily adopt.
2225
+ SessionStorageStore.adopt(previous, next);
2226
+ this.#rescopeStore(next);
2227
+ return;
2228
+ }
2229
+ SessionStorageStore.purge(previous);
2230
+ this.#rescopeStore(next);
2231
+ // The transcript on screen, the run in flight and the replayed history all
2232
+ // belong to the principal who just left. Purging storage without clearing
2233
+ // these would leave the previous user's conversation visible to the new one.
2234
+ this.#cancelRun();
2235
+ this.#resetState();
2236
+ this.#setRunning(false);
2237
+ this.#setUnread(0);
2238
+ this.#threadId = this.conversationStore.threadId();
2239
+ void this.#rehydrate();
2240
+ void this.#refreshDrawer();
2241
+ }
2242
+
2243
+ /**
2244
+ * Rebuild the `sessionStorage` store under `namespace`, re-wrapping it for
2245
+ * `data-threads-url` exactly as connecting did.
2246
+ *
2247
+ * A store of the host's own kind is left alone: a store that holds its data
2248
+ * somewhere the element cannot see has to scope itself. The transcript on
2249
+ * screen is still cleared either way — the host swapped principals, and that
2250
+ * much is the element's to act on.
2251
+ */
2252
+ #rescopeStore(namespace: string): void {
2253
+ if (this.#builtinStore === null) {
2254
+ return;
2255
+ }
2256
+ this.#builtinStore = new SessionStorageStore(namespace);
2257
+ this.conversationStore = this.#builtinStore;
2258
+ this.#wireThreadStore();
2259
+ }
2260
+
1579
2261
  /** This instance's namespaced form of an origin-scoped storage key. */
1580
2262
  #storageKey(base: string): string {
1581
2263
  return this.#storageNs === "" ? base : `${base}:${this.#storageNs}`;
@@ -1679,7 +2361,22 @@ export class AgUiChat extends HTMLElement {
1679
2361
  /** Drop the in-memory run + transcript, leaving the thread id untouched. */
1680
2362
  #resetState(): void {
1681
2363
  this.#client = null;
1682
- this.#streamingBubble = 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 {
2376
+ // Before the transcript goes: a render still queued would otherwise fire
2377
+ // against the wiped list and open a fresh bubble holding the discarded
2378
+ // conversation's last tokens.
2379
+ this.#endStream();
1683
2380
  this.#currentGroup = null;
1684
2381
  this.#thoughts = null;
1685
2382
  this.#hidePending();
@@ -1687,13 +2384,50 @@ export class AgUiChat extends HTMLElement {
1687
2384
  this.#serverSettled.clear();
1688
2385
  this.#cardElements.clear();
1689
2386
  this.#activityBlocks.clear();
1690
- this.#initialMessages = [];
2387
+ this.#retryOwner = null;
1691
2388
  this.#attachTray?.clear();
1692
2389
  // Keep the empty-state region; everything else clears.
1693
2390
  this.#messages.replaceChildren(this.#emptyWrap);
1694
2391
  this.#updateEmptyState();
1695
2392
  }
1696
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
+
1697
2431
  /** Switch the active conversation to an existing thread and replay it. */
1698
2432
  async #switchThread(threadId: string): Promise<void> {
1699
2433
  if (threadId === this.#threadId) {
@@ -1724,6 +2458,7 @@ export class AgUiChat extends HTMLElement {
1724
2458
 
1725
2459
  /** Reload the drawer's thread list, marking the active thread. */
1726
2460
  async #refreshDrawer(): Promise<void> {
2461
+ this.#drawer.setRelativeTimeFormatter(this.formatRelativeTime);
1727
2462
  this.#drawer.setThreads(await this.conversationStore.listThreads(), this.#threadId);
1728
2463
  }
1729
2464
 
@@ -1829,7 +2564,9 @@ export class AgUiChat extends HTMLElement {
1829
2564
  // transcript mounts at once, so animating every bubble's text in
1830
2565
  // parallel looks wrong. Mark it so the fade CSS skips it, and don't
1831
2566
  // wrap words.
1832
- 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);
1833
2570
  }
1834
2571
  // Narrowed rather than trusted, for the same reason `messageAttachments`
1835
2572
  // narrows the neighbouring field: anything that throws in this loop aborts
@@ -1868,8 +2605,8 @@ export class AgUiChat extends HTMLElement {
1868
2605
  // chart's data is in the transcript already and survives a reload. Only
1869
2606
  // the drawing had to be put back.
1870
2607
  const activity = message as unknown as { activityType?: unknown; content?: unknown };
1871
- if (activity.activityType === CHART_ACTIVITY_TYPE && this.#chartActivity) {
1872
- this.#drawActivityChart(message.id, activity.content);
2608
+ if (typeof activity.activityType === "string") {
2609
+ this.#drawActivity(message.id, activity.activityType, activity.content);
1873
2610
  }
1874
2611
  return;
1875
2612
  }
@@ -1944,7 +2681,14 @@ export class AgUiChat extends HTMLElement {
1944
2681
  this.#messages.appendChild(bubble);
1945
2682
  }
1946
2683
  this.#updateEmptyState();
1947
- 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
+ }
1948
2692
  return bubble;
1949
2693
  }
1950
2694
 
@@ -1968,9 +2712,6 @@ export class AgUiChat extends HTMLElement {
1968
2712
  }
1969
2713
 
1970
2714
  #render(): void {
1971
- const style = document.createElement("style");
1972
- style.textContent = STYLES;
1973
-
1974
2715
  this.#chat.className = "chat";
1975
2716
  this.#chat.setAttribute("part", "panel");
1976
2717
 
@@ -2045,11 +2786,68 @@ export class AgUiChat extends HTMLElement {
2045
2786
 
2046
2787
  this.#messages.className = "messages";
2047
2788
  this.#messages.setAttribute("part", "messages");
2048
- // Screen readers announce streamed messages as they arrive.
2049
2789
  this.#messages.setAttribute("role", "log");
2050
- 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");
2051
2799
  this.#messages.setAttribute("aria-label", this.#strings.conversation);
2052
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
+
2053
2851
  // Empty-state region: a host slot at the top of the list, hidden as soon as
2054
2852
  // anything renders.
2055
2853
  this.#emptyWrap.className = "empty";
@@ -2140,9 +2938,14 @@ export class AgUiChat extends HTMLElement {
2140
2938
  inputRow.append(composer, this.#fileInput);
2141
2939
  // Skill surfaces sit just above the input: palette (opens on `/`), chips,
2142
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
+
2143
2946
  this.#chat.append(
2144
2947
  header,
2145
- this.#messages,
2948
+ this.#messagesWrap,
2146
2949
  this.#skillsMenu.palette,
2147
2950
  this.#skillsMenu.chips,
2148
2951
  this.#skillHint,
@@ -2207,7 +3010,68 @@ export class AgUiChat extends HTMLElement {
2207
3010
  label: this.#strings.resizePanel,
2208
3011
  }),
2209
3012
  );
2210
- 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];
2211
3075
  }
2212
3076
 
2213
3077
  /**
@@ -2534,6 +3398,7 @@ export class AgUiChat extends HTMLElement {
2534
3398
  // token must still reach every request — the factory's fetch wrapper
2535
3399
  // re-reads this on each call.
2536
3400
  getHeaders: () => this.#requestHeaders(),
3401
+ trustedOrigins: this.trustedOrigins,
2537
3402
  ...this.#credentialsOption(),
2538
3403
  threadId: this.#threadId,
2539
3404
  initialMessages: this.#initialMessages,
@@ -2542,7 +3407,7 @@ export class AgUiChat extends HTMLElement {
2542
3407
  this.#client = new AgUiClient({
2543
3408
  agent,
2544
3409
  handlers: this.#handlers(),
2545
- getTools: () => this.getTools(),
3410
+ getTools: () => this.#advertiseTools(),
2546
3411
  getContext: () => this.#buildContext(),
2547
3412
  executeTool: (call) => this.#executeTool(call),
2548
3413
  resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
@@ -2566,15 +3431,75 @@ export class AgUiChat extends HTMLElement {
2566
3431
  );
2567
3432
  }
2568
3433
 
2569
- /** Whether ``call`` should be gated behind the confirmation card. */
2570
- 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> {
2571
3493
  if (this.autoConfirm) {
2572
- return false;
3494
+ return null;
2573
3495
  }
2574
3496
  if (this.confirmPredicate !== null) {
2575
- 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;
2576
3501
  }
2577
- return isDestructive(tool.parameters);
3502
+ return isDestructive(tool.parameters) ? "destructive" : null;
2578
3503
  }
2579
3504
 
2580
3505
  async #executeTool(call: AgUiToolCall): Promise<ToolExecution | null> {
@@ -2590,7 +3515,15 @@ export class AgUiChat extends HTMLElement {
2590
3515
  // transcript places itself against its own card, and by the time it runs the
2591
3516
  // card is no longer reachable by id.
2592
3517
  this.#cardElements.set(call.id, card.element);
2593
- const tool = this.#resolveTool(call.name);
3518
+ // Scoped out of this round's catalog ⇒ not a frontend tool of ours, for
3519
+ // this round. A host that offers `delete_record` only on the page where
3520
+ // deleting makes sense has said something about *this* run, and a call
3521
+ // arriving anyway (a hallucinated name, or one steered by text the model
3522
+ // just read) must not find the handler that happens to be registered
3523
+ // mount-wide. Treated exactly as an unknown name rather than as a refusal:
3524
+ // withholding a tool and never registering it are the same statement, and
3525
+ // the branch below already says the honest thing for both.
3526
+ const tool = this.#advertisedTools.has(call.name) ? this.#resolveTool(call.name) : null;
2594
3527
  if (tool === null) {
2595
3528
  // Not a client tool. A server-side tool's real output arrives via
2596
3529
  // `onToolResult` (TOOL_CALL_RESULT) and already settled the card — only
@@ -2625,7 +3558,8 @@ export class AgUiChat extends HTMLElement {
2625
3558
  this.#showPending();
2626
3559
  return { content: `Error: ${message}`, error: message };
2627
3560
  }
2628
- if (await this.#needsConfirmation(call, tool)) {
3561
+ const rule = await this.#confirmationRule(call, tool);
3562
+ if (rule !== null) {
2629
3563
  const request: ConfirmationRequest = { toolName: call.name, args: call.args };
2630
3564
  const confirmText = tool.parameters[X_CONFIRM_KEY];
2631
3565
  if (typeof confirmText === "string") {
@@ -2641,9 +3575,13 @@ export class AgUiChat extends HTMLElement {
2641
3575
  const decision = requestConfirmation(this.#ensureGroup(), request, {
2642
3576
  signal: this.#confirmAbort.signal,
2643
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
+ : {}),
2644
3582
  });
2645
3583
  this.#updateEmptyState();
2646
- this.#messages.scrollTop = this.#messages.scrollHeight;
3584
+ this.#scroller.follow();
2647
3585
  const accepted = await decision;
2648
3586
  this.#confirmAbort = null;
2649
3587
  card.recordDecision(accepted ? "approved" : "declined");
@@ -2685,6 +3623,12 @@ export class AgUiChat extends HTMLElement {
2685
3623
  // The navigation never happened; drop the dangling checkpoint.
2686
3624
  this.conversationStore.saveCheckpoint(this.#threadId, null);
2687
3625
  }
3626
+ // The handler's own message, verbatim, in two places at once: the card,
3627
+ // which the user sees, and the tool result, which goes to the endpoint,
3628
+ // is persisted there and is replayed to the model on every later round.
3629
+ // Kept verbatim because a real reason is what lets the agent recover —
3630
+ // and said out loud on `registerTool`, because the second destination is
3631
+ // invisible from the host's side and is not one it can take back.
2688
3632
  const message = error instanceof Error ? error.message : String(error);
2689
3633
  card.settle(TOOL_CALL_STATUS.ERROR, message);
2690
3634
  this.#showPending();
@@ -2719,6 +3663,13 @@ export class AgUiChat extends HTMLElement {
2719
3663
  ): Promise<Record<string, InterruptResponse>> {
2720
3664
  // One controller covers the whole batch: a single Stop denies all of them.
2721
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
+ );
2722
3673
  this.#hidePending();
2723
3674
  const signal = this.#confirmAbort.signal;
2724
3675
  const answered = await Promise.all(
@@ -2736,6 +3687,14 @@ export class AgUiChat extends HTMLElement {
2736
3687
  if (toolName !== null && toolName !== undefined) {
2737
3688
  request.toolName = toolName;
2738
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
+ }
2739
3698
  card?.mark(TOOL_CALL_STATUS.DEFERRED);
2740
3699
  // A host-supplied renderer takes full control of the approval UI. The
2741
3700
  // built-in card renders into the gated call's own card, falling back to
@@ -2746,6 +3705,13 @@ export class AgUiChat extends HTMLElement {
2746
3705
  : await requestApproval(card?.approvalSlot ?? this.#ensureGroup(), request, {
2747
3706
  signal,
2748
3707
  strings: this.#strings,
3708
+ ...(editable
3709
+ ? {
3710
+ onEdit: (args: Record<string, unknown>) => {
3711
+ editedArgs = args;
3712
+ },
3713
+ }
3714
+ : {}),
2749
3715
  });
2750
3716
  // Same annotation as the client-side confirmation gate. Without it the
2751
3717
  // two gates read differently for the same act: a locally-confirmed call
@@ -2760,16 +3726,21 @@ export class AgUiChat extends HTMLElement {
2760
3726
  // now rather than leaving it hanging until the onSettled sweep.
2761
3727
  card?.settle(TOOL_CALL_STATUS.DECLINED, this.#strings.declinedAction);
2762
3728
  }
2763
- return { id: interrupt.id, approved };
3729
+ return { id: interrupt.id, approved, editedArgs };
2764
3730
  }),
2765
3731
  );
2766
3732
  this.#updateEmptyState();
2767
- this.#messages.scrollTop = this.#messages.scrollHeight;
3733
+ this.#scroller.follow();
2768
3734
  this.#confirmAbort = null;
2769
3735
  const responses: Record<string, InterruptResponse> = {};
2770
- 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".
2771
3739
  responses[id] = approved
2772
- ? { status: "resolved", payload: { approved: true } }
3740
+ ? {
3741
+ status: "resolved",
3742
+ payload: editedArgs === undefined ? { approved: true } : { approved: true, editedArgs },
3743
+ }
2773
3744
  : { status: "cancelled" };
2774
3745
  }
2775
3746
  return responses;
@@ -2778,6 +3749,12 @@ export class AgUiChat extends HTMLElement {
2778
3749
  #handlers(): AgUiClientHandlers {
2779
3750
  return {
2780
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
+ }
2781
3758
  this.#setRunning(true);
2782
3759
  // Open the answer group on the turn's first run so the pending
2783
3760
  // indicator (and everything after) lands inside the well. Idempotent:
@@ -2802,7 +3779,10 @@ export class AgUiChat extends HTMLElement {
2802
3779
  this.#hidePending();
2803
3780
  // The answer has begun — fold the thoughts away so they don't crowd it.
2804
3781
  this.#thoughts?.collapse();
2805
- this.#streamInto(buffer);
3782
+ this.#queueStream(buffer);
3783
+ // Counted per delta received, not per render: the word reveal asks
3784
+ // whether the answer *arrived* progressively, which coalescing renders
3785
+ // must not change the answer to.
2806
3786
  this.#streamDeltas += 1;
2807
3787
  },
2808
3788
  onTextEnd: (buffer) => {
@@ -2815,7 +3795,8 @@ export class AgUiChat extends HTMLElement {
2815
3795
  this.#revealWords(bubble);
2816
3796
  }
2817
3797
  attachCopyButtons(bubble, this.#strings);
2818
- this.#streamingBubble = null;
3798
+ this.#attachActions(bubble);
3799
+ this.#endStream();
2819
3800
  this.#noteUnread();
2820
3801
  },
2821
3802
  onToolCall: (call) => {
@@ -2834,25 +3815,47 @@ export class AgUiChat extends HTMLElement {
2834
3815
  this.#cardFor(call);
2835
3816
  },
2836
3817
  onActivity: (activityType, content, messageId) => {
2837
- if (activityType === CHART_ACTIVITY_TYPE) {
2838
- if (this.#chartActivity) {
2839
- this.#drawActivityChart(messageId, content);
2840
- }
2841
- return;
2842
- }
2843
- if (activityType !== COMPACTION_ACTIVITY_TYPE) {
2844
- return;
2845
- }
2846
- const removed = compactionRemoved(content);
2847
- if (removed === null) {
3818
+ this.#drawActivity(messageId, activityType, content);
3819
+ },
3820
+ onCustomEvent: (name, value) => {
3821
+ if (name === INVALIDATE_CUSTOM_NAME) {
3822
+ this.#dispatchInvalidation(value);
2848
3823
  return;
2849
3824
  }
2850
- this.#appendNotice(
2851
- "🗜",
2852
- this.#strings.historyCompacted.replace("{count}", String(removed)),
2853
- "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
+ }),
2854
3836
  );
2855
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
+ },
2856
3859
  onToolResult: (toolCallId, content) => {
2857
3860
  const card = this.#toolCards.get(toolCallId);
2858
3861
  if (card === undefined) {
@@ -2876,33 +3879,50 @@ export class AgUiChat extends HTMLElement {
2876
3879
  this.#showPending();
2877
3880
  },
2878
3881
  onActivityChanged: (messageId, activityType, content) => {
2879
- if (activityType === CHART_ACTIVITY_TYPE && this.#chartActivity) {
2880
- this.#drawActivityChart(messageId, content);
2881
- }
3882
+ this.#drawActivity(messageId, activityType, content);
2882
3883
  },
2883
3884
  onRunEnd: () => {
2884
3885
  // Per-round end; the button stays on Stop until the whole interaction
2885
3886
  // settles — the user must be able to cancel between tool rounds.
2886
3887
  this.#hidePending();
2887
- this.#streamingBubble = null;
3888
+ this.#endStream();
2888
3889
  },
2889
3890
  onError: (message) => {
3891
+ this.#announcedOutcome = true;
3892
+ this.#announce(this.#strings.announceFailed);
2890
3893
  this.#hidePending();
2891
- this.#revealWords(this.appendMessage(MESSAGE_ROLE.ASSISTANT, `⚠️ ${message}`));
2892
- this.#streamingBubble = null;
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);
3907
+ this.#endStream();
2893
3908
  },
2894
3909
  onCancelled: () => {
2895
3910
  // Deliberate stop, not a failure: keep whatever partial text already
2896
3911
  // streamed and add a muted note instead of an error bubble.
3912
+ this.#announcedOutcome = true;
3913
+ this.#announce(this.#strings.announceStopped);
2897
3914
  this.#hidePending();
2898
3915
  this.#appendStoppedNote();
2899
- this.#streamingBubble = null;
3916
+ this.#endStream();
2900
3917
  },
2901
3918
  onSettled: () => {
2902
3919
  // Terminal guarantee: whatever path ended the run, return to rest.
3920
+ if (!this.#announcedOutcome) {
3921
+ this.#announce(this.#strings.announceAnswerReady);
3922
+ }
2903
3923
  this.#hidePending();
2904
3924
  this.#setRunning(false);
2905
- this.#streamingBubble = null;
3925
+ this.#endStream();
2906
3926
  // Belt-and-suspenders: a tool card still pending at settle (e.g. a
2907
3927
  // server tool whose result never streamed because the connection
2908
3928
  // dropped) would hang forever — settle it to the no-result fallback.
@@ -2940,9 +3960,48 @@ export class AgUiChat extends HTMLElement {
2940
3960
  side: this.#serverSettled.has(id) ? "server" : "client",
2941
3961
  }));
2942
3962
  this.#runTools = [];
3963
+ const invalidated = [...this.#runInvalidated];
3964
+ this.#runInvalidated = new Set<string>();
2943
3965
  this.dispatchEvent(
2944
3966
  new CustomEvent<RunFinishedDetail>(RUN_FINISHED_EVENT, {
2945
- 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 },
2946
4005
  bubbles: true,
2947
4006
  composed: true,
2948
4007
  }),
@@ -2958,7 +4017,7 @@ export class AgUiChat extends HTMLElement {
2958
4017
  note.textContent = this.#strings.stopped;
2959
4018
  this.#ensureGroup().appendChild(note);
2960
4019
  this.#updateEmptyState();
2961
- this.#messages.scrollTop = this.#messages.scrollHeight;
4020
+ this.#scroller.follow();
2962
4021
  }
2963
4022
 
2964
4023
  /**
@@ -2983,7 +4042,7 @@ export class AgUiChat extends HTMLElement {
2983
4042
  this.#pending = pending;
2984
4043
  this.#ensureGroup().appendChild(pending);
2985
4044
  this.#updateEmptyState();
2986
- this.#messages.scrollTop = this.#messages.scrollHeight;
4045
+ this.#scroller.follow();
2987
4046
  }
2988
4047
 
2989
4048
  /** Remove the pending indicator if shown. */
@@ -3003,21 +4062,83 @@ export class AgUiChat extends HTMLElement {
3003
4062
  const group = this.#ensureGroup();
3004
4063
  group.insertBefore(this.#thoughts.element, group.firstChild);
3005
4064
  this.#updateEmptyState();
3006
- this.#messages.scrollTop = this.#messages.scrollHeight;
4065
+ this.#scroller.follow();
3007
4066
  }
3008
4067
  return this.#thoughts;
3009
4068
  }
3010
4069
 
3011
- #streamInto(buffer: string): HTMLDivElement {
4070
+ /**
4071
+ * The bubble the current answer streams into, opening it on first sight.
4072
+ *
4073
+ * Opened the moment a token arrives rather than on the frame that draws it,
4074
+ * so the answer's container replaces the pending dots straight away and the
4075
+ * turn never shows a gap while the first render waits for a frame.
4076
+ */
4077
+ #openStream(): HTMLDivElement {
3012
4078
  if (this.#streamingBubble === null) {
3013
4079
  this.#streamingBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, "");
3014
4080
  this.#streamDeltas = 0;
3015
4081
  }
3016
- this.#streamingBubble.innerHTML = renderMarkdown(buffer, { allowImages: this.allowImages });
3017
- this.#messages.scrollTop = this.#messages.scrollHeight;
3018
4082
  return this.#streamingBubble;
3019
4083
  }
3020
4084
 
4085
+ /**
4086
+ * Queue a render of the answer so far, at most one per frame.
4087
+ *
4088
+ * Each `TEXT_MESSAGE_CONTENT` event carries the *whole* accumulated answer,
4089
+ * and drawing it means marked + DOMPurify over the entire document and a
4090
+ * wholesale replacement of the bubble's subtree. Once per token that is
4091
+ * quadratic in the answer's length — a long answer is agent-controlled, so
4092
+ * an ordinary run becomes a progressively stalling tab — and every rebuild
4093
+ * takes any selection or focus inside the bubble with it.
4094
+ *
4095
+ * A frame is the right grain: it is the fastest anything on screen can
4096
+ * change anyway, so a burst of tokens costs one parse and the text still
4097
+ * appears to flow rather than in visible chunks.
4098
+ */
4099
+ #queueStream(buffer: string): void {
4100
+ this.#streamBuffer = buffer;
4101
+ this.#openStream();
4102
+ if (this.#streamFrame !== null) {
4103
+ return;
4104
+ }
4105
+ this.#streamFrame = requestAnimationFrame(() => {
4106
+ this.#streamFrame = null;
4107
+ this.#streamInto(this.#streamBuffer);
4108
+ });
4109
+ }
4110
+
4111
+ /** Render `buffer` into the streaming bubble now, dropping any queued frame. */
4112
+ #streamInto(buffer: string): HTMLDivElement {
4113
+ // A frame still queued would otherwise fire after this and repaint the
4114
+ // bubble with whatever the last delta held — behind the buffer just drawn.
4115
+ if (this.#streamFrame !== null) {
4116
+ cancelAnimationFrame(this.#streamFrame);
4117
+ this.#streamFrame = null;
4118
+ }
4119
+ this.#streamBuffer = buffer;
4120
+ const bubble = this.#openStream();
4121
+ bubble.innerHTML = renderMarkdown(buffer, { allowImages: this.allowImages });
4122
+ this.#scroller.follow();
4123
+ return bubble;
4124
+ }
4125
+
4126
+ /**
4127
+ * Close the current answer's streaming bubble.
4128
+ *
4129
+ * Draws a queued render first. A run that ends without a text end — a
4130
+ * cancel, an error, a round boundary — leaves the last delta sitting in the
4131
+ * queue, and simply dropping the bubble here would strand it: the partial
4132
+ * answer the user stopped mid-sentence would lose its final tokens, or be an
4133
+ * empty bubble above the stopped note.
4134
+ */
4135
+ #endStream(): void {
4136
+ if (this.#streamFrame !== null) {
4137
+ this.#streamInto(this.#streamBuffer);
4138
+ }
4139
+ this.#streamingBubble = null;
4140
+ }
4141
+
3021
4142
  /**
3022
4143
  * The card for ``call``, creating and appending it on first sight.
3023
4144
  *
@@ -3049,7 +4170,7 @@ export class AgUiChat extends HTMLElement {
3049
4170
  #appendNotice(icon: string, text: string, kind: string): void {
3050
4171
  this.#ensureGroup().appendChild(renderRunNotice(icon, text, kind));
3051
4172
  this.#updateEmptyState();
3052
- this.#messages.scrollTop = this.#messages.scrollHeight;
4173
+ this.#scroller.follow();
3053
4174
  }
3054
4175
 
3055
4176
  /**
@@ -3071,9 +4192,20 @@ export class AgUiChat extends HTMLElement {
3071
4192
  * arrives is not something to switch on for everybody.
3072
4193
  */
3073
4194
  enableCharts(routes: readonly ("tool" | "activity")[] = ["tool", "activity"]): void {
3074
- 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);
3075
4197
  if (routes.includes("activity")) {
3076
- 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
+ });
3077
4209
  }
3078
4210
  if (routes.includes("tool")) {
3079
4211
  this.registerTool(createChartTool());
@@ -3121,37 +4253,140 @@ export class AgUiChat extends HTMLElement {
3121
4253
  this.#afterTranscriptGrew();
3122
4254
  }
3123
4255
 
3124
- /** Draw, or redraw in place, the chart for one activity message. */
3125
- #drawActivityChart(messageId: string, content: unknown): void {
3126
- const spec = chartSpecFrom(content);
3127
- const block = spec === null ? null : renderChart(spec);
3128
- if (block === null) {
3129
- // The server superseded this chart with something undrawable. Leaving the
3130
- // old one up is the worst available answer: it shows numbers that have
3131
- // been retracted, reading as current, and a reload then drops the chart
3132
- // entirely because the *stored* content is the version we could not draw.
3133
- // Live and reload should agree, and both should say "gone" rather than
3134
- // one of them lying.
3135
- this.#activityBlocks.get(messageId)?.remove();
3136
- 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);
3137
4338
  return;
3138
4339
  }
3139
4340
  const existing = this.#activityBlocks.get(messageId);
3140
4341
  if (existing === undefined) {
3141
- this.#ensureGroup().appendChild(block);
4342
+ this.#ensureGroup().appendChild(node as HTMLElement);
3142
4343
  } else {
3143
- // Replaced rather than appended: a server redrawing a chart under the same
3144
- // id means *this chart changed*, and a second copy below the first would
3145
- // read as two measurements instead of one that moved.
3146
- 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);
3147
4348
  }
3148
- this.#activityBlocks.set(messageId, block);
4349
+ this.#activityBlocks.set(messageId, node as HTMLElement);
3149
4350
  this.#afterTranscriptGrew();
3150
4351
  }
3151
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
+
3152
4387
  #afterTranscriptGrew(): void {
3153
4388
  this.#updateEmptyState();
3154
- this.#messages.scrollTop = this.#messages.scrollHeight;
4389
+ this.#scroller.follow();
3155
4390
  }
3156
4391
 
3157
4392
  #cardFor(call: AgUiToolCall): ToolCallCard {
@@ -3167,13 +4402,13 @@ export class AgUiChat extends HTMLElement {
3167
4402
  typeof labelled === "string"
3168
4403
  ? labelled
3169
4404
  : (this.toolSummaries[call.name] ??
3170
- this.#toolCatalog[call.name] ??
4405
+ this.#toolCatalog[call.name]?.summary ??
3171
4406
  prettifyToolName(call.name));
3172
4407
  const card = new ToolCallCard(call.name, call.args, summary, this.#strings);
3173
4408
  this.#toolCards.set(call.id, card);
3174
4409
  this.#ensureGroup().appendChild(card.element);
3175
4410
  this.#updateEmptyState();
3176
- this.#messages.scrollTop = this.#messages.scrollHeight;
4411
+ this.#scroller.follow();
3177
4412
  return card;
3178
4413
  }
3179
4414
  }
@@ -3198,6 +4433,15 @@ function confirmPhrase(interrupt: Interrupt): string | undefined {
3198
4433
  }
3199
4434
 
3200
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
+
3201
4445
  interface RestoredToolCall {
3202
4446
  readonly id: string;
3203
4447
  readonly function: { readonly name: string; readonly arguments?: unknown };