@artooi/ag-ui-web-component 0.27.0 → 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 +247 -1
  2. package/README.md +186 -6
  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 +55 -1
  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 +43 -1
  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 +23 -1
  14. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  15. package/dist/core/utils.d.ts +28 -0
  16. package/dist/core/utils.d.ts.map +1 -1
  17. package/dist/index.js +564 -94
  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 +431 -41
  32. package/src/core/agui_client.ts +18 -1
  33. package/src/core/conversation_store.ts +128 -42
  34. package/src/core/create_http_agent.ts +24 -2
  35. package/src/core/remote_conversation_store.ts +35 -2
  36. package/src/core/utils.ts +58 -0
  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
package/dist/index.js CHANGED
@@ -48,6 +48,9 @@ var ICON_FILE_PDF = `<svg class="glyph" viewBox="0 0 24 24" aria-hidden="true"><
48
48
  var ICON_FILE_TEXT = `<svg class="glyph" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/><path d="M8.5 13.5h7M8.5 17h4.5"/></svg>`;
49
49
  var CHART_ACTIVITY_TYPE = "chart";
50
50
 
51
+ // src/core/ag_ui_chat.ts
52
+ import { randomUUID as randomUUID5 } from "@ag-ui/client";
53
+
51
54
  // src/skills/fill_template.ts
52
55
  var PLACEHOLDER_RE = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
53
56
  function fillTemplate(template, values) {
@@ -441,8 +444,9 @@ function parseToolCatalog(data) {
441
444
  const record = entry;
442
445
  const name = record["name"];
443
446
  const summary = record["summary"];
447
+ const description = record["description"];
444
448
  if (typeof name === "string" && typeof summary === "string") {
445
- out[name] = summary;
449
+ out[name] = typeof description === "string" ? { name, summary, description } : { name, summary };
446
450
  }
447
451
  }
448
452
  return out;
@@ -555,6 +559,7 @@ var DEFAULT_UI_STRINGS = {
555
559
  stopRecording: "Stop recording",
556
560
  transcribing: "Transcribing\u2026",
557
561
  transcriptionFailed: "Transcription failed",
562
+ recordingLimit: "Stopped at the {n}-minute limit \u2014 transcribing what was recorded.",
558
563
  toolRunning: "running\u2026",
559
564
  toolDeferred: "waiting for you",
560
565
  toolDone: "\u2713 done",
@@ -4596,23 +4601,51 @@ var ALLOWED_TAGS = [
4596
4601
  "th",
4597
4602
  "td"
4598
4603
  ];
4599
- var ALLOWED_ATTR = ["href", "title", "class"];
4600
- var ALLOWED_TAGS_WITH_IMAGES = [...ALLOWED_TAGS, "img"];
4601
- var ALLOWED_ATTR_WITH_IMAGES = [...ALLOWED_ATTR, "src", "alt", "width", "height"];
4604
+ var ALLOWED_ATTR = ["href", "title", "class", "target", "rel"];
4605
+ var SANITIZE_CONFIG = {
4606
+ ALLOWED_TAGS,
4607
+ ALLOWED_ATTR,
4608
+ ALLOW_DATA_ATTR: false,
4609
+ ALLOW_ARIA_ATTR: false
4610
+ };
4611
+ var SANITIZE_CONFIG_WITH_IMAGES = {
4612
+ ...SANITIZE_CONFIG,
4613
+ ALLOWED_TAGS: [...ALLOWED_TAGS, "img"],
4614
+ ALLOWED_ATTR: [...ALLOWED_ATTR, "src", "alt", "width", "height"]
4615
+ };
4616
+ var LANGUAGE_CLASS = /^language-[A-Za-z0-9_+#.-]+$/;
4617
+ var LANGUAGE_HOSTS = /* @__PURE__ */ new Set(["CODE", "PRE"]);
4618
+ function harden(node) {
4619
+ if (node.nodeName === "A" && node.hasAttribute("href")) {
4620
+ node.setAttribute("target", "_blank");
4621
+ node.setAttribute("rel", "noopener noreferrer");
4622
+ } else {
4623
+ node.removeAttribute("target");
4624
+ node.removeAttribute("rel");
4625
+ }
4626
+ const classes = node.getAttribute("class");
4627
+ if (classes === null) {
4628
+ return;
4629
+ }
4630
+ const kept = LANGUAGE_HOSTS.has(node.nodeName) ? classes.split(/\s+/).filter((token) => LANGUAGE_CLASS.test(token)) : [];
4631
+ if (kept.length === 0) {
4632
+ node.removeAttribute("class");
4633
+ return;
4634
+ }
4635
+ node.setAttribute("class", kept.join(" "));
4636
+ }
4637
+ var purifier = null;
4638
+ function sanitizer() {
4639
+ if (purifier === null) {
4640
+ purifier = purify();
4641
+ purifier.addHook("afterSanitizeAttributes", harden);
4642
+ }
4643
+ return purifier;
4644
+ }
4602
4645
  function renderMarkdown(text3, options) {
4603
4646
  const allowImages = options?.allowImages === true;
4604
4647
  const rendered = parser.parse(text3, { async: false });
4605
- const clean = purify.sanitize(rendered, {
4606
- ALLOWED_TAGS: allowImages ? ALLOWED_TAGS_WITH_IMAGES : ALLOWED_TAGS,
4607
- ALLOWED_ATTR: allowImages ? ALLOWED_ATTR_WITH_IMAGES : ALLOWED_ATTR
4608
- });
4609
- const template = document.createElement("template");
4610
- template.innerHTML = clean;
4611
- for (const anchor of template.content.querySelectorAll("a[href]")) {
4612
- anchor.setAttribute("target", "_blank");
4613
- anchor.setAttribute("rel", "noopener noreferrer");
4614
- }
4615
- return template.innerHTML.trim();
4648
+ return sanitizer().sanitize(rendered, allowImages ? SANITIZE_CONFIG_WITH_IMAGES : SANITIZE_CONFIG).trim();
4616
4649
  }
4617
4650
 
4618
4651
  // src/ui/resize_handle.ts
@@ -4655,6 +4688,15 @@ function createResizeHandle(options) {
4655
4688
  window.addEventListener("pointerup", onUp);
4656
4689
  event.preventDefault();
4657
4690
  });
4691
+ let pending = null;
4692
+ const settle = () => {
4693
+ if (pending === null) {
4694
+ return;
4695
+ }
4696
+ const size = pending;
4697
+ pending = null;
4698
+ options.commit(size);
4699
+ };
4658
4700
  handle.addEventListener("keydown", (event) => {
4659
4701
  const axis = options.axis();
4660
4702
  if (axis === "none") {
@@ -4680,8 +4722,10 @@ function createResizeHandle(options) {
4680
4722
  }
4681
4723
  event.preventDefault();
4682
4724
  options.apply(next);
4683
- options.commit(next);
4725
+ pending = next;
4684
4726
  });
4727
+ handle.addEventListener("keyup", settle);
4728
+ handle.addEventListener("blur", settle);
4685
4729
  return handle;
4686
4730
  }
4687
4731
 
@@ -7704,6 +7748,7 @@ var ToolCallCard = class {
7704
7748
  };
7705
7749
 
7706
7750
  // src/ui/voice_input.ts
7751
+ var MAX_RECORDING_MS = 12e4;
7707
7752
  var VoiceInput = class {
7708
7753
  /** The mic button; mount this in the composer. */
7709
7754
  element;
@@ -7714,6 +7759,8 @@ var VoiceInput = class {
7714
7759
  #recorder = null;
7715
7760
  #stream = null;
7716
7761
  #chunks = [];
7762
+ #capTimer = null;
7763
+ #hitCap = false;
7717
7764
  #disposed = false;
7718
7765
  constructor(options) {
7719
7766
  this.#transcribe = options.transcribe;
@@ -7753,6 +7800,7 @@ var VoiceInput = class {
7753
7800
  }
7754
7801
  this.#stream = stream;
7755
7802
  this.#chunks = [];
7803
+ this.#hitCap = false;
7756
7804
  const recorder = new MediaRecorder(stream);
7757
7805
  recorder.addEventListener("dataavailable", (event) => {
7758
7806
  this.#chunks.push(event.data);
@@ -7762,11 +7810,23 @@ var VoiceInput = class {
7762
7810
  });
7763
7811
  this.#recorder = recorder;
7764
7812
  recorder.start();
7813
+ this.#capTimer = setTimeout(() => {
7814
+ this.#hitCap = true;
7815
+ this.#stop();
7816
+ }, MAX_RECORDING_MS);
7765
7817
  this.#setState("recording");
7766
7818
  }
7767
7819
  #stop() {
7820
+ this.#clearCap();
7768
7821
  this.#recorder?.stop();
7769
7822
  }
7823
+ /** Drop the cap timer; recording is over, by whichever route. */
7824
+ #clearCap() {
7825
+ if (this.#capTimer !== null) {
7826
+ clearTimeout(this.#capTimer);
7827
+ this.#capTimer = null;
7828
+ }
7829
+ }
7770
7830
  /**
7771
7831
  * Tear the control down, for a host element removed mid-recording. Stops any
7772
7832
  * live `MediaRecorder`, releases the mic tracks so the browser's recording
@@ -7775,6 +7835,7 @@ var VoiceInput = class {
7775
7835
  */
7776
7836
  dispose() {
7777
7837
  this.#disposed = true;
7838
+ this.#clearCap();
7778
7839
  if (this.#recorder !== null && this.#recorder.state !== "inactive") {
7779
7840
  this.#recorder.stop();
7780
7841
  }
@@ -7791,6 +7852,12 @@ var VoiceInput = class {
7791
7852
  try {
7792
7853
  const text3 = await this.#transcribe(audio);
7793
7854
  this.#setState("idle");
7855
+ if (this.#hitCap) {
7856
+ this.element.title = this.#strings.recordingLimit.replace(
7857
+ "{n}",
7858
+ String(MAX_RECORDING_MS / 6e4)
7859
+ );
7860
+ }
7794
7861
  if (text3 !== "") {
7795
7862
  this.#onText(text3);
7796
7863
  }
@@ -8104,6 +8171,16 @@ var AgUiClient = class {
8104
8171
  onReasoningMessageContentEvent({ reasoningMessageBuffer }) {
8105
8172
  h.onReasoningDelta(reasoningMessageBuffer);
8106
8173
  },
8174
+ // The delta callback reports the buffer as it stood *before* the announced
8175
+ // delta was appended, so on its own it always trails the stream by one and
8176
+ // renders nothing at all for a block that arrives as a single delta. The
8177
+ // answer text is spared that because its own end event carries the whole
8178
+ // message; this is the reasoning counterpart, and it has to be
8179
+ // REASONING_MESSAGE_END rather than REASONING_END, because only the former
8180
+ // carries a buffer.
8181
+ onReasoningMessageEndEvent({ reasoningMessageBuffer }) {
8182
+ h.onReasoningDelta(reasoningMessageBuffer);
8183
+ },
8107
8184
  onReasoningEndEvent() {
8108
8185
  h.onReasoningEnd();
8109
8186
  },
@@ -8159,12 +8236,68 @@ var MINTED_SUFFIX = "minted:";
8159
8236
  var TITLE_LIMIT = 60;
8160
8237
  var PREVIEW_LIMIT = 100;
8161
8238
  var DEFAULT_TITLE = "New conversation";
8162
- var SessionStorageStore = class {
8239
+ var writeFailureReported = false;
8240
+ function writeStoredItem(key, value) {
8241
+ try {
8242
+ sessionStorage.setItem(key, value);
8243
+ } catch {
8244
+ if (writeFailureReported) {
8245
+ return;
8246
+ }
8247
+ writeFailureReported = true;
8248
+ console.warn(
8249
+ "<ag-ui-chat>: the browser refused a sessionStorage write \u2014 the quota is full, or storage is disabled for this context. The conversation continues, but it will not survive a page reload. Deleting a long conversation from the history drawer frees the quota."
8250
+ );
8251
+ }
8252
+ }
8253
+ function rootFor(namespace) {
8254
+ return namespace === "" ? KEY_ROOT : `${KEY_ROOT}@${namespace}`;
8255
+ }
8256
+ var SessionStorageStore = class _SessionStorageStore {
8163
8257
  #root;
8164
8258
  constructor(namespace = "") {
8165
- this.#root = namespace === "" ? KEY_ROOT : `${KEY_ROOT}@${namespace}`;
8259
+ this.#root = rootFor(namespace);
8166
8260
  if (namespace !== "") {
8167
- this.#migrateLegacyKeys();
8261
+ _SessionStorageStore.adopt("", namespace);
8262
+ }
8263
+ }
8264
+ /**
8265
+ * Move every key a store owns out of `from`'s namespace and into `to`'s.
8266
+ *
8267
+ * Two callers, one move. The constructor adopts the pre-namespacing global
8268
+ * keys (`from` = `""`); `<ag-ui-chat>` adopts an element-scoped conversation
8269
+ * into a principal-scoped one the first time a `user-key` arrives, which is a
8270
+ * host naming the user who was already there rather than a handover.
8271
+ *
8272
+ * Only this store's own suffixes move — the element's `collapsed` / `size` /
8273
+ * `theme` keys share the global root and are deliberately left where they
8274
+ * are. A value already present at the destination wins: the destination is
8275
+ * the durable record and the source is the stray this move exists to clear.
8276
+ */
8277
+ static adopt(from, to) {
8278
+ const fromRoot = `${rootFor(from)}:`;
8279
+ const toRoot = `${rootFor(to)}:`;
8280
+ for (const [key, suffix] of ownedKeys(fromRoot)) {
8281
+ const value = sessionStorage.getItem(key);
8282
+ const destination = toRoot + suffix;
8283
+ if (value !== null && sessionStorage.getItem(destination) === null) {
8284
+ writeStoredItem(destination, value);
8285
+ }
8286
+ sessionStorage.removeItem(key);
8287
+ }
8288
+ }
8289
+ /**
8290
+ * Forget everything a store holds for `namespace`.
8291
+ *
8292
+ * The logout primitive: `<ag-ui-chat>` calls it when its `user-key` changes,
8293
+ * and a host driving its own store can call it from its own sign-out path.
8294
+ * Deliberately narrow — it removes only keys under this exact namespace whose
8295
+ * suffix parses as one this store writes, so it can never reach another
8296
+ * element's conversation or the host's own `sessionStorage` entries.
8297
+ */
8298
+ static purge(namespace) {
8299
+ for (const [key] of ownedKeys(`${rootFor(namespace)}:`)) {
8300
+ sessionStorage.removeItem(key);
8168
8301
  }
8169
8302
  }
8170
8303
  threadId() {
@@ -8172,8 +8305,8 @@ var SessionStorageStore = class {
8172
8305
  }
8173
8306
  newThread() {
8174
8307
  const id = randomUUID3();
8175
- sessionStorage.setItem(this.#key(THREAD_SUFFIX), id);
8176
- sessionStorage.setItem(this.#key(MINTED_SUFFIX + id), "1");
8308
+ writeStoredItem(this.#key(THREAD_SUFFIX), id);
8309
+ writeStoredItem(this.#key(MINTED_SUFFIX + id), "1");
8177
8310
  return id;
8178
8311
  }
8179
8312
  isUnsent(threadId) {
@@ -8183,7 +8316,7 @@ var SessionStorageStore = class {
8183
8316
  return Promise.resolve(this.#readJson(this.#key(MESSAGES_SUFFIX + threadId)));
8184
8317
  }
8185
8318
  saveMessages(threadId, messages) {
8186
- sessionStorage.setItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
8319
+ writeStoredItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
8187
8320
  sessionStorage.removeItem(this.#key(MINTED_SUFFIX + threadId));
8188
8321
  this.#touchThread(threadId, messages);
8189
8322
  }
@@ -8196,7 +8329,7 @@ var SessionStorageStore = class {
8196
8329
  sessionStorage.removeItem(key);
8197
8330
  return;
8198
8331
  }
8199
- sessionStorage.setItem(key, JSON.stringify(checkpoint));
8332
+ writeStoredItem(key, JSON.stringify(checkpoint));
8200
8333
  }
8201
8334
  clear(threadId) {
8202
8335
  sessionStorage.removeItem(this.#key(MESSAGES_SUFFIX + threadId));
@@ -8212,7 +8345,7 @@ var SessionStorageStore = class {
8212
8345
  return Promise.resolve(metas);
8213
8346
  }
8214
8347
  setActiveThread(threadId) {
8215
- sessionStorage.setItem(this.#key(THREAD_SUFFIX), threadId);
8348
+ writeStoredItem(this.#key(THREAD_SUFFIX), threadId);
8216
8349
  }
8217
8350
  renameThread(threadId, title) {
8218
8351
  const threads = this.#readThreads();
@@ -8256,40 +8389,12 @@ var SessionStorageStore = class {
8256
8389
  sessionStorage.removeItem(key);
8257
8390
  return;
8258
8391
  }
8259
- sessionStorage.setItem(key, JSON.stringify(threads));
8392
+ writeStoredItem(key, JSON.stringify(threads));
8260
8393
  }
8261
8394
  /** This store's fully-qualified key for a suffix (namespaced when set). */
8262
8395
  #key(suffix) {
8263
8396
  return `${this.#root}:${suffix}`;
8264
8397
  }
