@artooi/ag-ui-web-component 0.26.1 → 0.28.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 (43) hide show
  1. package/CHANGELOG.md +291 -1
  2. package/README.md +191 -8
  3. package/dist/ag-ui-web-component.bundle.js +50 -50
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/core/ag_ui_chat.d.ts +61 -3
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/agui_client.d.ts +8 -1
  8. package/dist/core/agui_client.d.ts.map +1 -1
  9. package/dist/core/conversation_store.d.ts +58 -3
  10. package/dist/core/conversation_store.d.ts.map +1 -1
  11. package/dist/core/create_http_agent.d.ts +13 -0
  12. package/dist/core/create_http_agent.d.ts.map +1 -1
  13. package/dist/core/remote_conversation_store.d.ts +29 -1
  14. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  15. package/dist/core/utils.d.ts +42 -0
  16. package/dist/core/utils.d.ts.map +1 -1
  17. package/dist/index.js +602 -104
  18. package/dist/index.js.map +4 -4
  19. package/dist/tools/is_destructive.d.ts +8 -2
  20. package/dist/tools/is_destructive.d.ts.map +1 -1
  21. package/dist/tools/parse_tool_catalog.d.ts +11 -4
  22. package/dist/tools/parse_tool_catalog.d.ts.map +1 -1
  23. package/dist/ui/render_markdown.d.ts +23 -5
  24. package/dist/ui/render_markdown.d.ts.map +1 -1
  25. package/dist/ui/resize_handle.d.ts +5 -1
  26. package/dist/ui/resize_handle.d.ts.map +1 -1
  27. package/dist/ui/ui_strings.d.ts +13 -7
  28. package/dist/ui/ui_strings.d.ts.map +1 -1
  29. package/dist/ui/voice_input.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/core/ag_ui_chat.ts +444 -45
  32. package/src/core/agui_client.ts +43 -2
  33. package/src/core/conversation_store.ts +146 -49
  34. package/src/core/create_http_agent.ts +24 -2
  35. package/src/core/remote_conversation_store.ts +45 -3
  36. package/src/core/utils.ts +83 -1
  37. package/src/tools/is_destructive.ts +8 -2
  38. package/src/tools/parse_tool_catalog.ts +18 -6
  39. package/src/ui/render_markdown.ts +111 -21
  40. package/src/ui/resize_handle.ts +32 -2
  41. package/src/ui/ui_strings.ts +19 -8
  42. package/src/ui/voice_input.ts +43 -0
  43. package/src/version.ts +1 -1
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "@ag-ui/client";
1
2
  import type { Context, Interrupt, Message, Tool } from "@ag-ui/core";