8265
- /**
8266
- * One-time move of un-namespaced `ag-ui-chat:*` keys into this instance's
8267
- * namespace, so an existing conversation isn't orphaned. Only this store's own
8268
- * keys move — the element's `collapsed` / `theme` keys are left alone. The
8269
- * first namespaced instance to mount adopts the data; a second namespace
8270
- * finds it gone and starts fresh.
8271
- */
8272
- #migrateLegacyKeys() {
8273
- const legacyRoot = `${KEY_ROOT}:`;
8274
- const moves = [];
8275
- for (let i = 0; i < sessionStorage.length; i += 1) {
8276
- const key = sessionStorage.key(i);
8277
- if (key === null || !key.startsWith(legacyRoot)) {
8278
- continue;
8279
- }
8280
- const suffix = key.slice(legacyRoot.length);
8281
- if (isOwnedSuffix(suffix)) {
8282
- moves.push([key, this.#key(suffix)]);
8283
- }
8284
- }
8285
- for (const [from, to] of moves) {
8286
- const value = sessionStorage.getItem(from);
8287
- if (value !== null && sessionStorage.getItem(to) === null) {
8288
- sessionStorage.setItem(to, value);
8289
- }
8290
- sessionStorage.removeItem(from);
8291
- }
8292
- }
8293
8398
  /** Parse a stored JSON value, returning `null` when absent or corrupt. */
8294
8399
  #readJson(key) {
8295
8400
  const raw = sessionStorage.getItem(key);
@@ -8303,8 +8408,22 @@ var SessionStorageStore = class {
8303
8408
  }
8304
8409
  }
8305
8410
  };
8411
+ function ownedKeys(root) {
8412
+ const found = [];
8413
+ for (let index = 0; index < sessionStorage.length; index += 1) {
8414
+ const key = sessionStorage.key(index);
8415
+ if (key === null || !key.startsWith(root)) {
8416
+ continue;
8417
+ }
8418
+ const suffix = key.slice(root.length);
8419
+ if (isOwnedSuffix(suffix)) {
8420
+ found.push([key, suffix]);
8421
+ }
8422
+ }
8423
+ return found;
8424
+ }
8306
8425
  function isOwnedSuffix(suffix) {
8307
- return suffix === THREAD_SUFFIX || suffix === THREADS_SUFFIX || suffix.startsWith(MESSAGES_SUFFIX) || suffix.startsWith(CHECKPOINT_SUFFIX);
8426
+ return suffix === THREAD_SUFFIX || suffix === THREADS_SUFFIX || suffix.startsWith(MESSAGES_SUFFIX) || suffix.startsWith(CHECKPOINT_SUFFIX) || suffix.startsWith(MINTED_SUFFIX);
8308
8427
  }
8309
8428
  function deriveTitle(messages) {
8310
8429
  for (const message of messages) {
@@ -8349,12 +8468,27 @@ function mintThread(store) {
8349
8468
  store.setActiveThread(id);
8350
8469
  return id;
8351
8470
  }
8471
+ function warnOnCrossOriginCredentials(url, credentialNames, trustedOrigins, warned) {
8472
+ if (credentialNames.length === 0) {
8473
+ return;
8474
+ }
8475
+ const destination = new URL(String(url), location.href).origin;
8476
+ if (destination === location.origin || trustedOrigins.includes(destination) || warned.has(destination)) {
8477
+ return;
8478
+ }
8479
+ warned.add(destination);
8480
+ console.warn(
8481
+ `<ag-ui-chat>: sending host credentials (${credentialNames.join(", ")}) to ${destination}, which is not this page's origin (${location.origin}). Those headers are the page's own authentication, and whichever server answers the browser's preflight receives them \u2014 so a URL attribute built from a query parameter or from tenant-authored configuration is a channel for the token to leave on. If this destination is deliberate, name it in \`trustedOrigins\` to confirm it and silence this notice. Reported once per origin.`
8482
+ );
8483
+ }
8352
8484
 
8353
8485
  // src/core/create_http_agent.ts
8354
8486
  function createHttpAgent(options) {
8487
+ const staticHeaders = options.headers ?? {};
8488
+ const warned = /* @__PURE__ */ new Set();
8355
8489
  return new HttpAgent({
8356
8490
  url: options.endpoint,
8357
- headers: options.headers ?? {},
8491
+ headers: staticHeaders,
8358
8492
  initialState: { ...options.initialState ?? {} },
8359
8493
  // HttpAgent invokes its configured fetch as a method (`this.fetch(...)`),
8360
8494
  // rebinding the global `fetch` to the agent instance — "Illegal invocation"
@@ -8363,6 +8497,10 @@ function createHttpAgent(options) {
8363
8497
  // own config having no seam for either.
8364
8498
  fetch: (url, init) => {
8365
8499
  const fresh = options.getHeaders?.();
8500
+ const credentialNames = [
8501
+ .../* @__PURE__ */ new Set([...Object.keys(staticHeaders), ...Object.keys(fresh ?? {})])
8502
+ ].sort();
8503
+ warnOnCrossOriginCredentials(url, credentialNames, options.trustedOrigins ?? [], warned);
8366
8504
  if (fresh === void 0) {
8367
8505
  return fetch(url, withCredentials(init, options.credentials));
8368
8506
  }
@@ -8385,13 +8523,34 @@ var RemoteConversationStore = class {
8385
8523
  #headers;
8386
8524
  #local;
8387
8525
  #credentials;
8526
+ #cacheMessages;
8388
8527
  #dropped = /* @__PURE__ */ new Set();
8389
8528
  #renamed = /* @__PURE__ */ new Map();
8390
- constructor(url, headers = () => ({}), local = new SessionStorageStore(), credentials = () => void 0) {
8529
+ /**
8530
+ * @param cacheMessages Whether to mirror message bodies into the local store.
8531
+ *
8532
+ * `true` (the default, and the behaviour this class has always had) keeps a
8533
+ * local copy of every turn, so the transcript still replays when the thread
8534
+ * endpoint is unreachable. `false` is for the deployment that chose a
8535
+ * server-backed store precisely so transcripts do not sit in the browser:
8536
+ * regulated content, a shared workstation, an operator who has to be able to
8537
+ * say where the conversation lives. It is not the same as passing a local
8538
+ * store that does nothing — the local store also owns the active thread id,
8539
+ * the navigation checkpoint and the "nothing sent here yet" marker, all of
8540
+ * which must keep working — so the opt-out is scoped to the bodies alone.
8541
+ *
8542
+ * The cost is deliberate and worth stating: with no local copy there is
8543
+ * nothing to fall back to, so a failed request shows an empty transcript
8544
+ * rather than a stale one, and the drawer's offline list loses its previews
8545
+ * (a preview is an excerpt of a message, which is the very thing being kept
8546
+ * off the client).
8547
+ */
8548
+ constructor(url, headers = () => ({}), local = new SessionStorageStore(), credentials = () => void 0, cacheMessages = true) {
8391
8549
  this.#url = url.endsWith("/") ? url : `${url}/`;
8392
8550
  this.#headers = headers;
8393
8551
  this.#local = local;
8394
8552
  this.#credentials = credentials;
8553
+ this.#cacheMessages = cacheMessages;
8395
8554
  }
8396
8555
  threadId() {
8397
8556
  return this.#local.threadId();
@@ -8412,7 +8571,7 @@ var RemoteConversationStore = class {
8412
8571
  return this.#local.isUnsent?.(threadId) === true;
8413
8572
  }
8414
8573
  saveMessages(threadId, messages) {
8415
- this.#local.saveMessages(threadId, messages);
8574
+ this.#local.saveMessages(threadId, this.#cacheMessages ? messages : []);
8416
8575
  }
8417
8576
  loadCheckpoint(threadId) {
8418
8577
  return this.#local.loadCheckpoint(threadId);
@@ -8677,6 +8836,7 @@ var CONNECT_TIME_ATTRIBUTES = [
8677
8836
  "data-attachment-max-bytes",
8678
8837
  "data-transcribe-url",
8679
8838
  "data-threads-url",
8839
+ "data-threads-cache",
8680
8840
  "data-tools-url",
8681
8841
  "data-skills-url",
8682
8842
  "data-skills",
@@ -8693,6 +8853,7 @@ function isCredentialsMode(value) {
8693
8853
  var COLLAPSED_KEY = "ag-ui-chat:collapsed";
8694
8854
  var SIZE_KEY = "ag-ui-chat:size";
8695
8855
  var THEME_KEY = "ag-ui-chat:theme";
8856
+ var CLAIMED_NAMESPACES = /* @__PURE__ */ new Set();
8696
8857
  var AgUiChat = class extends HTMLElement {
8697
8858
  /** Agent factory; override to inject a custom or fake agent (tests). */
8698
8859
  agentFactory = createHttpAgent;
@@ -8716,6 +8877,22 @@ var AgUiChat = class extends HTMLElement {
8716
8877
  * `Authorization` are configured independently and neither drops the other.
8717
8878
  */
8718
8879
  getHeaders = null;
8880
+ /**
8881
+ * Origins, besides the page's own, that this element may send {@link headers}
8882
+ * and {@link getHeaders} credentials to without saying so on the console.
8883
+ *
8884
+ * Seven attributes name a URL, and every one of them carries these headers.
8885
+ * They are plain HTML, so a page that builds one from a query parameter or
8886
+ * from tenant-authored configuration has handed an attacker the destination,
8887
+ * and the token leaves on the element's first request. Naming the origins you
8888
+ * expect turns that from silent into either confirmed or reported.
8889
+ *
8890
+ * A notice rather than a refusal: a cross-origin agent is a documented
8891
+ * deployment, so refusing would break working installations to defend against
8892
+ * a page that is already interpolating untrusted data into its own markup.
8893
+ * Leaving this empty costs nothing but one console line per foreign origin.
8894
+ */
8895
+ trustedOrigins = [];
8719
8896
  /**
8720
8897
  * Permit `<img>` in rendered assistant markdown. **Off by default**: a
8721
8898
  * model-controlled image URL is fetched with no user interaction, which
@@ -8851,13 +9028,37 @@ var AgUiChat = class extends HTMLElement {
8851
9028
  */
8852
9029
  resolvePageTarget = (target) => document.querySelector(target);
8853
9030
  /**
8854
- * Card labels fetched from a server tool catalog (`data-tools-url`), keyed by
8855
- * tool name. The base layer behind {@link toolSummaries}: an explicit entry in
8856
- * `toolSummaries` wins, this fills the rest. Populated once on connect.
9031
+ * The server tool catalog fetched from `data-tools-url`, keyed by tool
9032
+ * name. Cards label themselves from each entry's `summary`, the base
9033
+ * layer behind {@link toolSummaries}: an explicit entry in `toolSummaries`
9034
+ * wins, this fills the rest. Held as whole entries rather than labels so a
9035
+ * field the server sent is not lost on the way in. Populated once on connect.
8857
9036
  */
8858
9037
  #toolCatalog = {};
9038
+ /**
9039
+ * Foreign origins already reported, so the notice is once per origin per
9040
+ * element rather than once per request. Per-element rather than module-level,
9041
+ * because two elements on one page are two separate configurations.
9042
+ */
9043
+ #warnedOrigins = /* @__PURE__ */ new Set();
8859
9044
  /** The resolved string table (defaults ← `data-strings` ← `strings`). */
8860
9045
  #strings = DEFAULT_UI_STRINGS;
9046
+ /**
9047
+ * The tool names the current round handed the agent, captured as the catalog
9048
+ * went out.
9049
+ *
9050
+ * The registry is mount-wide but {@link getTools} is per-run, so a host is
9051
+ * free to scope what a given page offers — and a call naming a tool this run
9052
+ * withheld must not reach the handler that is merely still registered.
9053
+ * Snapshotted rather than re-asked at dispatch: a provider is a function, and
9054
+ * calling it again asks a question the run already answered, which is exactly
9055
+ * the window a scoped catalog exists to close.
9056
+ *
9057
+ * Empty until the first round advertises, which cannot precede a call: the
9058
+ * client builds `RunAgentInput.tools` at the top of every round, before the
9059
+ * calls that round produces are executed.
9060
+ */
9061
+ #advertisedTools = /* @__PURE__ */ new Set();
8861
9062
  #toolRegistry = new ClientToolRegistry();
8862
9063
  /** Tool-call cards awaiting execution, keyed by call id. */
8863
9064
  #toolCards = /* @__PURE__ */ new Map();
@@ -8935,6 +9136,12 @@ var AgUiChat = class extends HTMLElement {
8935
9136
  // revealed progressively as it streamed, so the word reveal must not re-animate
8936
9137
  // it; ≤1 ⇒ it arrived at once and the word reveal is appropriate.
8937
9138
  #streamDeltas = 0;
9139
+ // The accumulated answer the next render will draw. Deltas overwrite it
9140
+ // (each one carries the whole answer), so a frame always draws the latest.
9141
+ #streamBuffer = "";
9142
+ // The frame that render is queued on, or `null` when nothing is queued —
9143
+ // also the flag saying a delta is still undrawn.
9144
+ #streamFrame = null;
8938
9145
  #pending = null;
8939
9146
  // The current assistant turn's grouping container. One `.answer`
8940
9147
  // wraps everything a single answer produces — streamed text, tool cards, the
@@ -8949,9 +9156,23 @@ var AgUiChat = class extends HTMLElement {
8949
9156
  #thoughts = null;
8950
9157
  #threadId = "";
8951
9158
  // Per-instance suffix for the origin-scoped storage keys (collapsed / theme /
8952
- // active thread), so two instances on one origin don't clobber each other.
8953
- // Empty ⇒ the pre-namespacing global keys (back-compat). Resolved on connect.
9159
+ // size), so two instances on one origin don't clobber each other. Empty ⇒ the
9160
+ // pre-namespacing global keys (back-compat). Resolved on connect; the
9161
+ // conversation adds `user-key` on top of it, see #conversationNs.
8954
9162
  #storageNs = "";
9163
+ // The entry this element put in CLAIMED_NAMESPACES, to take back out on
9164
+ // disconnect. `null` when it claimed nothing (no id, no endpoint, or it lost
9165
+ // the claim to an element that mounted first).
9166
+ #claimedNs = null;
9167
+ // The fallback namespace minted when the preferred one was already claimed,
9168
+ // with the preferred value it was minted for — so the element keeps it across
9169
+ // remounts, but re-resolves if the host answers the warning with an `id`.
9170
+ #generatedNs = "";
9171
+ #generatedFor = "";
9172
+ // The `sessionStorage`-backed store, which the element may therefore re-scope
9173
+ // on a principal change. `null` when the host injected a store of its own
9174
+ // kind, whose keying the element does not know and must not guess at.
9175
+ #builtinStore = null;
8955
9176
  // Bumped on every #rehydrate; a replay whose generation is stale (a newer
8956
9177
  // thread switch started while it awaited a slow store) drops its result.
8957
9178
  #rehydrateGeneration = 0;
@@ -9007,7 +9228,7 @@ var AgUiChat = class extends HTMLElement {
9007
9228
  if (this.#runIndex === null) {
9008
9229
  this.#runIndex = new RunIndex(
9009
9230
  url,
9010
- () => this.#requestHeaders(),
9231
+ () => this.#headersFor(url),
9011
9232
  () => this.#requestCredentials()
9012
9233
  );
9013
9234
  }
@@ -9041,6 +9262,7 @@ var AgUiChat = class extends HTMLElement {
9041
9262
  endpoint,
9042
9263
  headers: this.#requestHeaders(),
9043
9264
  getHeaders: () => this.#requestHeaders(),
9265
+ trustedOrigins: this.trustedOrigins,
9044
9266
  ...this.#credentialsOption(),
9045
9267
  threadId: this.#threadId,
9046
9268
  // The seed the endpoints assume: nothing. The snapshot is the history.
@@ -9049,7 +9271,7 @@ var AgUiChat = class extends HTMLElement {
9049
9271
  const client = new AgUiClient({
9050
9272
  agent,
9051
9273
  handlers: this.#handlers(),
9052
- getTools: () => this.getTools(),
9274
+ getTools: () => this.#advertiseTools(),
9053
9275
  getContext: () => this.#buildContext(),
9054
9276
  executeTool: (call) => this.#executeTool(call),
9055
9277
  resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
@@ -9064,7 +9286,7 @@ var AgUiChat = class extends HTMLElement {
9064
9286
  }
9065
9287
  /** Attributes the element reacts to after it has been connected. */
9066
9288
  static get observedAttributes() {
9067
- return ["title-text", "placement", "credentials", ...CONNECT_TIME_ATTRIBUTES];
9289
+ return ["title-text", "placement", "credentials", "user-key", ...CONNECT_TIME_ATTRIBUTES];
9068
9290
  }
9069
9291
  attributeChangedCallback(name, previous, value) {
9070
9292
  if (name === "credentials") {
@@ -9084,6 +9306,12 @@ var AgUiChat = class extends HTMLElement {
9084
9306
  this.#title.textContent = value ?? this.#strings.title;
9085
9307
  return;
9086
9308
  }
9309
+ if (name === "user-key") {
9310
+ if (this.#connected && (previous ?? "") !== (value ?? "")) {
9311
+ this.#changePrincipal(previous ?? "", value ?? "");
9312
+ }
9313
+ return;
9314
+ }
9087
9315
  if (previous === value || !this.#connected) {
9088
9316
  return;
9089
9317
  }
@@ -9091,7 +9319,18 @@ var AgUiChat = class extends HTMLElement {
9091
9319
  `<ag-ui-chat>: "${name}" was changed after the element connected, and is read only while connecting \u2014 this assignment has no effect. Set it before the element enters the DOM (in the markup, or on the element before appending it); frameworks that patch attributes after mount should bind it at creation. To apply a new value now, remove and re-insert the element.`
9092
9320
  );
9093
9321
  }
9094
- /** Declare a frontend tool the agent may call. */
9322
+ /**
9323
+ * Declare a frontend tool the agent may call.
9324
+ *
9325
+ * **A handler's thrown message leaves the browser.** When a handler rejects,
9326
+ * its `Error.message` is posted back as that call's tool result — into the
9327
+ * conversation, on to the AG-UI endpoint, persisted server-side, and
9328
+ * forwarded to the model provider on every later round. That is deliberate,
9329
+ * since it is what lets the agent recover from a failure it caused; but it
9330
+ * means an internal hostname, a signed URL or a stack-derived path in a
9331
+ * rethrown error is disclosed to parties the host never chose. Throw the
9332
+ * message you would be content for the model to read, and log the detail.
9333
+ */
9095
9334
  registerTool(tool) {
9096
9335
  this.#toolRegistry.register(tool);
9097
9336
  }
@@ -9242,6 +9481,18 @@ var AgUiChat = class extends HTMLElement {
9242
9481
  this.#messages.scrollTop = this.#messages.scrollHeight;
9243
9482
  return answer;
9244
9483
  }
9484
+ /**
9485
+ * The catalog for the round about to start, remembering what it offered.
9486
+ *
9487
+ * Every path to a frontend tool goes through here first — the client asks
9488
+ * for `RunAgentInput.tools` at the top of each round — so this is the one
9489
+ * place that can know what the agent was actually told about.
9490
+ */
9491
+ #advertiseTools() {
9492
+ const tools = this.getTools();
9493
+ this.#advertisedTools = new Set(tools.map((tool) => tool.name));
9494
+ return tools;
9495
+ }
9245
9496
  /** Resolve a tool by name: built-in tools first, then the registry. */
9246
9497
  #resolveTool(name) {
9247
9498
  const builtin = this.#builtinTools().find((t) => t.name === name);
@@ -9260,6 +9511,37 @@ var AgUiChat = class extends HTMLElement {
9260
9511
  set endpoint(value) {
9261
9512
  this.setAttribute("endpoint", value);
9262
9513
  }
9514
+ /**
9515
+ * Who the stored conversation belongs to, from the `user-key` attribute.
9516
+ *
9517
+ * Set it to whatever identifies the signed-in principal — a user id, an
9518
+ * account id, a hash of one. The value joins the storage namespace, so two
9519
+ * principals in the same tab cannot read each other's transcript, and
9520
+ * **changing it purges what the previous one left behind**.
9521
+ *
9522
+ * That purge is the reason this is a live attribute rather than a
9523
+ * connect-time one. `sessionStorage` survives same-tab navigation, so it
9524
+ * survives a logout; and a single-page app signs out through its own router
9525
+ * without remounting anything, so there is no other moment at which the
9526
+ * element could find out. The host naming the new principal — or dropping the
9527
+ * attribute — is the signal.
9528
+ *
9529
+ * Absent means exactly today's behaviour, which is why nothing breaks by
9530
+ * leaving it off: the conversation is scoped to the element and to nobody in
9531
+ * particular, and on a shared workstation it carries into whoever signs in
9532
+ * next in the same tab.
9533
+ *
9534
+ * The first value to arrive is treated as a host naming the user who was
9535
+ * already there, not as a handover: the conversation in progress moves into
9536
+ * the principal's namespace rather than being destroyed, so an element
9537
+ * configured by an async auth handshake keeps what is on screen.
9538
+ */
9539
+ get userKey() {
9540
+ return this.getAttribute("user-key") ?? "";
9541
+ }
9542
+ set userKey(value) {
9543
+ this.setAttribute("user-key", value);
9544
+ }
9263
9545
  /**
9264
9546
  * Cookie policy for **every** request this element makes, as `fetch`'s own
9265
9547
  * `credentials` mode (`"omit"` / `"same-origin"` / `"include"`). Mirrored to
@@ -9304,6 +9586,24 @@ var AgUiChat = class extends HTMLElement {
9304
9586
  #requestHeaders() {
9305
9587
  return { ...this.headers, ...this.getHeaders?.() };
9306
9588
  }
9589
+ /**
9590
+ * The request headers, having first reported the destination if it is foreign.
9591
+ *
9592
+ * Every caller that sends these headers knows its URL, and `#requestHeaders`
9593
+ * does not -- so the check lives here, on the path that has both, rather than
9594
+ * being repeated at each call site with a chance to be forgotten at the next
9595
+ * one added.
9596
+ */
9597
+ #headersFor(url) {
9598
+ const headers = this.#requestHeaders();
9599
+ warnOnCrossOriginCredentials(
9600
+ url,
9601
+ Object.keys(headers),
9602
+ this.trustedOrigins,
9603
+ this.#warnedOrigins
9604
+ );
9605
+ return headers;
9606
+ }
9307
9607
  /** The configured cookie policy as `fetch` spells it; `undefined` when unset. */
9308
9608
  #requestCredentials() {
9309
9609
  return this.credentials ?? void 0;
@@ -9322,8 +9622,8 @@ var AgUiChat = class extends HTMLElement {
9322
9622
  return credentials === void 0 ? {} : { credentials };
9323
9623
  }
9324
9624
  /** The `fetch` init for the element's own plain GETs (catalogs). */
9325
- #fetchInit() {
9326
- return withCredentials({ headers: this.#requestHeaders() }, this.#requestCredentials());
9625
+ #fetchInit(url) {
9626
+ return withCredentials({ headers: this.#headersFor(url) }, this.#requestCredentials());
9327
9627
  }
9328
9628
  /**
9329
9629
  * How much detail tool-call cards show, from the `data-tool-display`
@@ -9344,7 +9644,7 @@ var AgUiChat = class extends HTMLElement {
9344
9644
  this.setAttribute("data-tool-display", value);
9345
9645
  }
9346
9646
  connectedCallback() {
9347
- this.#storageNs = this.id !== "" ? this.id : this.endpoint;
9647
+ this.#storageNs = this.#claimNamespace();
9348
9648
  this.#applySize(this.#readSize());
9349
9649
  requestAnimationFrame(() => this.#syncResizeAnchor());
9350
9650
  this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
@@ -9362,8 +9662,10 @@ var AgUiChat = class extends HTMLElement {
9362
9662
  }
9363
9663
  this.#syncLauncher();
9364
9664
  this.#initSkills();
9365
- if (this.#storageNs !== "" && this.conversationStore instanceof SessionStorageStore) {
9366
- this.conversationStore = new SessionStorageStore(this.#storageNs);
9665
+ if (this.conversationStore instanceof SessionStorageStore) {
9666
+ const namespace = this.#conversationNs();
9667
+ this.#builtinStore = namespace === "" ? this.conversationStore : new SessionStorageStore(namespace);
9668
+ this.conversationStore = this.#builtinStore;
9367
9669
  }
9368
9670
  this.#wireThreadStore();
9369
9671
  this.#wireAttachments();
@@ -9427,6 +9729,10 @@ var AgUiChat = class extends HTMLElement {
9427
9729
  */
9428
9730
  disconnectedCallback() {
9429
9731
  this.#connected = false;
9732
+ if (this.#claimedNs !== null) {
9733
+ CLAIMED_NAMESPACES.delete(this.#claimedNs);
9734
+ this.#claimedNs = null;
9735
+ }
9430
9736
  this.#cancelRun();
9431
9737
  this.#attachTray?.dispose();
9432
9738
  this.#voice?.dispose();
@@ -9496,7 +9802,7 @@ var AgUiChat = class extends HTMLElement {
9496
9802
  }
9497
9803
  return (file, onProgress, signal) => uploadAttachment(file, {
9498
9804
  url,
9499
- headers: this.#requestHeaders(),
9805
+ headers: this.#headersFor(url),
9500
9806
  ...this.#credentialsOption(),
9501
9807
  onProgress,
9502
9808
  signal
@@ -9529,7 +9835,7 @@ var AgUiChat = class extends HTMLElement {
9529
9835
  }
9530
9836
  return (audio) => transcribeAudio(audio, {
9531
9837
  url,
9532
- headers: this.#requestHeaders(),
9838
+ headers: this.#headersFor(url),
9533
9839
  ...this.#credentialsOption()
9534
9840
  });
9535
9841
  }
@@ -9584,15 +9890,21 @@ var AgUiChat = class extends HTMLElement {
9584
9890
  * delete through that server endpoint (wrapping the current store as the
9585
9891
  * client-only fallback), so the history drawer shows durable, cross-device
9586
9892
  * threads. Without it, the client store's per-tab threads are used.
9893
+ *
9894
+ * `data-threads-cache="false"` drops the local copy of the message bodies —
9895
+ * for the deployment that pointed history at a server precisely so that
9896
+ * transcripts do not sit in the browser. The client-only concerns (the active
9897
+ * thread id, the navigation checkpoint) keep their local store either way.
9587
9898
  */
9588
9899
  #wireThreadStore() {
9589
9900
  const url = this.getAttribute("data-threads-url");
9590
9901
  if (url !== null) {
9591
9902
  this.conversationStore = new RemoteConversationStore(
9592
9903
  url,
9593
- () => this.#requestHeaders(),
9904
+ () => this.#headersFor(url),
9594
9905
  this.conversationStore,
9595
- () => this.#requestCredentials()
9906
+ () => this.#requestCredentials(),
9907
+ this.getAttribute("data-threads-cache") !== "false"
9596
9908
  );
9597
9909
  }
9598
9910
  }
@@ -9603,7 +9915,7 @@ var AgUiChat = class extends HTMLElement {
9603
9915
  return;
9604
9916
  }
9605
9917
  try {
9606
- const response = await fetch(url, this.#fetchInit());
9918
+ const response = await fetch(url, this.#fetchInit(url));
9607
9919
  this.#toolCatalog = parseToolCatalog(await response.json());
9608
9920
  } catch {
9609
9921
  }
@@ -9646,7 +9958,7 @@ var AgUiChat = class extends HTMLElement {
9646
9958
  return;
9647
9959
  }
9648
9960
  try {
9649
- const response = await fetch(url, this.#fetchInit());
9961
+ const response = await fetch(url, this.#fetchInit(url));
9650
9962
  this.#backendSkills = parseSkills(await response.json());
9651
9963
  this.#recomputeSkills();
9652
9964
  } catch {
@@ -9729,7 +10041,7 @@ var AgUiChat = class extends HTMLElement {
9729
10041
  } else {
9730
10042
  this.removeAttribute("collapsed");
9731
10043
  }
9732
- sessionStorage.setItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
10044
+ writeStoredItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
9733
10045
  this.#setUnread(0);
9734
10046
  this.dispatchEvent(
9735
10047
  new CustomEvent(TOGGLE_EVENT, {
@@ -9761,7 +10073,7 @@ var AgUiChat = class extends HTMLElement {
9761
10073
  toggleTheme() {
9762
10074
  const next = this.getAttribute("theme") === "dark" ? "light" : "dark";
9763
10075
  this.setAttribute("theme", next);
9764
- sessionStorage.setItem(this.#storageKey(THEME_KEY), next);
10076
+ writeStoredItem(this.#storageKey(THEME_KEY), next);
9765
10077
  this.#syncThemeGlyph();
9766
10078
  }
9767
10079
  /**
@@ -9863,7 +10175,7 @@ var AgUiChat = class extends HTMLElement {
9863
10175
  /** Persist a dragged size per tab, alongside the collapsed/theme preferences. */
9864
10176
  #persistSize(size) {
9865
10177
  const stored = { ...this.#readSize(), ...size };
9866
- sessionStorage.setItem(this.#storageKey(SIZE_KEY), JSON.stringify(stored));
10178
+ writeStoredItem(this.#storageKey(SIZE_KEY), JSON.stringify(stored));
9867
10179
  }
9868
10180
  /** The persisted size for this instance, or an empty record. */
9869
10181
  #readSize() {
@@ -9878,6 +10190,106 @@ var AgUiChat = class extends HTMLElement {
9878
10190
  return {};
9879
10191
  }
9880
10192
  }
10193
+ /**
10194
+ * Claim this element's storage namespace: its `id`, else its `endpoint`.
10195
+ *
10196
+ * The endpoint fallback exists so a lone widget restores its conversation
10197
+ * across reloads with nothing asked of the page author. It stops working the
10198
+ * moment there are two of them — a docked support panel and an inline page
10199
+ * assistant against one agent mount, neither carrying an `id`, which nothing
10200
+ * requires — because both resolve to the same string and then share a thread
10201
+ * pointer, a drawer index and every message key. Whichever mounts second
10202
+ * adopts the first's active thread and rehydrates its transcript into its own
10203
+ * panel: one conversation's content inside another, on the same page.
10204
+ *
10205
+ * So the namespace is claimed by the first element to mount under it, and a
10206
+ * second is given one of its own plus a warning naming the fix. The first
10207
+ * element keeps the endpoint namespace, which is what leaves the ordinary
10208
+ * single-element case exactly as it was.
10209
+ *
10210
+ * The generated namespace is random rather than derived from mount order.
10211
+ * That costs the second element its history across reloads — the warning says
10212
+ * so, and an `id` fixes it — which is the honest trade against an order-based
10213
+ * name that would silently hand a stored conversation to whichever element
10214
+ * happened to mount second on the next load.
10215
+ */
10216
+ #claimNamespace() {
10217
+ const preferred = this.id !== "" ? this.id : this.endpoint;
10218
+ if (preferred === "") {
10219
+ return "";
10220
+ }
10221
+ if (this.#generatedFor === preferred) {
10222
+ return this.#generatedNs;
10223
+ }
10224
+ if (!CLAIMED_NAMESPACES.has(preferred)) {
10225
+ CLAIMED_NAMESPACES.add(preferred);
10226
+ this.#claimedNs = preferred;
10227
+ return preferred;
10228
+ }
10229
+ this.#generatedFor = preferred;
10230
+ this.#generatedNs = `${preferred}~${randomUUID5()}`;
10231
+ console.warn(
10232
+ `<ag-ui-chat>: another element on this page already stores its conversation under "${preferred}", so this one has been given a throwaway namespace of its own \u2014 the two would otherwise share a thread pointer, a history drawer and every message. Give each <ag-ui-chat> its own id to keep them apart and let this one restore its conversation across reloads.`
10233
+ );
10234
+ return this.#generatedNs;
10235
+ }
10236
+ /**
10237
+ * The conversation store's namespace: this element's, scoped to the principal
10238
+ * {@link userKey} names.
10239
+ *
10240
+ * Only the conversation is principal-scoped. The panel's own collapsed / size
10241
+ * / theme preferences stay on `#storageNs`, because they are this element's
10242
+ * UI state rather than anyone's data — they carry no word of what was said —
10243
+ * and because they are read once while connecting, so re-scoping them under a
10244
+ * live element would rearrange the panel around a user who had only just
10245
+ * signed in.
10246
+ */
10247
+ #conversationNs(key = this.userKey) {
10248
+ return key === "" ? this.#storageNs : `${this.#storageNs}#${key}`;
10249
+ }
10250
+ /**
10251
+ * Move the element's client state from one principal to another.
10252
+ *
10253
+ * The whole reason {@link userKey} is live: `sessionStorage` outlives a
10254
+ * logout, because a logout is a navigation (or, in a single-page app, not
10255
+ * even that) rather than a tab close. Nothing remounts, so the host naming
10256
+ * the new principal is the only signal the element will ever get.
10257
+ */
10258
+ #changePrincipal(previousKey, nextKey) {
10259
+ const previous = this.#conversationNs(previousKey);
10260
+ const next = this.#conversationNs(nextKey);
10261
+ if (previousKey === "") {
10262
+ SessionStorageStore.adopt(previous, next);
10263
+ this.#rescopeStore(next);
10264
+ return;
10265
+ }
10266
+ SessionStorageStore.purge(previous);
10267
+ this.#rescopeStore(next);
10268
+ this.#cancelRun();
10269
+ this.#resetState();
10270
+ this.#setRunning(false);
10271
+ this.#setUnread(0);
10272
+ this.#threadId = this.conversationStore.threadId();
10273
+ void this.#rehydrate();
10274
+ void this.#refreshDrawer();
10275
+ }
10276
+ /**
10277
+ * Rebuild the `sessionStorage` store under `namespace`, re-wrapping it for
10278
+ * `data-threads-url` exactly as connecting did.
10279
+ *
10280
+ * A store of the host's own kind is left alone: a store that holds its data
10281
+ * somewhere the element cannot see has to scope itself. The transcript on
10282
+ * screen is still cleared either way — the host swapped principals, and that
10283
+ * much is the element's to act on.
10284
+ */
10285
+ #rescopeStore(namespace) {
10286
+ if (this.#builtinStore === null) {
10287
+ return;
10288
+ }
10289
+ this.#builtinStore = new SessionStorageStore(namespace);
10290
+ this.conversationStore = this.#builtinStore;
10291
+ this.#wireThreadStore();
10292
+ }
9881
10293
  /** This instance's namespaced form of an origin-scoped storage key. */
9882
10294
  #storageKey(base) {
9883
10295
  return this.#storageNs === "" ? base : `${base}:${this.#storageNs}`;
@@ -9963,7 +10375,7 @@ var AgUiChat = class extends HTMLElement {
9963
10375
  /** Drop the in-memory run + transcript, leaving the thread id untouched. */
9964
10376
  #resetState() {
9965
10377
  this.#client = null;
9966
- this.#streamingBubble = null;
10378
+ this.#endStream();
9967
10379
  this.#currentGroup = null;
9968
10380
  this.#thoughts = null;
9969
10381
  this.#hidePending();
@@ -10644,6 +11056,7 @@ var AgUiChat = class extends HTMLElement {
10644
11056
  // token must still reach every request — the factory's fetch wrapper
10645
11057
  // re-reads this on each call.
10646
11058
  getHeaders: () => this.#requestHeaders(),
11059
+ trustedOrigins: this.trustedOrigins,
10647
11060
  ...this.#credentialsOption(),
10648
11061
  threadId: this.#threadId,
10649
11062
  initialMessages: this.#initialMessages,
@@ -10652,7 +11065,7 @@ var AgUiChat = class extends HTMLElement {
10652
11065
  this.#client = new AgUiClient({
10653
11066
  agent,
10654
11067
  handlers: this.#handlers(),
10655
- getTools: () => this.getTools(),
11068
+ getTools: () => this.#advertiseTools(),
10656
11069
  getContext: () => this.#buildContext(),
10657
11070
  executeTool: (call) => this.#executeTool(call),
10658
11071
  resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
@@ -10691,7 +11104,7 @@ var AgUiChat = class extends HTMLElement {
10691
11104
  const card = this.#cardFor(call);
10692
11105
  this.#toolCards.delete(call.id);
10693
11106
  this.#cardElements.set(call.id, card.element);
10694
- const tool = this.#resolveTool(call.name);
11107
+ const tool = this.#advertisedTools.has(call.name) ? this.#resolveTool(call.name) : null;
10695
11108
  if (tool === null) {
10696
11109
  if (!this.#serverSettled.has(call.id)) {
10697
11110
  card.settle(TOOL_CALL_STATUS.DONE, this.#strings.noResult);
@@ -10834,7 +11247,7 @@ var AgUiChat = class extends HTMLElement {
10834
11247
  onTextDelta: (buffer) => {
10835
11248
  this.#hidePending();
10836
11249
  this.#thoughts?.collapse();
10837
- this.#streamInto(buffer);
11250
+ this.#queueStream(buffer);
10838
11251
  this.#streamDeltas += 1;
10839
11252
  },
10840
11253
  onTextEnd: (buffer) => {
@@ -10843,7 +11256,7 @@ var AgUiChat = class extends HTMLElement {
10843
11256
  this.#revealWords(bubble);
10844
11257
  }
10845
11258
  attachCopyButtons(bubble, this.#strings);
10846
- this.#streamingBubble = null;
11259
+ this.#endStream();
10847
11260
  this.#noteUnread();
10848
11261
  },
10849
11262
  onToolCall: (call) => {
@@ -10890,22 +11303,22 @@ var AgUiChat = class extends HTMLElement {
10890
11303
  },
10891
11304
  onRunEnd: () => {
10892
11305
  this.#hidePending();
10893
- this.#streamingBubble = null;
11306
+ this.#endStream();
10894
11307
  },
10895
11308
  onError: (message) => {
10896
11309
  this.#hidePending();
10897
11310
  this.#revealWords(this.appendMessage(MESSAGE_ROLE.ASSISTANT, `\u26A0\uFE0F ${message}`));
10898
- this.#streamingBubble = null;
11311
+ this.#endStream();
10899
11312
  },
10900
11313
  onCancelled: () => {
10901
11314
  this.#hidePending();
10902
11315
  this.#appendStoppedNote();
10903
- this.#streamingBubble = null;
11316
+ this.#endStream();
10904
11317
  },
10905
11318
  onSettled: () => {
10906
11319
  this.#hidePending();
10907
11320
  this.#setRunning(false);
10908
- this.#streamingBubble = null;
11321
+ this.#endStream();
10909
11322
  for (const card of this.#toolCards.values()) {
10910
11323
  if (!card.settled) {
10911
11324
  card.settle(TOOL_CALL_STATUS.DONE, this.#strings.noResult);
@@ -10999,15 +11412,72 @@ var AgUiChat = class extends HTMLElement {
10999
11412
  }
11000
11413
  return this.#thoughts;
11001
11414
  }
11002
- #streamInto(buffer) {
11415
+ /**
11416
+ * The bubble the current answer streams into, opening it on first sight.
11417
+ *
11418
+ * Opened the moment a token arrives rather than on the frame that draws it,
11419
+ * so the answer's container replaces the pending dots straight away and the
11420
+ * turn never shows a gap while the first render waits for a frame.
11421
+ */
11422
+ #openStream() {
11003
11423
  if (this.#streamingBubble === null) {
11004
11424
  this.#streamingBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, "");
11005
11425
  this.#streamDeltas = 0;
11006
11426
  }
11007
- this.#streamingBubble.innerHTML = renderMarkdown(buffer, { allowImages: this.allowImages });
11008
- this.#messages.scrollTop = this.#messages.scrollHeight;
11009
11427
  return this.#streamingBubble;
11010
11428
  }
11429
+ /**
11430
+ * Queue a render of the answer so far, at most one per frame.
11431
+ *
11432
+ * Each `TEXT_MESSAGE_CONTENT` event carries the *whole* accumulated answer,
11433
+ * and drawing it means marked + DOMPurify over the entire document and a
11434
+ * wholesale replacement of the bubble's subtree. Once per token that is
11435
+ * quadratic in the answer's length — a long answer is agent-controlled, so
11436
+ * an ordinary run becomes a progressively stalling tab — and every rebuild
11437
+ * takes any selection or focus inside the bubble with it.
11438
+ *
11439
+ * A frame is the right grain: it is the fastest anything on screen can
11440
+ * change anyway, so a burst of tokens costs one parse and the text still
11441
+ * appears to flow rather than in visible chunks.
11442
+ */
11443
+ #queueStream(buffer) {
11444
+ this.#streamBuffer = buffer;
11445
+ this.#openStream();
11446
+ if (this.#streamFrame !== null) {
11447
+ return;
11448
+ }
11449
+ this.#streamFrame = requestAnimationFrame(() => {
11450
+ this.#streamFrame = null;
11451
+ this.#streamInto(this.#streamBuffer);
11452
+ });
11453
+ }
11454
+ /** Render `buffer` into the streaming bubble now, dropping any queued frame. */
11455
+ #streamInto(buffer) {
11456
+ if (this.#streamFrame !== null) {
11457
+ cancelAnimationFrame(this.#streamFrame);
11458
+ this.#streamFrame = null;
11459
+ }
11460
+ this.#streamBuffer = buffer;
11461
+ const bubble = this.#openStream();
11462
+ bubble.innerHTML = renderMarkdown(buffer, { allowImages: this.allowImages });
11463
+ this.#messages.scrollTop = this.#messages.scrollHeight;
11464
+ return bubble;
11465
+ }
11466
+ /**
11467
+ * Close the current answer's streaming bubble.
11468
+ *
11469
+ * Draws a queued render first. A run that ends without a text end — a
11470
+ * cancel, an error, a round boundary — leaves the last delta sitting in the
11471
+ * queue, and simply dropping the bubble here would strand it: the partial
11472
+ * answer the user stopped mid-sentence would lose its final tokens, or be an
11473
+ * empty bubble above the stopped note.
11474
+ */
11475
+ #endStream() {
11476
+ if (this.#streamFrame !== null) {
11477
+ this.#streamInto(this.#streamBuffer);
11478
+ }
11479
+ this.#streamingBubble = null;
11480
+ }
11011
11481
  /**
11012
11482
  * The card for ``call``, creating and appending it on first sight.
11013
11483
  *
@@ -11122,7 +11592,7 @@ var AgUiChat = class extends HTMLElement {
11122
11592
  return existing;
11123
11593
  }
11124
11594
  const labelled = this.#resolveTool(call.name)?.parameters[X_SUMMARY_KEY];
11125
- const summary = typeof labelled === "string" ? labelled : this.toolSummaries[call.name] ?? this.#toolCatalog[call.name] ?? prettifyToolName(call.name);
11595
+ const summary = typeof labelled === "string" ? labelled : this.toolSummaries[call.name] ?? this.#toolCatalog[call.name]?.summary ?? prettifyToolName(call.name);
11126
11596
  const card = new ToolCallCard(call.name, call.args, summary, this.#strings);
11127
11597
  this.#toolCards.set(call.id, card);
11128
11598
  this.#ensureGroup().appendChild(card.element);
@@ -11197,7 +11667,7 @@ function setControlValue(el2, value) {
11197
11667
  }
11198
11668
 
11199
11669
  // src/version.ts
11200
- var VERSION = "0.27.0";
11670
+ var VERSION = "0.28.0";
11201
11671
  export {
11202
11672
  ATTACHMENT_EVENT,
11203
11673
  AgUiChat,