2
3
  import {
3
4
  ATTACHMENT_EVENT,
@@ -31,7 +32,7 @@ import { isNavigates } from "../tools/is_navigates.js";
31
32
  import { createPageActionTools, type ResolvePageTarget } from "../tools/page_action_tools.js";
32
33
  import { createPageMapContext, type PageMap } from "../tools/page_map.js";
33
34
  import { createPageStateTools, type PageState } from "../tools/page_state.js";
34
- import { parseToolCatalog } from "../tools/parse_tool_catalog.js";
35
+ import { parseToolCatalog, type ToolCatalogEntry } from "../tools/parse_tool_catalog.js";
35
36
  import { createRouteTools, type RouteMap } from "../tools/route_map.js";
36
37
  import {
37
38
  type ApprovalRenderer,
@@ -80,13 +81,14 @@ import {
80
81
  type ClientConversationStore,
81
82
  type NavigationCheckpoint,
82
83
  SessionStorageStore,
84
+ writeStoredItem,
83
85
  } from "./conversation_store.js";
84
86
  import { type AgentFactory, createHttpAgent } from "./create_http_agent.js";
85
87
  import { RemoteConversationStore } from "./remote_conversation_store.js";
86
88
  import { RunIndex } from "./run_index.js";
87
89
  import { type TranscribeHandler, transcribeAudio } from "./transcribe_audio.js";
88
90
  import { type UploadHandler, uploadAttachment } from "./upload_attachment.js";
89
- import { withCredentials } from "./utils.js";
91
+ import { mintThread, warnOnCrossOriginCredentials, withCredentials } from "./utils.js";
90
92
 
91
93
  /** The role a rendered chat message takes. */
92
94
  export type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE];
@@ -156,6 +158,7 @@ const CONNECT_TIME_ATTRIBUTES = [
156
158
  "data-attachment-max-bytes",
157
159
  "data-transcribe-url",
158
160
  "data-threads-url",
161
+ "data-threads-cache",
159
162
  "data-tools-url",
160
163
  "data-skills-url",
161
164
  "data-skills",
@@ -187,6 +190,17 @@ const SIZE_KEY = "ag-ui-chat:size";
187
190
  /** Per-tab persistence key for the built-in theme toggle. */
188
191
  const THEME_KEY = "ag-ui-chat:theme";
189
192
 
193
+ /**
194
+ * Storage namespaces already spoken for in this document.
195
+ *
196
+ * Per document rather than per origin, and released on disconnect, because the
197
+ * question it answers is "is another element on this page using these keys right
198
+ * now" — not "has anything ever used them". A registry that never released would
199
+ * turn every remount, and every framework re-render that moves the node, into a
200
+ * false collision that costs the element its own conversation.
201
+ */
202
+ const CLAIMED_NAMESPACES = new Set<string>();
203
+
190
204
  /**
191
205
  * `<ag-ui-chat>` — a framework-free chat sidebar Web Component over AG-UI.
192
206
  *
@@ -224,6 +238,23 @@ export class AgUiChat extends HTMLElement {
224
238
  */
225
239
  getHeaders: (() => Record<string, string>) | null = null;
226
240
 
241
+ /**
242
+ * Origins, besides the page's own, that this element may send {@link headers}
243
+ * and {@link getHeaders} credentials to without saying so on the console.
244
+ *
245
+ * Seven attributes name a URL, and every one of them carries these headers.
246
+ * They are plain HTML, so a page that builds one from a query parameter or
247
+ * from tenant-authored configuration has handed an attacker the destination,
248
+ * and the token leaves on the element's first request. Naming the origins you
249
+ * expect turns that from silent into either confirmed or reported.
250
+ *
251
+ * A notice rather than a refusal: a cross-origin agent is a documented
252
+ * deployment, so refusing would break working installations to defend against
253
+ * a page that is already interpolating untrusted data into its own markup.
254
+ * Leaving this empty costs nothing but one console line per foreign origin.
255
+ */
256
+ trustedOrigins: readonly string[] = [];
257
+
227
258
  /**
228
259
  * Permit `<img>` in rendered assistant markdown. **Off by default**: a
229
260
  * model-controlled image URL is fetched with no user interaction, which
@@ -381,14 +412,40 @@ export class AgUiChat extends HTMLElement {
381
412
  resolvePageTarget: ResolvePageTarget = (target) => document.querySelector<HTMLElement>(target);
382
413
 
383
414
  /**
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.
415
+ * The server tool catalog fetched from `data-tools-url`, keyed by tool
416
+ * name. Cards label themselves from each entry's `summary`, the base
417
+ * layer behind {@link toolSummaries}: an explicit entry in `toolSummaries`
418
+ * wins, this fills the rest. Held as whole entries rather than labels so a
419
+ * field the server sent is not lost on the way in. Populated once on connect.
387
420
  */
388
- #toolCatalog: Record<string, string> = {};
421
+ #toolCatalog: Record<string, ToolCatalogEntry> = {};
422
+
423
+ /**
424
+ * Foreign origins already reported, so the notice is once per origin per
425
+ * element rather than once per request. Per-element rather than module-level,
426
+ * because two elements on one page are two separate configurations.
427
+ */
428
+ #warnedOrigins = new Set<string>();
389
429
  /** The resolved string table (defaults ← `data-strings` ← `strings`). */
390
430
  #strings: UiStrings = DEFAULT_UI_STRINGS;
391
431
 
432
+ /**
433
+ * The tool names the current round handed the agent, captured as the catalog
434
+ * went out.
435
+ *
436
+ * The registry is mount-wide but {@link getTools} is per-run, so a host is
437
+ * free to scope what a given page offers — and a call naming a tool this run
438
+ * withheld must not reach the handler that is merely still registered.
439
+ * Snapshotted rather than re-asked at dispatch: a provider is a function, and
440
+ * calling it again asks a question the run already answered, which is exactly
441
+ * the window a scoped catalog exists to close.
442
+ *
443
+ * Empty until the first round advertises, which cannot precede a call: the
444
+ * client builds `RunAgentInput.tools` at the top of every round, before the
445
+ * calls that round produces are executed.
446
+ */
447
+ #advertisedTools: ReadonlySet<string> = new Set();
448
+
392
449
  readonly #toolRegistry = new ClientToolRegistry();
393
450
  /** Tool-call cards awaiting execution, keyed by call id. */
394
451
  readonly #toolCards = new Map<string, ToolCallCard>();
@@ -470,6 +527,12 @@ export class AgUiChat extends HTMLElement {
470
527
  // revealed progressively as it streamed, so the word reveal must not re-animate
471
528
  // it; ≤1 ⇒ it arrived at once and the word reveal is appropriate.
472
529
  #streamDeltas = 0;
530
+ // The accumulated answer the next render will draw. Deltas overwrite it
531
+ // (each one carries the whole answer), so a frame always draws the latest.
532
+ #streamBuffer = "";
533
+ // The frame that render is queued on, or `null` when nothing is queued —
534
+ // also the flag saying a delta is still undrawn.
535
+ #streamFrame: number | null = null;
473
536
  #pending: HTMLDivElement | null = null;
474
537
  // The current assistant turn's grouping container. One `.answer`
475
538
  // wraps everything a single answer produces — streamed text, tool cards, the
@@ -484,9 +547,23 @@ export class AgUiChat extends HTMLElement {
484
547
  #thoughts: ThoughtsBlock | null = null;
485
548
  #threadId = "";
486
549
  // 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.
550
+ // size), so two instances on one origin don't clobber each other. Empty ⇒ the
551
+ // pre-namespacing global keys (back-compat). Resolved on connect; the
552
+ // conversation adds `user-key` on top of it, see #conversationNs.
489
553
  #storageNs = "";
554
+ // The entry this element put in CLAIMED_NAMESPACES, to take back out on
555
+ // disconnect. `null` when it claimed nothing (no id, no endpoint, or it lost
556
+ // the claim to an element that mounted first).
557
+ #claimedNs: string | null = null;
558
+ // The fallback namespace minted when the preferred one was already claimed,
559
+ // with the preferred value it was minted for — so the element keeps it across
560
+ // remounts, but re-resolves if the host answers the warning with an `id`.
561
+ #generatedNs = "";
562
+ #generatedFor = "";
563
+ // The `sessionStorage`-backed store, which the element may therefore re-scope
564
+ // on a principal change. `null` when the host injected a store of its own
565
+ // kind, whose keying the element does not know and must not guess at.
566
+ #builtinStore: SessionStorageStore | null = null;
490
567
  // Bumped on every #rehydrate; a replay whose generation is stale (a newer
491
568
  // thread switch started while it awaited a slow store) drops its result.
492
569
  #rehydrateGeneration = 0;
@@ -544,7 +621,7 @@ export class AgUiChat extends HTMLElement {
544
621
  if (this.#runIndex === null) {
545
622
  this.#runIndex = new RunIndex(
546
623
  url,
547
- () => this.#requestHeaders(),
624
+ () => this.#headersFor(url),
548
625
  () => this.#requestCredentials(),
549
626
  );
550
627
  }
@@ -579,6 +656,7 @@ export class AgUiChat extends HTMLElement {
579
656
  endpoint,
580
657
  headers: this.#requestHeaders(),
581
658
  getHeaders: () => this.#requestHeaders(),
659
+ trustedOrigins: this.trustedOrigins,
582
660
  ...this.#credentialsOption(),
583
661
  threadId: this.#threadId,
584
662
  // The seed the endpoints assume: nothing. The snapshot is the history.
@@ -587,7 +665,7 @@ export class AgUiChat extends HTMLElement {
587
665
  const client = new AgUiClient({
588
666
  agent,
589
667
  handlers: this.#handlers(),
590
- getTools: () => this.getTools(),
668
+ getTools: () => this.#advertiseTools(),
591
669
  getContext: () => this.#buildContext(),
592
670
  executeTool: (call) => this.#executeTool(call),
593
671
  resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
@@ -604,7 +682,7 @@ export class AgUiChat extends HTMLElement {
604
682
 
605
683
  /** Attributes the element reacts to after it has been connected. */
606
684
  static get observedAttributes(): string[] {
607
- return ["title-text", "placement", "credentials", ...CONNECT_TIME_ATTRIBUTES];
685
+ return ["title-text", "placement", "credentials", "user-key", ...CONNECT_TIME_ATTRIBUTES];
608
686
  }
609
687
 
610
688
  attributeChangedCallback(name: string, previous: string | null, value: string | null): void {
@@ -638,6 +716,16 @@ export class AgUiChat extends HTMLElement {
638
716
  this.#title.textContent = value ?? this.#strings.title;
639
717
  return;
640
718
  }
719
+ if (name === "user-key") {
720
+ // Before connect there is nothing to move: connectedCallback resolves the
721
+ // namespace from the attribute as it stands by then. An absent attribute
722
+ // and an empty one name the same (unnamed) principal, so neither is a
723
+ // change worth acting on.
724
+ if (this.#connected && (previous ?? "") !== (value ?? "")) {
725
+ this.#changePrincipal(previous ?? "", value ?? "");
726
+ }
727
+ return;
728
+ }
641
729
  // Everything else here is read once, in connectedCallback, to build chrome
642
730
  // that then exists or does not. A later change is silently ignored and the
643
731
  // symptom is an affordance that never appears, which reads as a broken
@@ -657,7 +745,18 @@ export class AgUiChat extends HTMLElement {
657
745
  );
658
746
  }
659
747
 
660
- /** Declare a frontend tool the agent may call. */
748
+ /**
749
+ * Declare a frontend tool the agent may call.
750
+ *
751
+ * **A handler's thrown message leaves the browser.** When a handler rejects,
752
+ * its `Error.message` is posted back as that call's tool result — into the
753
+ * conversation, on to the AG-UI endpoint, persisted server-side, and
754
+ * forwarded to the model provider on every later round. That is deliberate,
755
+ * since it is what lets the agent recover from a failure it caused; but it
756
+ * means an internal hostname, a signed URL or a stack-derived path in a
757
+ * rethrown error is disclosed to parties the host never chose. Throw the
758
+ * message you would be content for the model to read, and log the detail.
759
+ */
661
760
  registerTool(tool: ClientTool): void {
662
761
  this.#toolRegistry.register(tool);
663
762
  }
@@ -835,6 +934,19 @@ export class AgUiChat extends HTMLElement {
835
934
  return answer;
836
935
  }
837
936
 
937
+ /**
938
+ * The catalog for the round about to start, remembering what it offered.
939
+ *
940
+ * Every path to a frontend tool goes through here first — the client asks
941
+ * for `RunAgentInput.tools` at the top of each round — so this is the one
942
+ * place that can know what the agent was actually told about.
943
+ */
944
+ #advertiseTools(): Tool[] {
945
+ const tools = this.getTools();
946
+ this.#advertisedTools = new Set(tools.map((tool) => tool.name));
947
+ return tools;
948
+ }
949
+
838
950
  /** Resolve a tool by name: built-in tools first, then the registry. */
839
951
  #resolveTool(name: string): ClientTool | null {
840
952
  const builtin = this.#builtinTools().find((t) => t.name === name);
@@ -856,6 +968,39 @@ export class AgUiChat extends HTMLElement {
856
968
  this.setAttribute("endpoint", value);
857
969
  }
858
970
 
971
+ /**
972
+ * Who the stored conversation belongs to, from the `user-key` attribute.
973
+ *
974
+ * Set it to whatever identifies the signed-in principal — a user id, an
975
+ * account id, a hash of one. The value joins the storage namespace, so two
976
+ * principals in the same tab cannot read each other's transcript, and
977
+ * **changing it purges what the previous one left behind**.
978
+ *
979
+ * That purge is the reason this is a live attribute rather than a
980
+ * connect-time one. `sessionStorage` survives same-tab navigation, so it
981
+ * survives a logout; and a single-page app signs out through its own router
982
+ * without remounting anything, so there is no other moment at which the
983
+ * element could find out. The host naming the new principal — or dropping the
984
+ * attribute — is the signal.
985
+ *
986
+ * Absent means exactly today's behaviour, which is why nothing breaks by
987
+ * leaving it off: the conversation is scoped to the element and to nobody in
988
+ * particular, and on a shared workstation it carries into whoever signs in
989
+ * next in the same tab.
990
+ *
991
+ * The first value to arrive is treated as a host naming the user who was
992
+ * already there, not as a handover: the conversation in progress moves into
993
+ * the principal's namespace rather than being destroyed, so an element
994
+ * configured by an async auth handshake keeps what is on screen.
995
+ */
996
+ get userKey(): string {
997
+ return this.getAttribute("user-key") ?? "";
998
+ }
999
+
1000
+ set userKey(value: string) {
1001
+ this.setAttribute("user-key", value);
1002
+ }
1003
+
859
1004
  /**
860
1005
  * Cookie policy for **every** request this element makes, as `fetch`'s own
861
1006
  * `credentials` mode (`"omit"` / `"same-origin"` / `"include"`). Mirrored to
@@ -907,6 +1052,25 @@ export class AgUiChat extends HTMLElement {
907
1052
  return { ...this.headers, ...this.getHeaders?.() };
908
1053
  }
909
1054
 
1055
+ /**
1056
+ * The request headers, having first reported the destination if it is foreign.
1057
+ *
1058
+ * Every caller that sends these headers knows its URL, and `#requestHeaders`
1059
+ * does not -- so the check lives here, on the path that has both, rather than
1060
+ * being repeated at each call site with a chance to be forgotten at the next
1061
+ * one added.
1062
+ */
1063
+ #headersFor(url: string): Record<string, string> {
1064
+ const headers = this.#requestHeaders();
1065
+ warnOnCrossOriginCredentials(
1066
+ url,
1067
+ Object.keys(headers),
1068
+ this.trustedOrigins,
1069
+ this.#warnedOrigins,
1070
+ );
1071
+ return headers;
1072
+ }
1073
+
910
1074
  /** The configured cookie policy as `fetch` spells it; `undefined` when unset. */
911
1075
  #requestCredentials(): RequestCredentials | undefined {
912
1076
  return this.credentials ?? undefined;
@@ -927,8 +1091,8 @@ export class AgUiChat extends HTMLElement {
927
1091
  }
928
1092
 
929
1093
  /** The `fetch` init for the element's own plain GETs (catalogs). */
930
- #fetchInit(): RequestInit | undefined {
931
- return withCredentials({ headers: this.#requestHeaders() }, this.#requestCredentials());
1094
+ #fetchInit(url: string): RequestInit | undefined {
1095
+ return withCredentials({ headers: this.#headersFor(url) }, this.#requestCredentials());
932
1096
  }
933
1097
 
934
1098
  /**
@@ -956,10 +1120,10 @@ export class AgUiChat extends HTMLElement {
956
1120
  }
957
1121
 
958
1122
  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;
1123
+ // Resolve the per-instance storage namespace before any key read/write, so
1124
+ // this instance doesn't share collapsed/theme/thread state with another on
1125
+ // the same origin.
1126
+ this.#storageNs = this.#claimNamespace();
963
1127
  // Restore a dragged size before the panel paints, so it does not snap from
964
1128
  // the placement default to the user's width on the first frame.
965
1129
  this.#applySize(this.#readSize());
@@ -989,8 +1153,13 @@ export class AgUiChat extends HTMLElement {
989
1153
  this.#initSkills();
990
1154
  // Namespace the built-in default store too (a host-injected store is used
991
1155
  // verbatim). Must precede #wireThreadStore, which wraps the current store.
992
- if (this.#storageNs !== "" && this.conversationStore instanceof SessionStorageStore) {
993
- this.conversationStore = new SessionStorageStore(this.#storageNs);
1156
+ if (this.conversationStore instanceof SessionStorageStore) {
1157
+ const namespace = this.#conversationNs();
1158
+ // Remembered either way: this is the element's own store, so a later
1159
+ // `user-key` change may move it to another namespace.
1160
+ this.#builtinStore =
1161
+ namespace === "" ? this.conversationStore : new SessionStorageStore(namespace);
1162
+ this.conversationStore = this.#builtinStore;
994
1163
  }
995
1164
  this.#wireThreadStore();
996
1165
  this.#wireAttachments();
@@ -1064,6 +1233,14 @@ export class AgUiChat extends HTMLElement {
1064
1233
  */
1065
1234
  disconnectedCallback(): void {
1066
1235
  this.#connected = false;
1236
+ // Give the namespace back. A disconnect is not necessarily a farewell — a
1237
+ // DOM move and a framework re-render both look like one — and an element
1238
+ // that could not reclaim its own namespace on the way back in would lose
1239
+ // its conversation to a false collision.
1240
+ if (this.#claimedNs !== null) {
1241
+ CLAIMED_NAMESPACES.delete(this.#claimedNs);
1242
+ this.#claimedNs = null;
1243
+ }
1067
1244
  this.#cancelRun();
1068
1245
  this.#attachTray?.dispose();
1069
1246
  this.#voice?.dispose();
@@ -1143,7 +1320,7 @@ export class AgUiChat extends HTMLElement {
1143
1320
  return (file, onProgress, signal) =>
1144
1321
  uploadAttachment(file, {
1145
1322
  url,
1146
- headers: this.#requestHeaders(),
1323
+ headers: this.#headersFor(url),
1147
1324
  ...this.#credentialsOption(),
1148
1325
  onProgress,
1149
1326
  signal,
@@ -1179,7 +1356,7 @@ export class AgUiChat extends HTMLElement {
1179
1356
  return (audio) =>
1180
1357
  transcribeAudio(audio, {
1181
1358
  url,
1182
- headers: this.#requestHeaders(),
1359
+ headers: this.#headersFor(url),
1183
1360
  ...this.#credentialsOption(),
1184
1361
  });
1185
1362
  }
@@ -1240,15 +1417,21 @@ export class AgUiChat extends HTMLElement {
1240
1417
  * delete through that server endpoint (wrapping the current store as the
1241
1418
  * client-only fallback), so the history drawer shows durable, cross-device
1242
1419
  * threads. Without it, the client store's per-tab threads are used.
1420
+ *
1421
+ * `data-threads-cache="false"` drops the local copy of the message bodies —
1422
+ * for the deployment that pointed history at a server precisely so that
1423
+ * transcripts do not sit in the browser. The client-only concerns (the active
1424
+ * thread id, the navigation checkpoint) keep their local store either way.
1243
1425
  */
1244
1426
  #wireThreadStore(): void {
1245
1427
  const url = this.getAttribute("data-threads-url");
1246
1428
  if (url !== null) {
1247
1429
  this.conversationStore = new RemoteConversationStore(
1248
1430
  url,
1249
- () => this.#requestHeaders(),
1431
+ () => this.#headersFor(url),
1250
1432
  this.conversationStore,
1251
1433
  () => this.#requestCredentials(),
1434
+ this.getAttribute("data-threads-cache") !== "false",
1252
1435
  );
1253
1436
  }
1254
1437
  }
@@ -1260,7 +1443,7 @@ export class AgUiChat extends HTMLElement {
1260
1443
  return;
1261
1444
  }
1262
1445
  try {
1263
- const response = await fetch(url, this.#fetchInit());
1446
+ const response = await fetch(url, this.#fetchInit(url));
1264
1447
  this.#toolCatalog = parseToolCatalog(await response.json());
1265
1448
  } catch {
1266
1449
  // Network/parse failure: cards fall back to toolSummaries / raw names.
@@ -1308,7 +1491,7 @@ export class AgUiChat extends HTMLElement {
1308
1491
  return;
1309
1492
  }
1310
1493
  try {
1311
- const response = await fetch(url, this.#fetchInit());
1494
+ const response = await fetch(url, this.#fetchInit(url));
1312
1495
  this.#backendSkills = parseSkills(await response.json());
1313
1496
  this.#recomputeSkills();
1314
1497
  } catch {
@@ -1409,7 +1592,7 @@ export class AgUiChat extends HTMLElement {
1409
1592
  } else {
1410
1593
  this.removeAttribute("collapsed");
1411
1594
  }
1412
- sessionStorage.setItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
1595
+ writeStoredItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
1413
1596
  // Expanding is what marks the waiting answers read; collapsing starts a
1414
1597
  // fresh count. Either way the badge is cleared and the host told.
1415
1598
  this.#setUnread(0);
@@ -1446,7 +1629,7 @@ export class AgUiChat extends HTMLElement {
1446
1629
  toggleTheme(): void {
1447
1630
  const next = this.getAttribute("theme") === "dark" ? "light" : "dark";
1448
1631
  this.setAttribute("theme", next);
1449
- sessionStorage.setItem(this.#storageKey(THEME_KEY), next);
1632
+ writeStoredItem(this.#storageKey(THEME_KEY), next);
1450
1633
  this.#syncThemeGlyph();
1451
1634
  }
1452
1635
 
@@ -1557,7 +1740,7 @@ export class AgUiChat extends HTMLElement {
1557
1740
  /** Persist a dragged size per tab, alongside the collapsed/theme preferences. */
1558
1741
  #persistSize(size: ResizeSize): void {
1559
1742
  const stored = { ...this.#readSize(), ...size };
1560
- sessionStorage.setItem(this.#storageKey(SIZE_KEY), JSON.stringify(stored));
1743
+ writeStoredItem(this.#storageKey(SIZE_KEY), JSON.stringify(stored));
1561
1744
  }
1562
1745
 
1563
1746
  /** The persisted size for this instance, or an empty record. */
@@ -1576,6 +1759,130 @@ export class AgUiChat extends HTMLElement {
1576
1759
  }
1577
1760
  }
1578
1761
 
1762
+ /**
1763
+ * Claim this element's storage namespace: its `id`, else its `endpoint`.
1764
+ *
1765
+ * The endpoint fallback exists so a lone widget restores its conversation
1766
+ * across reloads with nothing asked of the page author. It stops working the
1767
+ * moment there are two of them — a docked support panel and an inline page
1768
+ * assistant against one agent mount, neither carrying an `id`, which nothing
1769
+ * requires — because both resolve to the same string and then share a thread
1770
+ * pointer, a drawer index and every message key. Whichever mounts second
1771
+ * adopts the first's active thread and rehydrates its transcript into its own
1772
+ * panel: one conversation's content inside another, on the same page.
1773
+ *
1774
+ * So the namespace is claimed by the first element to mount under it, and a
1775
+ * second is given one of its own plus a warning naming the fix. The first
1776
+ * element keeps the endpoint namespace, which is what leaves the ordinary
1777
+ * single-element case exactly as it was.
1778
+ *
1779
+ * The generated namespace is random rather than derived from mount order.
1780
+ * That costs the second element its history across reloads — the warning says
1781
+ * so, and an `id` fixes it — which is the honest trade against an order-based
1782
+ * name that would silently hand a stored conversation to whichever element
1783
+ * happened to mount second on the next load.
1784
+ */
1785
+ #claimNamespace(): string {
1786
+ const preferred = this.id !== "" ? this.id : this.endpoint;
1787
+ // Nothing to key on. The pre-namespacing global keys, as before: an element
1788
+ // with neither an id nor an endpoint cannot send anything, so what it would
1789
+ // be claiming is an empty conversation.
1790
+ if (preferred === "") {
1791
+ return "";
1792
+ }
1793
+ // Already lost this claim once. Keep the fallback rather than drifting back
1794
+ // onto a namespace the other element may since have released, which would
1795
+ // swap this panel's conversation for that one's.
1796
+ if (this.#generatedFor === preferred) {
1797
+ return this.#generatedNs;
1798
+ }
1799
+ if (!CLAIMED_NAMESPACES.has(preferred)) {
1800
+ CLAIMED_NAMESPACES.add(preferred);
1801
+ this.#claimedNs = preferred;
1802
+ return preferred;
1803
+ }
1804
+ this.#generatedFor = preferred;
1805
+ this.#generatedNs = `${preferred}~${randomUUID()}`;
1806
+ console.warn(
1807
+ `<ag-ui-chat>: another element on this page already stores its ` +
1808
+ `conversation under "${preferred}", so this one has been given a ` +
1809
+ "throwaway namespace of its own — the two would otherwise share a " +
1810
+ "thread pointer, a history drawer and every message. Give each " +
1811
+ "<ag-ui-chat> its own id to keep them apart and let this one restore " +
1812
+ "its conversation across reloads.",
1813
+ );
1814
+ return this.#generatedNs;
1815
+ }
1816
+
1817
+ /**
1818
+ * The conversation store's namespace: this element's, scoped to the principal
1819
+ * {@link userKey} names.
1820
+ *
1821
+ * Only the conversation is principal-scoped. The panel's own collapsed / size
1822
+ * / theme preferences stay on `#storageNs`, because they are this element's
1823
+ * UI state rather than anyone's data — they carry no word of what was said —
1824
+ * and because they are read once while connecting, so re-scoping them under a
1825
+ * live element would rearrange the panel around a user who had only just
1826
+ * signed in.
1827
+ */
1828
+ #conversationNs(key: string = this.userKey): string {
1829
+ return key === "" ? this.#storageNs : `${this.#storageNs}#${key}`;
1830
+ }
1831
+
1832
+ /**
1833
+ * Move the element's client state from one principal to another.
1834
+ *
1835
+ * The whole reason {@link userKey} is live: `sessionStorage` outlives a
1836
+ * logout, because a logout is a navigation (or, in a single-page app, not
1837
+ * even that) rather than a tab close. Nothing remounts, so the host naming
1838
+ * the new principal is the only signal the element will ever get.
1839
+ */
1840
+ #changePrincipal(previousKey: string, nextKey: string): void {
1841
+ const previous = this.#conversationNs(previousKey);
1842
+ const next = this.#conversationNs(nextKey);
1843
+ if (previousKey === "") {
1844
+ // Absent to present is not a handover. It is the documented late
1845
+ // configuration shape — the element mounts, an auth handshake resolves,
1846
+ // and only then is the user known — so the conversation already on screen
1847
+ // belongs to this principal and moves with them. Moving rather than
1848
+ // copying also matters: a copy left behind under the unscoped namespace
1849
+ // is a transcript the next key-less mount would happily adopt.
1850
+ SessionStorageStore.adopt(previous, next);
1851
+ this.#rescopeStore(next);
1852
+ return;
1853
+ }
1854
+ SessionStorageStore.purge(previous);
1855
+ this.#rescopeStore(next);
1856
+ // The transcript on screen, the run in flight and the replayed history all
1857
+ // belong to the principal who just left. Purging storage without clearing
1858
+ // these would leave the previous user's conversation visible to the new one.
1859
+ this.#cancelRun();
1860
+ this.#resetState();
1861
+ this.#setRunning(false);
1862
+ this.#setUnread(0);
1863
+ this.#threadId = this.conversationStore.threadId();
1864
+ void this.#rehydrate();
1865
+ void this.#refreshDrawer();
1866
+ }
1867
+
1868
+ /**
1869
+ * Rebuild the `sessionStorage` store under `namespace`, re-wrapping it for
1870
+ * `data-threads-url` exactly as connecting did.
1871
+ *
1872
+ * A store of the host's own kind is left alone: a store that holds its data
1873
+ * somewhere the element cannot see has to scope itself. The transcript on
1874
+ * screen is still cleared either way — the host swapped principals, and that
1875
+ * much is the element's to act on.
1876
+ */
1877
+ #rescopeStore(namespace: string): void {
1878
+ if (this.#builtinStore === null) {
1879
+ return;
1880
+ }
1881
+ this.#builtinStore = new SessionStorageStore(namespace);
1882
+ this.conversationStore = this.#builtinStore;
1883
+ this.#wireThreadStore();
1884
+ }
1885
+
1579
1886
  /** This instance's namespaced form of an origin-scoped storage key. */
1580
1887
  #storageKey(base: string): string {
1581
1888
  return this.#storageNs === "" ? base : `${base}:${this.#storageNs}`;
@@ -1653,16 +1960,25 @@ export class AgUiChat extends HTMLElement {
1653
1960
  }
1654
1961
 
1655
1962
  /**
1656
- * Start a fresh conversation: forget the persisted history, drop the
1657
- * in-memory run state, clear the transcript, and mint a new thread id.
1963
+ * Start a fresh conversation: drop the in-memory run state, clear the
1964
+ * transcript, and mint a new thread id.
1965
+ *
1966
+ * The conversation being left is kept, and stays in the history drawer to
1967
+ * return to. Deleting one is the drawer row's own action; a button that
1968
+ * starts something new must not be the button that destroys what was there.
1658
1969
  */
1659
1970
  newChat(): void {
1660
1971
  // Stop any in-flight run first — discarding the client mid-run would
1661
1972
  // leave the old agent streaming into a cleared transcript.
1662
1973
  this.#cancelRun();
1663
- this.conversationStore.clear(this.#threadId);
1974
+ // A thread nothing was ever sent in has nothing to come back to, and the
1975
+ // drawer never listed it — so reap it here rather than strand one record
1976
+ // per press of a button whose whole use is being pressed again.
1977
+ if (this.conversationStore.isUnsent?.(this.#threadId) === true) {
1978
+ this.conversationStore.clear(this.#threadId);
1979
+ }
1664
1980
  this.#resetState();
1665
- this.#threadId = this.conversationStore.threadId();
1981
+ this.#threadId = mintThread(this.conversationStore);
1666
1982
  this.#setRunning(false);
1667
1983
  this.#setUnread(0);
1668
1984
  }
@@ -1670,7 +1986,10 @@ export class AgUiChat extends HTMLElement {
1670
1986
  /** Drop the in-memory run + transcript, leaving the thread id untouched. */
1671
1987
  #resetState(): void {
1672
1988
  this.#client = null;
1673
- this.#streamingBubble = null;
1989
+ // Before the transcript goes: a render still queued would otherwise fire
1990
+ // against the wiped list and open a fresh bubble holding the discarded
1991
+ // conversation's last tokens.
1992
+ this.#endStream();
1674
1993
  this.#currentGroup = null;
1675
1994
  this.#thoughts = null;
1676
1995
  this.#hidePending();
@@ -2525,6 +2844,7 @@ export class AgUiChat extends HTMLElement {
2525
2844
  // token must still reach every request — the factory's fetch wrapper
2526
2845
  // re-reads this on each call.
2527
2846
  getHeaders: () => this.#requestHeaders(),
2847
+ trustedOrigins: this.trustedOrigins,
2528
2848
  ...this.#credentialsOption(),
2529
2849
  threadId: this.#threadId,
2530
2850
  initialMessages: this.#initialMessages,
@@ -2533,7 +2853,7 @@ export class AgUiChat extends HTMLElement {
2533
2853
  this.#client = new AgUiClient({
2534
2854
  agent,
2535
2855
  handlers: this.#handlers(),
2536
- getTools: () => this.getTools(),
2856
+ getTools: () => this.#advertiseTools(),
2537
2857
  getContext: () => this.#buildContext(),
2538
2858
  executeTool: (call) => this.#executeTool(call),
2539
2859
  resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
@@ -2581,7 +2901,15 @@ export class AgUiChat extends HTMLElement {
2581
2901
  // transcript places itself against its own card, and by the time it runs the
2582
2902
  // card is no longer reachable by id.
2583
2903
  this.#cardElements.set(call.id, card.element);
2584
- const tool = this.#resolveTool(call.name);
2904
+ // Scoped out of this round's catalog ⇒ not a frontend tool of ours, for
2905
+ // this round. A host that offers `delete_record` only on the page where
2906
+ // deleting makes sense has said something about *this* run, and a call
2907
+ // arriving anyway (a hallucinated name, or one steered by text the model
2908
+ // just read) must not find the handler that happens to be registered
2909
+ // mount-wide. Treated exactly as an unknown name rather than as a refusal:
2910
+ // withholding a tool and never registering it are the same statement, and
2911
+ // the branch below already says the honest thing for both.
2912
+ const tool = this.#advertisedTools.has(call.name) ? this.#resolveTool(call.name) : null;
2585
2913
  if (tool === null) {
2586
2914
  // Not a client tool. A server-side tool's real output arrives via
2587
2915
  // `onToolResult` (TOOL_CALL_RESULT) and already settled the card — only
@@ -2676,6 +3004,12 @@ export class AgUiChat extends HTMLElement {
2676
3004
  // The navigation never happened; drop the dangling checkpoint.
2677
3005
  this.conversationStore.saveCheckpoint(this.#threadId, null);
2678
3006
  }
3007
+ // The handler's own message, verbatim, in two places at once: the card,
3008
+ // which the user sees, and the tool result, which goes to the endpoint,
3009
+ // is persisted there and is replayed to the model on every later round.
3010
+ // Kept verbatim because a real reason is what lets the agent recover —
3011
+ // and said out loud on `registerTool`, because the second destination is
3012
+ // invisible from the host's side and is not one it can take back.
2679
3013
  const message = error instanceof Error ? error.message : String(error);
2680
3014
  card.settle(TOOL_CALL_STATUS.ERROR, message);
2681
3015
  this.#showPending();
@@ -2793,7 +3127,10 @@ export class AgUiChat extends HTMLElement {
2793
3127
  this.#hidePending();
2794
3128
  // The answer has begun — fold the thoughts away so they don't crowd it.
2795
3129
  this.#thoughts?.collapse();
2796
- this.#streamInto(buffer);
3130
+ this.#queueStream(buffer);
3131
+ // Counted per delta received, not per render: the word reveal asks
3132
+ // whether the answer *arrived* progressively, which coalescing renders
3133
+ // must not change the answer to.
2797
3134
  this.#streamDeltas += 1;
2798
3135
  },
2799
3136
  onTextEnd: (buffer) => {
@@ -2806,7 +3143,7 @@ export class AgUiChat extends HTMLElement {
2806
3143
  this.#revealWords(bubble);
2807
3144
  }
2808
3145
  attachCopyButtons(bubble, this.#strings);
2809
- this.#streamingBubble = null;
3146
+ this.#endStream();
2810
3147
  this.#noteUnread();
2811
3148
  },
2812
3149
  onToolCall: (call) => {
@@ -2875,25 +3212,25 @@ export class AgUiChat extends HTMLElement {
2875
3212
  // Per-round end; the button stays on Stop until the whole interaction
2876
3213
  // settles — the user must be able to cancel between tool rounds.
2877
3214
  this.#hidePending();
2878
- this.#streamingBubble = null;
3215
+ this.#endStream();
2879
3216
  },
2880
3217
  onError: (message) => {
2881
3218
  this.#hidePending();
2882
3219
  this.#revealWords(this.appendMessage(MESSAGE_ROLE.ASSISTANT, `⚠️ ${message}`));
2883
- this.#streamingBubble = null;
3220
+ this.#endStream();
2884
3221
  },
2885
3222
  onCancelled: () => {
2886
3223
  // Deliberate stop, not a failure: keep whatever partial text already
2887
3224
  // streamed and add a muted note instead of an error bubble.
2888
3225
  this.#hidePending();
2889
3226
  this.#appendStoppedNote();
2890
- this.#streamingBubble = null;
3227
+ this.#endStream();
2891
3228
  },
2892
3229
  onSettled: () => {
2893
3230
  // Terminal guarantee: whatever path ended the run, return to rest.
2894
3231
  this.#hidePending();
2895
3232
  this.#setRunning(false);
2896
- this.#streamingBubble = null;
3233
+ this.#endStream();
2897
3234
  // Belt-and-suspenders: a tool card still pending at settle (e.g. a
2898
3235
  // server tool whose result never streamed because the connection
2899
3236
  // dropped) would hang forever — settle it to the no-result fallback.
@@ -2999,16 +3336,78 @@ export class AgUiChat extends HTMLElement {
2999
3336
  return this.#thoughts;
3000
3337
  }
3001
3338
 
3002
- #streamInto(buffer: string): HTMLDivElement {
3339
+ /**
3340
+ * The bubble the current answer streams into, opening it on first sight.
3341
+ *
3342
+ * Opened the moment a token arrives rather than on the frame that draws it,
3343
+ * so the answer's container replaces the pending dots straight away and the
3344
+ * turn never shows a gap while the first render waits for a frame.
3345
+ */
3346
+ #openStream(): HTMLDivElement {
3003
3347
  if (this.#streamingBubble === null) {
3004
3348
  this.#streamingBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, "");
3005
3349
  this.#streamDeltas = 0;
3006
3350
  }
3007
- this.#streamingBubble.innerHTML = renderMarkdown(buffer, { allowImages: this.allowImages });
3008
- this.#messages.scrollTop = this.#messages.scrollHeight;
3009
3351
  return this.#streamingBubble;
3010
3352
  }
3011
3353
 
3354
+ /**
3355
+ * Queue a render of the answer so far, at most one per frame.
3356
+ *
3357
+ * Each `TEXT_MESSAGE_CONTENT` event carries the *whole* accumulated answer,
3358
+ * and drawing it means marked + DOMPurify over the entire document and a
3359
+ * wholesale replacement of the bubble's subtree. Once per token that is
3360
+ * quadratic in the answer's length — a long answer is agent-controlled, so
3361
+ * an ordinary run becomes a progressively stalling tab — and every rebuild
3362
+ * takes any selection or focus inside the bubble with it.
3363
+ *
3364
+ * A frame is the right grain: it is the fastest anything on screen can
3365
+ * change anyway, so a burst of tokens costs one parse and the text still
3366
+ * appears to flow rather than in visible chunks.
3367
+ */
3368
+ #queueStream(buffer: string): void {
3369
+ this.#streamBuffer = buffer;
3370
+ this.#openStream();
3371
+ if (this.#streamFrame !== null) {
3372
+ return;
3373
+ }
3374
+ this.#streamFrame = requestAnimationFrame(() => {
3375
+ this.#streamFrame = null;
3376
+ this.#streamInto(this.#streamBuffer);
3377
+ });
3378
+ }
3379
+
3380
+ /** Render `buffer` into the streaming bubble now, dropping any queued frame. */
3381
+ #streamInto(buffer: string): HTMLDivElement {
3382
+ // A frame still queued would otherwise fire after this and repaint the
3383
+ // bubble with whatever the last delta held — behind the buffer just drawn.
3384
+ if (this.#streamFrame !== null) {
3385
+ cancelAnimationFrame(this.#streamFrame);
3386
+ this.#streamFrame = null;
3387
+ }
3388
+ this.#streamBuffer = buffer;
3389
+ const bubble = this.#openStream();
3390
+ bubble.innerHTML = renderMarkdown(buffer, { allowImages: this.allowImages });
3391
+ this.#messages.scrollTop = this.#messages.scrollHeight;
3392
+ return bubble;
3393
+ }
3394
+
3395
+ /**
3396
+ * Close the current answer's streaming bubble.
3397
+ *
3398
+ * Draws a queued render first. A run that ends without a text end — a
3399
+ * cancel, an error, a round boundary — leaves the last delta sitting in the
3400
+ * queue, and simply dropping the bubble here would strand it: the partial
3401
+ * answer the user stopped mid-sentence would lose its final tokens, or be an
3402
+ * empty bubble above the stopped note.
3403
+ */
3404
+ #endStream(): void {
3405
+ if (this.#streamFrame !== null) {
3406
+ this.#streamInto(this.#streamBuffer);
3407
+ }
3408
+ this.#streamingBubble = null;
3409
+ }
3410
+
3012
3411
  /**
3013
3412
  * The card for ``call``, creating and appending it on first sight.
3014
3413
  *
@@ -3158,7 +3557,7 @@ export class AgUiChat extends HTMLElement {
3158
3557
  typeof labelled === "string"
3159
3558
  ? labelled
3160
3559
  : (this.toolSummaries[call.name] ??
3161
- this.#toolCatalog[call.name] ??
3560
+ this.#toolCatalog[call.name]?.summary ??
3162
3561
  prettifyToolName(call.name));
3163
3562
  const card = new ToolCallCard(call.name, call.args, summary, this.#strings);
3164
3563
  this.#toolCards.set(call.id, card);