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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +663 -1
  2. package/README.md +557 -11
  3. package/dist/ag-ui-web-component.bundle.js +294 -36
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +69 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +262 -1
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +46 -1
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/core/conversation_store.d.ts +43 -1
  12. package/dist/core/conversation_store.d.ts.map +1 -1
  13. package/dist/core/create_http_agent.d.ts +13 -0
  14. package/dist/core/create_http_agent.d.ts.map +1 -1
  15. package/dist/core/remote_conversation_store.d.ts +23 -1
  16. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  17. package/dist/core/utils.d.ts +28 -0
  18. package/dist/core/utils.d.ts.map +1 -1
  19. package/dist/index.d.ts +7 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2104 -194
  22. package/dist/index.js.map +4 -4
  23. package/dist/tools/is_destructive.d.ts +8 -2
  24. package/dist/tools/is_destructive.d.ts.map +1 -1
  25. package/dist/tools/parse_tool_catalog.d.ts +11 -4
  26. package/dist/tools/parse_tool_catalog.d.ts.map +1 -1
  27. package/dist/ui/approval_card.d.ts +18 -0
  28. package/dist/ui/approval_card.d.ts.map +1 -1
  29. package/dist/ui/checkpoint_menu.d.ts +10 -0
  30. package/dist/ui/checkpoint_menu.d.ts.map +1 -1
  31. package/dist/ui/confirmation_card.d.ts +16 -0
  32. package/dist/ui/confirmation_card.d.ts.map +1 -1
  33. package/dist/ui/message_actions.d.ts +46 -0
  34. package/dist/ui/message_actions.d.ts.map +1 -0
  35. package/dist/ui/page_quote_offer.d.ts +33 -0
  36. package/dist/ui/page_quote_offer.d.ts.map +1 -0
  37. package/dist/ui/quote_selection.d.ts +66 -0
  38. package/dist/ui/quote_selection.d.ts.map +1 -0
  39. package/dist/ui/relative_time.d.ts +10 -0
  40. package/dist/ui/relative_time.d.ts.map +1 -1
  41. package/dist/ui/render_markdown.d.ts +23 -5
  42. package/dist/ui/render_markdown.d.ts.map +1 -1
  43. package/dist/ui/resize_handle.d.ts +5 -1
  44. package/dist/ui/resize_handle.d.ts.map +1 -1
  45. package/dist/ui/stick_to_bottom.d.ts +55 -0
  46. package/dist/ui/stick_to_bottom.d.ts.map +1 -0
  47. package/dist/ui/styles.d.ts +1 -1
  48. package/dist/ui/styles.d.ts.map +1 -1
  49. package/dist/ui/suggestion_chips.d.ts +29 -0
  50. package/dist/ui/suggestion_chips.d.ts.map +1 -0
  51. package/dist/ui/thread_drawer.d.ts +10 -0
  52. package/dist/ui/thread_drawer.d.ts.map +1 -1
  53. package/dist/ui/tool_call_card.d.ts +8 -0
  54. package/dist/ui/tool_call_card.d.ts.map +1 -1
  55. package/dist/ui/ui_strings.d.ts +53 -7
  56. package/dist/ui/ui_strings.d.ts.map +1 -1
  57. package/dist/ui/voice_input.d.ts.map +1 -1
  58. package/package.json +1 -1
  59. package/src/constants.ts +75 -0
  60. package/src/core/ag_ui_chat.ts +1357 -113
  61. package/src/core/agui_client.ts +81 -1
  62. package/src/core/conversation_store.ts +128 -42
  63. package/src/core/create_http_agent.ts +24 -2
  64. package/src/core/remote_conversation_store.ts +35 -2
  65. package/src/core/utils.ts +58 -0
  66. package/src/index.ts +39 -0
  67. package/src/tools/is_destructive.ts +8 -2
  68. package/src/tools/parse_tool_catalog.ts +18 -6
  69. package/src/ui/approval_card.ts +90 -2
  70. package/src/ui/checkpoint_menu.ts +22 -5
  71. package/src/ui/confirmation_card.ts +29 -1
  72. package/src/ui/message_actions.ts +158 -0
  73. package/src/ui/page_quote_offer.ts +215 -0
  74. package/src/ui/quote_selection.ts +345 -0
  75. package/src/ui/relative_time.ts +11 -0
  76. package/src/ui/render_markdown.ts +111 -21
  77. package/src/ui/resize_handle.ts +32 -2
  78. package/src/ui/stick_to_bottom.ts +126 -0
  79. package/src/ui/styles.ts +227 -0
  80. package/src/ui/suggestion_chips.ts +73 -0
  81. package/src/ui/thread_drawer.ts +22 -2
  82. package/src/ui/tool_call_card.ts +9 -0
  83. package/src/ui/ui_strings.ts +79 -8
  84. package/src/ui/voice_input.ts +43 -0
  85. package/src/version.ts +1 -1
package/dist/index.js CHANGED
@@ -6,6 +6,11 @@ var UNREAD_EVENT = "ag-ui-unread";
6
6
  var STATE_EVENT = "ag-ui-state";
7
7
  var ATTACHMENT_EVENT = "ag-ui-attachments";
8
8
  var RUN_FINISHED_EVENT = "ag-ui-run-finished";
9
+ var CUSTOM_AGENT_EVENT = "ag-ui-custom";
10
+ var INVALIDATE_EVENT = "ag-ui-invalidate";
11
+ var FEEDBACK_EVENT = "ag-ui-feedback";
12
+ var SUGGESTIONS_ACTIVITY_TYPE = "suggestions";
13
+ var INVALIDATE_CUSTOM_NAME = "ag_ui.invalidate";
9
14
  var MESSAGE_ROLE = {
10
15
  USER: "user",
11
16
  ASSISTANT: "assistant"
@@ -47,6 +52,10 @@ var ICON_FILE_IMAGE = `<svg class="glyph" viewBox="0 0 24 24" aria-hidden="true"
47
52
  var ICON_FILE_PDF = `<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"/><rect class="glyph--solid" x="7.5" y="13.5" width="9" height="4.5" rx="1"/></svg>`;
48
53
  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
54
  var CHART_ACTIVITY_TYPE = "chart";
55
+ var ANNOUNCE_CLEAR_MS = 150;
56
+
57
+ // src/core/ag_ui_chat.ts
58
+ import { randomUUID as randomUUID5 } from "@ag-ui/client";
50
59
 
51
60
  // src/skills/fill_template.ts
52
61
  var PLACEHOLDER_RE = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
@@ -441,8 +450,9 @@ function parseToolCatalog(data) {
441
450
  const record = entry;
442
451
  const name = record["name"];
443
452
  const summary = record["summary"];
453
+ const description = record["description"];
444
454
  if (typeof name === "string" && typeof summary === "string") {
445
- out[name] = summary;
455
+ out[name] = typeof description === "string" ? { name, summary, description } : { name, summary };
446
456
  }
447
457
  }
448
458
  return out;
@@ -533,6 +543,12 @@ var DEFAULT_UI_STRINGS = {
533
543
  forkRun: "Fork",
534
544
  forkedRun: "branched",
535
545
  conversation: "Conversation",
546
+ jumpToLatest: "Jump to latest",
547
+ announceResponding: "Assistant is responding",
548
+ announceAnswerReady: "Assistant answered",
549
+ announceAwaitingDecision: "{count} action is waiting for your approval",
550
+ announceStopped: "Response stopped",
551
+ announceFailed: "The response failed",
536
552
  thinking: "Assistant is thinking\u2026",
537
553
  thoughts: "Thoughts",
538
554
  stopped: "\u23F9 Stopped",
@@ -540,6 +556,8 @@ var DEFAULT_UI_STRINGS = {
540
556
  noResult: "No result returned.",
541
557
  declinedAction: "User declined the action.",
542
558
  navigating: "Navigating\u2026",
559
+ historyReplaced: "The server replaced this conversation's history. Reload to see the updated transcript.",
560
+ chartUndrawable: "A chart could not be drawn from the data sent, so it was removed.",
543
561
  historyCompacted: "Earlier turns condensed to fit the context window ({count} removed)",
544
562
  usingSkill: "Using skill {name}",
545
563
  runInterrupted: "The previous response didn\u2019t finish \u2014 the page changed before it arrived.",
@@ -555,6 +573,7 @@ var DEFAULT_UI_STRINGS = {
555
573
  stopRecording: "Stop recording",
556
574
  transcribing: "Transcribing\u2026",
557
575
  transcriptionFailed: "Transcription failed",
576
+ recordingLimit: "Stopped at the {n}-minute limit \u2014 transcribing what was recorded.",
558
577
  toolRunning: "running\u2026",
559
578
  toolDeferred: "waiting for you",
560
579
  toolDone: "\u2713 done",
@@ -568,7 +587,18 @@ var DEFAULT_UI_STRINGS = {
568
587
  errorLabel: "Error",
569
588
  declinedLabel: "Declined",
570
589
  details: "Details",
590
+ approvalEditArgs: "Edit the arguments before approving",
591
+ approvalArgsInvalid: "That is not valid JSON, so nothing was sent.",
592
+ approvalArgsNotAnObject: "Arguments have to be a JSON object.",
593
+ suggestions: "Suggested follow-ups",
594
+ messageActions: "Message actions",
595
+ quoteSelection: "Quote",
596
+ copyMessage: "Copy message",
597
+ retryMessage: "Try again",
598
+ feedbackUp: "Good answer",
599
+ feedbackDown: "Poor answer",
571
600
  confirmAction: "Confirm action",
601
+ confirmAlways: "Always allow",
572
602
  confirmRun: "Run \u201C{tool}\u201D?",
573
603
  confirm: "Confirm",
574
604
  cancel: "Cancel",
@@ -635,6 +665,7 @@ function requestApproval(host, request, options = {}) {
635
665
  body.className = "approval-body";
636
666
  body.setAttribute("part", "approval-body");
637
667
  body.textContent = request.message ?? strings.approvalPrompt;
668
+ const editor = buildEditor(request, options, strings);
638
669
  const actions = document.createElement("div");
639
670
  actions.className = "approval-actions";
640
671
  actions.setAttribute("part", "approval-actions");
@@ -652,10 +683,15 @@ function requestApproval(host, request, options = {}) {
652
683
  resolve(approved);
653
684
  };
654
685
  deny.addEventListener("click", () => close(false));
655
- approve.addEventListener("click", () => close(true));
686
+ approve.addEventListener("click", () => {
687
+ if (editor !== null && !editor.commit()) {
688
+ return;
689
+ }
690
+ close(true);
691
+ });
656
692
  options.signal?.addEventListener("abort", () => close(false), { once: true });
657
693
  actions.append(deny, approve);
658
- card.append(body, actions);
694
+ card.append(body, ...editor === null ? [] : [editor.root], actions);
659
695
  host.appendChild(card);
660
696
  if (options.signal?.aborted === true) {
661
697
  close(false);
@@ -664,6 +700,54 @@ function requestApproval(host, request, options = {}) {
664
700
  approve.focus();
665
701
  });
666
702
  }
703
+ function buildEditor(request, options, strings) {
704
+ const { onEdit } = options;
705
+ if (onEdit === void 0 || request.args === void 0) {
706
+ return null;
707
+ }
708
+ const original = JSON.stringify(request.args, null, 2);
709
+ const root = document.createElement("div");
710
+ root.className = "approval-edit";
711
+ root.setAttribute("part", "approval-edit");
712
+ const field = document.createElement("textarea");
713
+ field.className = "approval-args";
714
+ field.setAttribute("part", "approval-args");
715
+ field.setAttribute("aria-label", strings.approvalEditArgs);
716
+ field.rows = Math.min(10, original.split("\n").length);
717
+ field.value = original;
718
+ const error = document.createElement("div");
719
+ error.className = "approval-error";
720
+ error.setAttribute("part", "approval-error");
721
+ error.setAttribute("role", "alert");
722
+ error.hidden = true;
723
+ root.append(field, error);
724
+ return {
725
+ root,
726
+ commit: () => {
727
+ if (field.value === original) {
728
+ return true;
729
+ }
730
+ let parsed;
731
+ try {
732
+ parsed = JSON.parse(field.value);
733
+ } catch {
734
+ error.textContent = strings.approvalArgsInvalid;
735
+ error.hidden = false;
736
+ field.focus();
737
+ return false;
738
+ }
739
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
740
+ error.textContent = strings.approvalArgsNotAnObject;
741
+ error.hidden = false;
742
+ field.focus();
743
+ return false;
744
+ }
745
+ error.hidden = true;
746
+ onEdit(parsed);
747
+ return true;
748
+ }
749
+ };
750
+ }
667
751
 
668
752
  // src/ui/attach_copy_buttons.ts
669
753
  var CONFIRM_MS = 1500;
@@ -1394,6 +1478,7 @@ var CheckpointMenu = class {
1394
1478
  #heading;
1395
1479
  /** What had focus before the panel opened, restored on close. */
1396
1480
  #lastFocused = null;
1481
+ #formatRelativeTime = null;
1397
1482
  #strings;
1398
1483
  #runs = [];
1399
1484
  constructor(onPick, strings = DEFAULT_UI_STRINGS) {
@@ -1426,6 +1511,21 @@ var CheckpointMenu = class {
1426
1511
  this.#render();
1427
1512
  }
1428
1513
  /** Re-localize a panel built before the host's strings resolved. */
1514
+ /**
1515
+ * Replace the timestamp formatter, or restore the built-in with `null`.
1516
+ *
1517
+ * The built-in is deliberately locale-neutral -- there is no `Intl` anywhere
1518
+ * in this component, so it never disagrees with a host's own formatting by
1519
+ * guessing a locale. That is a defensible default and a poor requirement, so
1520
+ * this is the way out.
1521
+ */
1522
+ setRelativeTimeFormatter(format) {
1523
+ this.#formatRelativeTime = format;
1524
+ }
1525
+ /** This row's timestamp, through the host's formatter when it set one. */
1526
+ #formatTime(timestamp) {
1527
+ return this.#formatRelativeTime !== null ? this.#formatRelativeTime(timestamp) : relativeTime(timestamp, Date.now(), this.#strings);
1528
+ }
1429
1529
  setStrings(strings) {
1430
1530
  this.#strings = strings;
1431
1531
  this.element.setAttribute("aria-label", strings.checkpoints);
@@ -1527,7 +1627,7 @@ var CheckpointMenu = class {
1527
1627
  row.className = "checkpoint-row";
1528
1628
  row.setAttribute("part", "checkpoint-row");
1529
1629
  const preview = previewOf(run);
1530
- const time = run.started_at === null ? null : relativeTime(Date.parse(run.started_at), Date.now(), this.#strings);
1630
+ const time = run.started_at === null ? null : this.#formatTime(Date.parse(run.started_at));
1531
1631
  const label = document.createElement("span");
1532
1632
  label.className = "checkpoint-label";
1533
1633
  label.setAttribute("part", "checkpoint-label");
@@ -1608,6 +1708,7 @@ function requestConfirmation(host, request, options = {}) {
1608
1708
  actions.className = "confirm-actions";
1609
1709
  actions.setAttribute("part", "confirm-actions");
1610
1710
  const cancel = actionButton2("cancel", strings.cancel);
1711
+ const always = options.onAlwaysAllow === void 0 ? null : actionButton2("always", strings.confirmAlways.replace("{tool}", request.toolName));
1611
1712
  const confirm2 = actionButton2("confirm", strings.confirm);
1612
1713
  let settled = false;
1613
1714
  const close = (accepted) => {
@@ -1620,8 +1721,12 @@ function requestConfirmation(host, request, options = {}) {
1620
1721
  };
1621
1722
  cancel.addEventListener("click", () => close(false));
1622
1723
  confirm2.addEventListener("click", () => close(true));
1724
+ always?.addEventListener("click", () => {
1725
+ options.onAlwaysAllow?.();
1726
+ close(true);
1727
+ });
1623
1728
  options.signal?.addEventListener("abort", () => close(false), { once: true });
1624
- actions.append(cancel, confirm2);
1729
+ actions.append(cancel, ...always === null ? [] : [always], confirm2);
1625
1730
  card.append(body, args, actions);
1626
1731
  host.appendChild(card);
1627
1732
  if (options.signal?.aborted === true) {
@@ -1632,6 +1737,347 @@ function requestConfirmation(host, request, options = {}) {
1632
1737
  });
1633
1738
  }
1634
1739
 
1740
+ // src/ui/message_actions.ts
1741
+ var CONFIRM_MS2 = 1500;
1742
+ function attachMessageActions(bubble, options) {
1743
+ if (existingBar(bubble) !== null) {
1744
+ return;
1745
+ }
1746
+ const bar = messageActionBar(bubble, options.strings);
1747
+ bar.appendChild(copyButton(options));
1748
+ if (options.onFeedback !== void 0) {
1749
+ bar.append(
1750
+ feedbackButton("up", options.strings.feedbackUp, options.onFeedback),
1751
+ feedbackButton("down", options.strings.feedbackDown, options.onFeedback)
1752
+ );
1753
+ }
1754
+ }
1755
+ function messageActionBar(bubble, strings) {
1756
+ const existing = existingBar(bubble);
1757
+ if (existing !== null) {
1758
+ return existing;
1759
+ }
1760
+ const bar = document.createElement("div");
1761
+ bar.className = "message-actions";
1762
+ bar.setAttribute("part", "message-actions");
1763
+ bar.setAttribute("role", "group");
1764
+ bar.setAttribute("aria-label", strings.messageActions);
1765
+ bubble.after(bar);
1766
+ return bar;
1767
+ }
1768
+ function existingBar(bubble) {
1769
+ const next = bubble.nextElementSibling;
1770
+ return next?.classList.contains("message-actions") === true ? next : null;
1771
+ }
1772
+ function messageActionButton(modifier, label, glyph) {
1773
+ const button2 = document.createElement("button");
1774
+ button2.type = "button";
1775
+ button2.className = `message-action message-action--${modifier}`;
1776
+ button2.setAttribute("part", `message-action message-action-${modifier}`);
1777
+ button2.title = label;
1778
+ button2.setAttribute("aria-label", label);
1779
+ const icon = document.createElement("span");
1780
+ icon.setAttribute("aria-hidden", "true");
1781
+ icon.textContent = glyph;
1782
+ button2.appendChild(icon);
1783
+ return button2;
1784
+ }
1785
+ function copyButton(options) {
1786
+ const { strings } = options;
1787
+ const button2 = messageActionButton("copy", strings.copyMessage, "\u2398");
1788
+ button2.addEventListener("click", () => {
1789
+ void navigator.clipboard.writeText(options.text()).then(
1790
+ () => flash2(button2, strings.copied, strings.copyMessage),
1791
+ // A denied clipboard permission is the common case, not an exception:
1792
+ // say so on the button rather than throwing into an unhandled rejection.
1793
+ () => flash2(button2, strings.copyFailed, strings.copyMessage)
1794
+ );
1795
+ });
1796
+ return button2;
1797
+ }
1798
+ function feedbackButton(rating, label, report) {
1799
+ const button2 = messageActionButton(
1800
+ rating === "up" ? "up" : "down",
1801
+ label,
1802
+ rating === "up" ? "\u{1F44D}" : "\u{1F44E}"
1803
+ );
1804
+ button2.addEventListener("click", () => {
1805
+ const pressed = button2.getAttribute("aria-pressed") === "true";
1806
+ button2.setAttribute("aria-pressed", pressed ? "false" : "true");
1807
+ report(rating);
1808
+ });
1809
+ button2.setAttribute("aria-pressed", "false");
1810
+ return button2;
1811
+ }
1812
+ function flash2(button2, message, label) {
1813
+ button2.title = message;
1814
+ button2.setAttribute("aria-label", message);
1815
+ button2.classList.add("message-action--confirmed");
1816
+ setTimeout(() => {
1817
+ button2.title = label;
1818
+ button2.setAttribute("aria-label", label);
1819
+ button2.classList.remove("message-action--confirmed");
1820
+ }, CONFIRM_MS2);
1821
+ }
1822
+
1823
+ // src/ui/quote_selection.ts
1824
+ var MAX_QUOTE_CHARS = 500;
1825
+ function quotableSelection(container, roots = [], near) {
1826
+ for (const endpoints of endpointCandidates(roots)) {
1827
+ if (!container.contains(endpoints.startContainer)) {
1828
+ continue;
1829
+ }
1830
+ if (!container.contains(endpoints.endContainer)) {
1831
+ continue;
1832
+ }
1833
+ const range = document.createRange();
1834
+ range.setStart(endpoints.startContainer, endpoints.startOffset);
1835
+ range.setEnd(endpoints.endContainer, endpoints.endOffset);
1836
+ const text3 = renderedText(range).trim();
1837
+ if (text3 === "") {
1838
+ continue;
1839
+ }
1840
+ return { text: text3, rect: lineToHangFrom(range, near) };
1841
+ }
1842
+ return null;
1843
+ }
1844
+ function asQuote(text3) {
1845
+ const lines = tidy(text3);
1846
+ if (lines.length === 0) {
1847
+ return "";
1848
+ }
1849
+ const capped = cap(lines.join("\n"));
1850
+ const quoted = capped.split("\n").map((line) => `> ${line}`.trimEnd()).join("\n");
1851
+ return `${quoted}
1852
+
1853
+ `;
1854
+ }
1855
+ function tidy(text3) {
1856
+ const lines = text3.split(/\r\n?|\n/).map((line) => line.trimEnd());
1857
+ const indents = lines.filter((line) => line !== "").map(indentOf);
1858
+ const shared = indents.length === 0 ? 0 : Math.min(...indents);
1859
+ const kept = [];
1860
+ for (const line of lines) {
1861
+ const dedented = line.slice(shared);
1862
+ if (dedented === "" && (kept.length === 0 || kept[kept.length - 1] === "")) {
1863
+ continue;
1864
+ }
1865
+ kept.push(dedented);
1866
+ }
1867
+ while (kept[kept.length - 1] === "") {
1868
+ kept.pop();
1869
+ }
1870
+ return kept;
1871
+ }
1872
+ function indentOf(line) {
1873
+ return line.length - line.trimStart().length;
1874
+ }
1875
+ function cap(text3) {
1876
+ return text3.length > MAX_QUOTE_CHARS ? `${text3.slice(0, MAX_QUOTE_CHARS).trimEnd()}...` : text3;
1877
+ }
1878
+ function renderedText(range) {
1879
+ let text3 = "";
1880
+ for (const node of textNodesIn(range)) {
1881
+ const parent = node.parentElement;
1882
+ if (!isRendered(parent)) {
1883
+ continue;
1884
+ }
1885
+ const from = node === range.startContainer ? range.startOffset : 0;
1886
+ const to = node === range.endContainer ? range.endOffset : node.data.length;
1887
+ text3 += collapse(node.data.slice(from, to), parent);
1888
+ }
1889
+ return text3;
1890
+ }
1891
+ function collapse(text3, parent) {
1892
+ if (PREFORMATTED.has(whiteSpaceOf(parent))) {
1893
+ return text3;
1894
+ }
1895
+ return text3.replace(/[^\S\n]*\n[^\S\n]*/g, "\n").replace(/[^\S\n]+/g, " ");
1896
+ }
1897
+ var PREFORMATTED = /* @__PURE__ */ new Set(["pre", "pre-wrap", "break-spaces"]);
1898
+ function whiteSpaceOf(element) {
1899
+ return window.getComputedStyle(element).whiteSpace;
1900
+ }
1901
+ function textNodesIn(range) {
1902
+ const root = range.commonAncestorContainer;
1903
+ if (root.nodeType === Node.TEXT_NODE) {
1904
+ return [root];
1905
+ }
1906
+ const found = [];
1907
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1908
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
1909
+ if (range.intersectsNode(node)) {
1910
+ found.push(node);
1911
+ }
1912
+ }
1913
+ return found;
1914
+ }
1915
+ function isRendered(element) {
1916
+ if (typeof element.checkVisibility !== "function") {
1917
+ return true;
1918
+ }
1919
+ return element.checkVisibility({
1920
+ contentVisibilityAuto: true,
1921
+ opacityProperty: true,
1922
+ visibilityProperty: true
1923
+ });
1924
+ }
1925
+ function lineToHangFrom(range, near) {
1926
+ const lines = [...range.getClientRects()];
1927
+ if (lines.length === 0) {
1928
+ return range.getBoundingClientRect();
1929
+ }
1930
+ if (near === void 0) {
1931
+ return lines[0];
1932
+ }
1933
+ let closest = lines[0];
1934
+ let shortest = distanceTo(closest, near);
1935
+ for (const line of lines.slice(1)) {
1936
+ const distance = distanceTo(line, near);
1937
+ if (distance < shortest) {
1938
+ shortest = distance;
1939
+ closest = line;
1940
+ }
1941
+ }
1942
+ return closest;
1943
+ }
1944
+ function distanceTo(rect, point) {
1945
+ const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
1946
+ const dy = Math.max(rect.top - point.y, 0, point.y - rect.bottom);
1947
+ return Math.hypot(dx, dy);
1948
+ }
1949
+ function endpointCandidates(roots) {
1950
+ const selection = window.getSelection();
1951
+ if (selection === null) {
1952
+ return [];
1953
+ }
1954
+ const candidates = [...composedRanges(selection, roots)];
1955
+ if (selection.rangeCount > 0) {
1956
+ candidates.push(selection.getRangeAt(0));
1957
+ }
1958
+ return candidates;
1959
+ }
1960
+ function composedRanges(selection, roots) {
1961
+ const composed = selection.getComposedRanges;
1962
+ if (composed === void 0) {
1963
+ return [];
1964
+ }
1965
+ try {
1966
+ return composed.call(selection, { shadowRoots: roots });
1967
+ } catch {
1968
+ return composed.call(selection, ...roots);
1969
+ }
1970
+ }
1971
+
1972
+ // src/ui/page_quote_offer.ts
1973
+ var GAP = 6;
1974
+ var OFFER_CSS = `
1975
+ .ag-ui-quote-offer {
1976
+ position: fixed;
1977
+ z-index: 2147483000;
1978
+ transform: translate(-50%, -100%);
1979
+ margin: 0;
1980
+ padding: 0.25em 0.7em;
1981
+ border: 1px solid rgb(0 0 0 / 0.15);
1982
+ border-radius: 999px;
1983
+ background: Canvas;
1984
+ color: CanvasText;
1985
+ font: inherit;
1986
+ font-size: 0.8rem;
1987
+ line-height: 1.6;
1988
+ white-space: nowrap;
1989
+ cursor: pointer;
1990
+ box-shadow: 0 2px 10px rgb(0 0 0 / 0.18);
1991
+ }
1992
+
1993
+ .ag-ui-quote-offer[data-below="true"] {
1994
+ transform: translate(-50%, 0);
1995
+ }
1996
+ `;
1997
+ function attachQuoteOffer(options) {
1998
+ const { within, exclude, onQuote } = options;
1999
+ const sheet = new CSSStyleSheet();
2000
+ sheet.replaceSync(OFFER_CSS);
2001
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
2002
+ const button2 = document.createElement("button");
2003
+ button2.type = "button";
2004
+ button2.className = "ag-ui-quote-offer";
2005
+ button2.textContent = options.label;
2006
+ button2.hidden = true;
2007
+ document.body.append(button2);
2008
+ let quoting = "";
2009
+ const hide = () => {
2010
+ button2.hidden = true;
2011
+ quoting = "";
2012
+ };
2013
+ const settled = (event) => {
2014
+ if (event.composedPath().includes(exclude)) {
2015
+ hide();
2016
+ return;
2017
+ }
2018
+ if (fieldHasFocus()) {
2019
+ hide();
2020
+ return;
2021
+ }
2022
+ const near = event instanceof MouseEvent ? { x: event.clientX, y: event.clientY } : void 0;
2023
+ const selected = quotableSelection(within, [], near);
2024
+ if (selected === null) {
2025
+ hide();
2026
+ return;
2027
+ }
2028
+ quoting = selected.text;
2029
+ place(button2, selected.rect);
2030
+ };
2031
+ const onMouseDown = (event) => {
2032
+ if (!button2.contains(event.target)) {
2033
+ hide();
2034
+ }
2035
+ };
2036
+ within.addEventListener("mouseup", settled);
2037
+ within.addEventListener("keyup", settled);
2038
+ within.addEventListener("mousedown", onMouseDown);
2039
+ document.addEventListener("scroll", hide, true);
2040
+ window.addEventListener("resize", hide);
2041
+ button2.addEventListener("mousedown", (event) => {
2042
+ event.preventDefault();
2043
+ });
2044
+ button2.addEventListener("click", () => {
2045
+ const text3 = quoting;
2046
+ window.getSelection()?.removeAllRanges();
2047
+ hide();
2048
+ onQuote(text3);
2049
+ });
2050
+ return {
2051
+ element: button2,
2052
+ detach() {
2053
+ within.removeEventListener("mouseup", settled);
2054
+ within.removeEventListener("keyup", settled);
2055
+ within.removeEventListener("mousedown", onMouseDown);
2056
+ document.removeEventListener("scroll", hide, true);
2057
+ window.removeEventListener("resize", hide);
2058
+ button2.remove();
2059
+ document.adoptedStyleSheets = document.adoptedStyleSheets.filter((each) => each !== sheet);
2060
+ }
2061
+ };
2062
+ }
2063
+ function fieldHasFocus() {
2064
+ const active = document.activeElement;
2065
+ if (active === null) {
2066
+ return false;
2067
+ }
2068
+ return active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.isContentEditable === true;
2069
+ }
2070
+ function place(button2, rect) {
2071
+ button2.hidden = false;
2072
+ const below = rect.top < GAP + button2.offsetHeight;
2073
+ button2.dataset["below"] = String(below);
2074
+ button2.style.top = `${below ? rect.bottom + GAP : rect.top - GAP}px`;
2075
+ const half = button2.offsetWidth / 2;
2076
+ const centre = rect.left + rect.width / 2;
2077
+ const width = document.documentElement.clientWidth;
2078
+ button2.style.left = `${Math.min(Math.max(centre, half), width - half)}px`;
2079
+ }
2080
+
1635
2081
  // src/ui/prettify_tool_name.ts
1636
2082
  function prettifyToolName(name) {
1637
2083
  const spaced = name.replace(/[._-]+/g, " ").trim();
@@ -2121,7 +2567,7 @@ function createDOMPurify() {
2121
2567
  const originalDocument = document2;
2122
2568
  const currentScript = originalDocument.currentScript;
2123
2569
  window2.DocumentFragment;
2124
- const HTMLTemplateElement = window2.HTMLTemplateElement, Node2 = window2.Node, Element = window2.Element, NodeFilter = window2.NodeFilter, _window$NamedNodeMap = window2.NamedNodeMap;
2570
+ const HTMLTemplateElement = window2.HTMLTemplateElement, Node2 = window2.Node, Element = window2.Element, NodeFilter2 = window2.NodeFilter, _window$NamedNodeMap = window2.NamedNodeMap;
2125
2571
  _window$NamedNodeMap === void 0 ? window2.NamedNodeMap || window2.MozNamedAttrMap : _window$NamedNodeMap;
2126
2572
  window2.HTMLFormElement;
2127
2573
  const DOMParser = window2.DOMParser, trustedTypes = window2.trustedTypes;
@@ -2718,7 +3164,7 @@ function createDOMPurify() {
2718
3164
  doc || root,
2719
3165
  root,
2720
3166
  // eslint-disable-next-line no-bitwise
2721
- NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_PROCESSING_INSTRUCTION | NodeFilter.SHOW_CDATA_SECTION,
3167
+ NodeFilter2.SHOW_ELEMENT | NodeFilter2.SHOW_COMMENT | NodeFilter2.SHOW_TEXT | NodeFilter2.SHOW_PROCESSING_INSTRUCTION | NodeFilter2.SHOW_CDATA_SECTION,
2722
3168
  null
2723
3169
  );
2724
3170
  };
@@ -2736,7 +3182,7 @@ function createDOMPurify() {
2736
3182
  doc || node,
2737
3183
  node,
2738
3184
  // eslint-disable-next-line no-bitwise
2739
- NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_CDATA_SECTION | NodeFilter.SHOW_PROCESSING_INSTRUCTION,
3185
+ NodeFilter2.SHOW_TEXT | NodeFilter2.SHOW_COMMENT | NodeFilter2.SHOW_CDATA_SECTION | NodeFilter2.SHOW_PROCESSING_INSTRUCTION,
2740
3186
  null
2741
3187
  );
2742
3188
  let currentNode = walker.nextNode();
@@ -4596,23 +5042,51 @@ var ALLOWED_TAGS = [
4596
5042
  "th",
4597
5043
  "td"
4598
5044
  ];
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"];
5045
+ var ALLOWED_ATTR = ["href", "title", "class", "target", "rel"];
5046
+ var SANITIZE_CONFIG = {
5047
+ ALLOWED_TAGS,
5048
+ ALLOWED_ATTR,
5049
+ ALLOW_DATA_ATTR: false,
5050
+ ALLOW_ARIA_ATTR: false
5051
+ };
5052
+ var SANITIZE_CONFIG_WITH_IMAGES = {
5053
+ ...SANITIZE_CONFIG,
5054
+ ALLOWED_TAGS: [...ALLOWED_TAGS, "img"],
5055
+ ALLOWED_ATTR: [...ALLOWED_ATTR, "src", "alt", "width", "height"]
5056
+ };
5057
+ var LANGUAGE_CLASS = /^language-[A-Za-z0-9_+#.-]+$/;
5058
+ var LANGUAGE_HOSTS = /* @__PURE__ */ new Set(["CODE", "PRE"]);
5059
+ function harden(node) {
5060
+ if (node.nodeName === "A" && node.hasAttribute("href")) {
5061
+ node.setAttribute("target", "_blank");
5062
+ node.setAttribute("rel", "noopener noreferrer");
5063
+ } else {
5064
+ node.removeAttribute("target");
5065
+ node.removeAttribute("rel");
5066
+ }
5067
+ const classes = node.getAttribute("class");
5068
+ if (classes === null) {
5069
+ return;
5070
+ }
5071
+ const kept = LANGUAGE_HOSTS.has(node.nodeName) ? classes.split(/\s+/).filter((token) => LANGUAGE_CLASS.test(token)) : [];
5072
+ if (kept.length === 0) {
5073
+ node.removeAttribute("class");
5074
+ return;
5075
+ }
5076
+ node.setAttribute("class", kept.join(" "));
5077
+ }
5078
+ var purifier = null;
5079
+ function sanitizer() {
5080
+ if (purifier === null) {
5081
+ purifier = purify();
5082
+ purifier.addHook("afterSanitizeAttributes", harden);
5083
+ }
5084
+ return purifier;
5085
+ }
4602
5086
  function renderMarkdown(text3, options) {
4603
5087
  const allowImages = options?.allowImages === true;
4604
5088
  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();
5089
+ return sanitizer().sanitize(rendered, allowImages ? SANITIZE_CONFIG_WITH_IMAGES : SANITIZE_CONFIG).trim();
4616
5090
  }
4617
5091
 
4618
5092
  // src/ui/resize_handle.ts
@@ -4655,6 +5129,15 @@ function createResizeHandle(options) {
4655
5129
  window.addEventListener("pointerup", onUp);
4656
5130
  event.preventDefault();
4657
5131
  });
5132
+ let pending = null;
5133
+ const settle = () => {
5134
+ if (pending === null) {
5135
+ return;
5136
+ }
5137
+ const size = pending;
5138
+ pending = null;
5139
+ options.commit(size);
5140
+ };
4658
5141
  handle.addEventListener("keydown", (event) => {
4659
5142
  const axis = options.axis();
4660
5143
  if (axis === "none") {
@@ -4680,8 +5163,10 @@ function createResizeHandle(options) {
4680
5163
  }
4681
5164
  event.preventDefault();
4682
5165
  options.apply(next);
4683
- options.commit(next);
5166
+ pending = next;
4684
5167
  });
5168
+ handle.addEventListener("keyup", settle);
5169
+ handle.addEventListener("blur", settle);
4685
5170
  return handle;
4686
5171
  }
4687
5172
 
@@ -4892,6 +5377,60 @@ var SkillsMenu = class {
4892
5377
  }
4893
5378
  };
4894
5379
 
5380
+ // src/ui/stick_to_bottom.ts
5381
+ var BOTTOM_SLACK_PX = 4;
5382
+ function createStickToBottom({
5383
+ viewport,
5384
+ onMissedContent
5385
+ }) {
5386
+ let isFollowing = true;
5387
+ let missed = false;
5388
+ const atBottom = () => viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= BOTTOM_SLACK_PX;
5389
+ const setMissed = (next) => {
5390
+ if (next === missed) {
5391
+ return;
5392
+ }
5393
+ missed = next;
5394
+ onMissedContent(missed);
5395
+ };
5396
+ const toBottom = () => {
5397
+ viewport.scrollTop = viewport.scrollHeight;
5398
+ };
5399
+ const onScroll = () => {
5400
+ isFollowing = atBottom();
5401
+ if (isFollowing) {
5402
+ setMissed(false);
5403
+ }
5404
+ };
5405
+ const follow = () => {
5406
+ if (isFollowing) {
5407
+ toBottom();
5408
+ return;
5409
+ }
5410
+ setMissed(true);
5411
+ };
5412
+ viewport.addEventListener("scroll", onScroll, { passive: true });
5413
+ const observer = new ResizeObserver(() => {
5414
+ if (isFollowing) {
5415
+ toBottom();
5416
+ }
5417
+ });
5418
+ observer.observe(viewport);
5419
+ return {
5420
+ follow,
5421
+ jump: () => {
5422
+ isFollowing = true;
5423
+ setMissed(false);
5424
+ toBottom();
5425
+ },
5426
+ following: () => isFollowing,
5427
+ dispose: () => {
5428
+ viewport.removeEventListener("scroll", onScroll);
5429
+ observer.disconnect();
5430
+ }
5431
+ };
5432
+ }
5433
+
4895
5434
  // src/ui/styles.ts
4896
5435
  var STYLES = `
4897
5436
  /* \u2500\u2500 Token defaults \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
@@ -5462,6 +6001,7 @@ var STYLES = `
5462
6001
  opacity: 0;
5463
6002
  }
5464
6003
 
6004
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) .messages-wrap,
5465
6005
  :host([collapsed]:is([placement="embedded"], [placement="page"])) .messages,
5466
6006
  :host([collapsed]:is([placement="embedded"], [placement="page"])) .input-row,
5467
6007
  :host([collapsed]:is([placement="embedded"], [placement="page"])) .skill-chips,
@@ -5470,9 +6010,113 @@ var STYLES = `
5470
6010
  display: none;
5471
6011
  }
5472
6012
 
6013
+ /* Jump-to-latest: shown only once the reader has scrolled away *and* missed
6014
+ something. Anchored to the panel rather than the list so it does not scroll
6015
+ with the content it is offering to scroll to. */
6016
+ /* The transcript's own box, and the only one whose foot is the transcript's
6017
+ foot. The panel's foot is below the composer, the chips and the footer. */
6018
+ .messages-wrap {
6019
+ position: relative;
6020
+ flex: 1;
6021
+ min-height: 0;
6022
+ display: flex;
6023
+ flex-direction: column;
6024
+ }
6025
+
6026
+ .jump-latest {
6027
+ position: absolute;
6028
+ left: 50%;
6029
+ transform: translateX(-50%);
6030
+ bottom: var(--_pad);
6031
+ z-index: 2;
6032
+ display: none;
6033
+ align-items: center;
6034
+ gap: 0.35em;
6035
+ padding: 0.4em 0.9em;
6036
+ border: 1px solid var(--_border);
6037
+ border-radius: 999px;
6038
+ /* A raised surface, not the panel's own background. Reusing --_bg made the
6039
+ pill the same colour as everything behind it, leaving a 1px border and a
6040
+ shadow to carry the whole affordance -- and a dark-on-dark shadow carries
6041
+ nothing. --_hover is the token that already means "lifted off the panel",
6042
+ and it separates in both themes without competing with the accent the send
6043
+ button owns. */
6044
+ background: var(--_hover);
6045
+ color: var(--_text);
6046
+ font: inherit;
6047
+ font-size: 0.85em;
6048
+ cursor: pointer;
6049
+ box-shadow: 0 2px 10px rgb(0 0 0 / 0.18);
6050
+ }
6051
+
6052
+ .jump-latest[data-missed="true"] {
6053
+ display: flex;
6054
+ }
6055
+
6056
+ .jump-latest:hover {
6057
+ border-color: var(--_accent);
6058
+ }
6059
+
6060
+ /* The offer to quote a selection. Positioned in script against the transcript
6061
+ box, which is the only ancestor whose top and foot are the transcript's --
6062
+ the same reason .jump-latest lives here. The translate is the half the
6063
+ script does not do: script sets the point the offer hangs from, CSS decides
6064
+ which corner of the offer that point is. */
6065
+ .quote-selection {
6066
+ position: absolute;
6067
+ z-index: 2;
6068
+ transform: translate(-50%, -100%);
6069
+ padding: 0.25em 0.7em;
6070
+ border: 1px solid var(--_border);
6071
+ border-radius: 999px;
6072
+ background: var(--_hover);
6073
+ color: var(--_text);
6074
+ font: inherit;
6075
+ font-size: 0.8em;
6076
+ line-height: 1.6;
6077
+ white-space: nowrap;
6078
+ cursor: pointer;
6079
+ box-shadow: 0 2px 10px rgb(0 0 0 / 0.18);
6080
+ }
6081
+
6082
+ /* Flipped under the selection when there was no room above it. Only the
6083
+ vertical half of the translate changes: it still hangs from its own centre
6084
+ horizontally. */
6085
+ .quote-selection[data-below="true"] {
6086
+ transform: translate(-50%, 0);
6087
+ }
6088
+
6089
+ .quote-selection:hover {
6090
+ border-color: var(--_accent);
6091
+ }
6092
+
6093
+ /* Screen-reader-only status region. Off-screen rather than display:none or
6094
+ visibility:hidden, both of which take the element out of the accessibility
6095
+ tree entirely -- a hidden live region announces nothing at all, which is the
6096
+ classic way this pattern is written wrong.
6097
+
6098
+ The 1px box with clip-path, rather than width/height 0, is the shape that
6099
+ survives: a zero-sized element is dropped from the tree by some engines. */
6100
+ .sr-only {
6101
+ position: absolute;
6102
+ width: 1px;
6103
+ height: 1px;
6104
+ margin: -1px;
6105
+ padding: 0;
6106
+ border: 0;
6107
+ overflow: hidden;
6108
+ white-space: nowrap;
6109
+ clip-path: inset(50%);
6110
+ }
6111
+
5473
6112
  .messages {
5474
6113
  flex: 1;
5475
6114
  overflow-y: auto;
6115
+ /* The browser's own scroll anchoring competes with the scroller for the same
6116
+ job and wins unpredictably -- it can hold the view still exactly when we
6117
+ want to follow. Turned off so following is decided in one place. Safari
6118
+ does not implement it, which is itself a reason not to depend on it. */
6119
+ overflow-anchor: none;
5476
6120
  padding: var(--_pad);
5477
6121
  display: flex;
5478
6122
  flex-direction: column;
@@ -6638,6 +7282,7 @@ var STYLES = `
6638
7282
 
6639
7283
  .confirm-actions {
6640
7284
  display: flex;
7285
+ flex-wrap: wrap;
6641
7286
  gap: 8px;
6642
7287
  justify-content: flex-end;
6643
7288
  }
@@ -6664,41 +7309,134 @@ var STYLES = `
6664
7309
  color: #ffffff;
6665
7310
  }
6666
7311
 
6667
- /* Approval card \u2014 the server-side-tool gate (approve/deny an interrupt). */
6668
- .approval {
6669
- align-self: stretch;
6670
- box-sizing: border-box;
6671
- display: flex;
6672
- flex-direction: column;
6673
- gap: 8px;
6674
- padding: 12px;
6675
- background: var(--_bg);
6676
- border: 1px solid var(--_accent);
6677
- border-radius: 10px;
6678
- }
6679
-
6680
- .approval[data-resolved] {
6681
- opacity: 0.7;
6682
- border-color: var(--_border);
7312
+ /* The session waiver. Deliberately the quietest of the three: it is the widest
7313
+ decision on the card, so it should be reachable without being the one the eye
7314
+ lands on when the user means to say yes once. */
7315
+ .confirm-btn--always {
7316
+ font-weight: 500;
7317
+ opacity: 0.85;
6683
7318
  }
6684
7319
 
6685
- .approval-body {
6686
- font-weight: 600;
7320
+ .confirm-btn--always:hover,
7321
+ .confirm-btn--always:focus-visible {
7322
+ opacity: 1;
6687
7323
  }
6688
7324
 
6689
- .approval-actions {
7325
+ /* Editable arguments on an approval card. A plain field rather than a code
7326
+ editor: it holds the JSON a card already displays, and the only interaction
7327
+ is correcting a value before letting the call run. */
7328
+ .approval-edit {
6690
7329
  display: flex;
6691
- gap: 8px;
6692
- justify-content: flex-end;
7330
+ flex-direction: column;
7331
+ gap: 6px;
6693
7332
  }
6694
7333
 
6695
- .approval-btn {
7334
+ .approval-args {
7335
+ box-sizing: border-box;
7336
+ width: 100%;
7337
+ resize: vertical;
6696
7338
  border: 1px solid var(--_border);
6697
7339
  border-radius: 8px;
6698
- padding: 8px 14px;
6699
- font: inherit;
6700
- font-weight: 600;
6701
- cursor: pointer;
7340
+ padding: 8px;
7341
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
7342
+ font-size: 0.85em;
7343
+ background: var(--_bg);
7344
+ color: var(--_fg);
7345
+ }
7346
+
7347
+ .approval-args:focus-visible {
7348
+ border-color: var(--_accent);
7349
+ outline: none;
7350
+ }
7351
+
7352
+ .approval-error {
7353
+ font-size: 0.85em;
7354
+ color: var(--_danger);
7355
+ }
7356
+
7357
+ /* Message action row. Sits under a finished assistant bubble.
7358
+
7359
+ The wrap is insurance rather than a fix: these buttons are glyph-only, so at
7360
+ every width tested they fit on one line and removing the wrap changes
7361
+ nothing. It is here because the confirmation row one release earlier did
7362
+ overflow when it gained a third button, off the left edge and outside its own
7363
+ card, and the difference between the two rows is only that this one's labels
7364
+ are icons today. */
7365
+ .message-actions {
7366
+ display: flex;
7367
+ flex-wrap: wrap;
7368
+ gap: 4px;
7369
+ /* Negative, and that is the point. The answer group is a flex column with its
7370
+ own gap, so a positive margin here pushes the row further from the message
7371
+ it acts on than the next card is below it -- the buttons then read as
7372
+ belonging to whatever follows. Pulling back inside the gap is what makes
7373
+ them the message's own. */
7374
+ margin-top: -6px;
7375
+ }
7376
+
7377
+ .message-action {
7378
+ border: none;
7379
+ border-radius: 6px;
7380
+ padding: 2px 6px;
7381
+ font: inherit;
7382
+ line-height: 1.2;
7383
+ cursor: pointer;
7384
+ background: transparent;
7385
+ color: var(--_muted);
7386
+ opacity: 0.75;
7387
+ }
7388
+
7389
+ .message-action:hover,
7390
+ .message-action:focus-visible {
7391
+ opacity: 1;
7392
+ background: var(--_border);
7393
+ }
7394
+
7395
+ .message-action[aria-pressed="true"] {
7396
+ opacity: 1;
7397
+ color: var(--_accent);
7398
+ }
7399
+
7400
+ .message-action--confirmed {
7401
+ opacity: 1;
7402
+ color: var(--_accent);
7403
+ }
7404
+
7405
+ /* Approval card \u2014 the server-side-tool gate (approve/deny an interrupt). */
7406
+ .approval {
7407
+ align-self: stretch;
7408
+ box-sizing: border-box;
7409
+ display: flex;
7410
+ flex-direction: column;
7411
+ gap: 8px;
7412
+ padding: 12px;
7413
+ background: var(--_bg);
7414
+ border: 1px solid var(--_accent);
7415
+ border-radius: 10px;
7416
+ }
7417
+
7418
+ .approval[data-resolved] {
7419
+ opacity: 0.7;
7420
+ border-color: var(--_border);
7421
+ }
7422
+
7423
+ .approval-body {
7424
+ font-weight: 600;
7425
+ }
7426
+
7427
+ .approval-actions {
7428
+ display: flex;
7429
+ gap: 8px;
7430
+ justify-content: flex-end;
7431
+ }
7432
+
7433
+ .approval-btn {
7434
+ border: 1px solid var(--_border);
7435
+ border-radius: 8px;
7436
+ padding: 8px 14px;
7437
+ font: inherit;
7438
+ font-weight: 600;
7439
+ cursor: pointer;
6702
7440
  background: var(--_bg);
6703
7441
  color: var(--_fg);
6704
7442
  }
@@ -6808,6 +7546,34 @@ var STYLES = `
6808
7546
  border-color: var(--_accent);
6809
7547
  }
6810
7548
 
7549
+ /* Follow-up suggestion chips. Deliberately the skill chips' shape rather than a
7550
+ second chip vocabulary -- both are "a question you could ask", and the only
7551
+ difference is who chose it. Inside the transcript, so they scroll with the
7552
+ answer they follow instead of hovering above the composer. */
7553
+ .suggestions {
7554
+ display: flex;
7555
+ flex-wrap: wrap;
7556
+ gap: 6px;
7557
+ align-self: stretch;
7558
+ }
7559
+
7560
+ .suggestion-chip {
7561
+ border: 1px solid var(--_border);
7562
+ border-radius: 999px;
7563
+ padding: 4px 12px;
7564
+ font: inherit;
7565
+ font-size: 0.9em;
7566
+ text-align: left;
7567
+ cursor: pointer;
7568
+ background: var(--_assistant-bg);
7569
+ color: var(--_fg);
7570
+ }
7571
+
7572
+ .suggestion-chip:hover,
7573
+ .suggestion-chip:focus-visible {
7574
+ border-color: var(--_accent);
7575
+ }
7576
+
6811
7577
  .skill-palette {
6812
7578
  margin: 8px 12px 0;
6813
7579
  display: flex;
@@ -7222,6 +7988,42 @@ var STYLES = `
7222
7988
  }
7223
7989
  `;
7224
7990
 
7991
+ // src/ui/suggestion_chips.ts
7992
+ var MAX_SUGGESTIONS = 4;
7993
+ var MAX_SUGGESTION_CHARS = 120;
7994
+ function suggestionPrompts(content) {
7995
+ if (typeof content !== "object" || content === null) {
7996
+ return null;
7997
+ }
7998
+ const raw = content.prompts;
7999
+ if (!Array.isArray(raw)) {
8000
+ return null;
8001
+ }
8002
+ const prompts = raw.filter((prompt) => typeof prompt === "string").map((prompt) => prompt.trim()).filter((prompt) => prompt !== "" && prompt.length <= MAX_SUGGESTION_CHARS).slice(0, MAX_SUGGESTIONS);
8003
+ return prompts.length === 0 ? null : prompts;
8004
+ }
8005
+ function renderSuggestionChips(content, strings, onPick) {
8006
+ const prompts = suggestionPrompts(content);
8007
+ if (prompts === null) {
8008
+ return null;
8009
+ }
8010
+ const row = document.createElement("div");
8011
+ row.className = "suggestions";
8012
+ row.setAttribute("part", "suggestions");
8013
+ row.setAttribute("role", "group");
8014
+ row.setAttribute("aria-label", strings.suggestions);
8015
+ for (const prompt of prompts) {
8016
+ const chip = document.createElement("button");
8017
+ chip.type = "button";
8018
+ chip.className = "suggestion-chip";
8019
+ chip.setAttribute("part", "suggestion-chip");
8020
+ chip.textContent = prompt;
8021
+ chip.addEventListener("click", () => onPick(prompt));
8022
+ row.appendChild(chip);
8023
+ }
8024
+ return row;
8025
+ }
8026
+
7225
8027
  // src/ui/thoughts_block.ts
7226
8028
  var ThoughtsBlock = class {
7227
8029
  /** The block's root element; insert this at the top of the answer group. */
@@ -7288,6 +8090,7 @@ var ThreadDrawer = class {
7288
8090
  #heading;
7289
8091
  #newButton;
7290
8092
  #list;
8093
+ #formatRelativeTime = null;
7291
8094
  #strings;
7292
8095
  #threads = [];
7293
8096
  #activeId = "";
@@ -7335,6 +8138,21 @@ var ThreadDrawer = class {
7335
8138
  this.element.append(backdrop, this.#panel);
7336
8139
  }
7337
8140
  /** Re-localize the drawer's chrome and rows (the host calls this on connect). */
8141
+ /**
8142
+ * Replace the timestamp formatter, or restore the built-in with `null`.
8143
+ *
8144
+ * The built-in is deliberately locale-neutral -- there is no `Intl` anywhere
8145
+ * in this component, so it never disagrees with a host's own formatting by
8146
+ * guessing a locale. That is a defensible default and a poor requirement, so
8147
+ * this is the way out.
8148
+ */
8149
+ setRelativeTimeFormatter(format) {
8150
+ this.#formatRelativeTime = format;
8151
+ }
8152
+ /** This row's timestamp, through the host's formatter when it set one. */
8153
+ #formatTime(timestamp) {
8154
+ return this.#formatRelativeTime !== null ? this.#formatRelativeTime(timestamp) : relativeTime(timestamp, void 0, this.#strings);
8155
+ }
7338
8156
  setStrings(strings) {
7339
8157
  this.#strings = strings;
7340
8158
  this.#panel.setAttribute("aria-label", strings.chatHistory);
@@ -7434,7 +8252,7 @@ var ThreadDrawer = class {
7434
8252
  const time = document.createElement("span");
7435
8253
  time.className = "drawer-row-time";
7436
8254
  time.setAttribute("part", "drawer-row-time");
7437
- time.textContent = relativeTime(meta.updatedAt, void 0, this.#strings);
8255
+ time.textContent = this.#formatTime(meta.updatedAt);
7438
8256
  const preview = document.createElement("span");
7439
8257
  preview.className = "drawer-row-preview";
7440
8258
  preview.setAttribute("part", "drawer-row-preview");
@@ -7583,9 +8401,18 @@ var ToolCallCard = class {
7583
8401
  #resultLabel;
7584
8402
  #resultBody;
7585
8403
  #strings;
8404
+ /**
8405
+ * The arguments this call was made with.
8406
+ *
8407
+ * Retained rather than only rendered, because an approval interrupt names a
8408
+ * `toolCallId` and nothing else -- so this card is the only place the args
8409
+ * still exist when the user is asked to approve, edit or deny the call.
8410
+ */
8411
+ args;
7586
8412
  #settled = false;
7587
8413
  constructor(name, args, summary, strings = DEFAULT_UI_STRINGS) {
7588
8414
  this.#strings = strings;
8415
+ this.args = args;
7589
8416
  this.element = document.createElement("div");
7590
8417
  this.element.className = "tool-call";
7591
8418
  this.element.setAttribute("part", "tool-card");
@@ -7704,6 +8531,7 @@ var ToolCallCard = class {
7704
8531
  };
7705
8532
 
7706
8533
  // src/ui/voice_input.ts
8534
+ var MAX_RECORDING_MS = 12e4;
7707
8535
  var VoiceInput = class {
7708
8536
  /** The mic button; mount this in the composer. */
7709
8537
  element;
@@ -7714,6 +8542,8 @@ var VoiceInput = class {
7714
8542
  #recorder = null;
7715
8543
  #stream = null;
7716
8544
  #chunks = [];
8545
+ #capTimer = null;
8546
+ #hitCap = false;
7717
8547
  #disposed = false;
7718
8548
  constructor(options) {
7719
8549
  this.#transcribe = options.transcribe;
@@ -7753,6 +8583,7 @@ var VoiceInput = class {
7753
8583
  }
7754
8584
  this.#stream = stream;
7755
8585
  this.#chunks = [];
8586
+ this.#hitCap = false;
7756
8587
  const recorder = new MediaRecorder(stream);
7757
8588
  recorder.addEventListener("dataavailable", (event) => {
7758
8589
  this.#chunks.push(event.data);
@@ -7762,11 +8593,23 @@ var VoiceInput = class {
7762
8593
  });
7763
8594
  this.#recorder = recorder;
7764
8595
  recorder.start();
8596
+ this.#capTimer = setTimeout(() => {
8597
+ this.#hitCap = true;
8598
+ this.#stop();
8599
+ }, MAX_RECORDING_MS);
7765
8600
  this.#setState("recording");
7766
8601
  }
7767
8602
  #stop() {
8603
+ this.#clearCap();
7768
8604
  this.#recorder?.stop();
7769
8605
  }
8606
+ /** Drop the cap timer; recording is over, by whichever route. */
8607
+ #clearCap() {
8608
+ if (this.#capTimer !== null) {
8609
+ clearTimeout(this.#capTimer);
8610
+ this.#capTimer = null;
8611
+ }
8612
+ }
7770
8613
  /**
7771
8614
  * Tear the control down, for a host element removed mid-recording. Stops any
7772
8615
  * live `MediaRecorder`, releases the mic tracks so the browser's recording
@@ -7775,6 +8618,7 @@ var VoiceInput = class {
7775
8618
  */
7776
8619
  dispose() {
7777
8620
  this.#disposed = true;
8621
+ this.#clearCap();
7778
8622
  if (this.#recorder !== null && this.#recorder.state !== "inactive") {
7779
8623
  this.#recorder.stop();
7780
8624
  }
@@ -7791,6 +8635,12 @@ var VoiceInput = class {
7791
8635
  try {
7792
8636
  const text3 = await this.#transcribe(audio);
7793
8637
  this.#setState("idle");
8638
+ if (this.#hitCap) {
8639
+ this.element.title = this.#strings.recordingLimit.replace(
8640
+ "{n}",
8641
+ String(MAX_RECORDING_MS / 6e4)
8642
+ );
8643
+ }
7794
8644
  if (text3 !== "") {
7795
8645
  this.#onText(text3);
7796
8646
  }
@@ -7917,6 +8767,41 @@ var AgUiClient = class {
7917
8767
  this.#onPersist(this.#agent.messages);
7918
8768
  await this.#run();
7919
8769
  }
8770
+ /**
8771
+ * Drop everything after the most recent user message, so the same question
8772
+ * can be asked again.
8773
+ *
8774
+ * Returns the retained history, or `null` when there is nothing to retry (no
8775
+ * user message has been sent yet). **Truncates only** -- the caller re-renders
8776
+ * from the returned list and then calls {@link resume}, because the transcript
8777
+ * belongs to the element and a client that reached into it would own two
8778
+ * things. Running here instead would stream the new answer in underneath the
8779
+ * old one.
8780
+ *
8781
+ * Re-running answers the question the agent was last asked, rather than
8782
+ * telling it its answer was wrong, which is what makes the result a
8783
+ * *different* answer instead of a conversation about the previous one.
8784
+ *
8785
+ * **A retried turn re-runs its tools.** For a page-driving agent that is not
8786
+ * neutral: the previous attempt already clicked what it clicked, and this
8787
+ * does not undo it.
8788
+ */
8789
+ truncateToLastUser() {
8790
+ const messages = [...this.#agent.messages];
8791
+ let lastUser = -1;
8792
+ for (const [index, message] of messages.entries()) {
8793
+ if (message.role === "user") {
8794
+ lastUser = index;
8795
+ }
8796
+ }
8797
+ if (lastUser === -1) {
8798
+ return null;
8799
+ }
8800
+ const kept = messages.slice(0, lastUser + 1);
8801
+ this.#agent.setMessages(kept);
8802
+ this.#onPersist(this.#agent.messages);
8803
+ return kept;
8804
+ }
7920
8805
  /**
7921
8806
  * Resume the run loop after a navigating tool's result was supplied
7922
8807
  * post-reload (via {@link addToolResult}). Unlike {@link send}, adds no user
@@ -8083,6 +8968,12 @@ var AgUiClient = class {
8083
8968
  // Emitted after the client has written the patched messages, which is the
8084
8969
  // first moment the result exists. Only the ids marked above are looked at,
8085
8970
  // so an ordinary text delta does not walk the transcript.
8971
+ onCustomEvent({ event }) {
8972
+ h.onCustomEvent(event.name, event.value);
8973
+ },
8974
+ onMessagesSnapshotEvent({ event }) {
8975
+ h.onMessagesSnapshot(event.messages);
8976
+ },
8086
8977
  onMessagesChanged({ messages }) {
8087
8978
  if (pendingDeltas.size === 0) {
8088
8979
  return;
@@ -8104,6 +8995,16 @@ var AgUiClient = class {
8104
8995
  onReasoningMessageContentEvent({ reasoningMessageBuffer }) {
8105
8996
  h.onReasoningDelta(reasoningMessageBuffer);
8106
8997
  },
8998
+ // The delta callback reports the buffer as it stood *before* the announced
8999
+ // delta was appended, so on its own it always trails the stream by one and
9000
+ // renders nothing at all for a block that arrives as a single delta. The
9001
+ // answer text is spared that because its own end event carries the whole
9002
+ // message; this is the reasoning counterpart, and it has to be
9003
+ // REASONING_MESSAGE_END rather than REASONING_END, because only the former
9004
+ // carries a buffer.
9005
+ onReasoningMessageEndEvent({ reasoningMessageBuffer }) {
9006
+ h.onReasoningDelta(reasoningMessageBuffer);
9007
+ },
8107
9008
  onReasoningEndEvent() {
8108
9009
  h.onReasoningEnd();
8109
9010
  },
@@ -8159,12 +9060,68 @@ var MINTED_SUFFIX = "minted:";
8159
9060
  var TITLE_LIMIT = 60;
8160
9061
  var PREVIEW_LIMIT = 100;
8161
9062
  var DEFAULT_TITLE = "New conversation";
8162
- var SessionStorageStore = class {
9063
+ var writeFailureReported = false;
9064
+ function writeStoredItem(key, value) {
9065
+ try {
9066
+ sessionStorage.setItem(key, value);
9067
+ } catch {
9068
+ if (writeFailureReported) {
9069
+ return;
9070
+ }
9071
+ writeFailureReported = true;
9072
+ console.warn(
9073
+ "<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."
9074
+ );
9075
+ }
9076
+ }
9077
+ function rootFor(namespace) {
9078
+ return namespace === "" ? KEY_ROOT : `${KEY_ROOT}@${namespace}`;
9079
+ }
9080
+ var SessionStorageStore = class _SessionStorageStore {
8163
9081
  #root;
8164
9082
  constructor(namespace = "") {
8165
- this.#root = namespace === "" ? KEY_ROOT : `${KEY_ROOT}@${namespace}`;
9083
+ this.#root = rootFor(namespace);
8166
9084
  if (namespace !== "") {
8167
- this.#migrateLegacyKeys();
9085
+ _SessionStorageStore.adopt("", namespace);
9086
+ }
9087
+ }
9088
+ /**
9089
+ * Move every key a store owns out of `from`'s namespace and into `to`'s.
9090
+ *
9091
+ * Two callers, one move. The constructor adopts the pre-namespacing global
9092
+ * keys (`from` = `""`); `<ag-ui-chat>` adopts an element-scoped conversation
9093
+ * into a principal-scoped one the first time a `user-key` arrives, which is a
9094
+ * host naming the user who was already there rather than a handover.
9095
+ *
9096
+ * Only this store's own suffixes move — the element's `collapsed` / `size` /
9097
+ * `theme` keys share the global root and are deliberately left where they
9098
+ * are. A value already present at the destination wins: the destination is
9099
+ * the durable record and the source is the stray this move exists to clear.
9100
+ */
9101
+ static adopt(from, to) {
9102
+ const fromRoot = `${rootFor(from)}:`;
9103
+ const toRoot = `${rootFor(to)}:`;
9104
+ for (const [key, suffix] of ownedKeys(fromRoot)) {
9105
+ const value = sessionStorage.getItem(key);
9106
+ const destination = toRoot + suffix;
9107
+ if (value !== null && sessionStorage.getItem(destination) === null) {
9108
+ writeStoredItem(destination, value);
9109
+ }
9110
+ sessionStorage.removeItem(key);
9111
+ }
9112
+ }
9113
+ /**
9114
+ * Forget everything a store holds for `namespace`.
9115
+ *
9116
+ * The logout primitive: `<ag-ui-chat>` calls it when its `user-key` changes,
9117
+ * and a host driving its own store can call it from its own sign-out path.
9118
+ * Deliberately narrow — it removes only keys under this exact namespace whose
9119
+ * suffix parses as one this store writes, so it can never reach another
9120
+ * element's conversation or the host's own `sessionStorage` entries.
9121
+ */
9122
+ static purge(namespace) {
9123
+ for (const [key] of ownedKeys(`${rootFor(namespace)}:`)) {
9124
+ sessionStorage.removeItem(key);
8168
9125
  }
8169
9126
  }
8170
9127
  threadId() {
@@ -8172,8 +9129,8 @@ var SessionStorageStore = class {
8172
9129
  }
8173
9130
  newThread() {
8174
9131
  const id = randomUUID3();
8175
- sessionStorage.setItem(this.#key(THREAD_SUFFIX), id);
8176
- sessionStorage.setItem(this.#key(MINTED_SUFFIX + id), "1");
9132
+ writeStoredItem(this.#key(THREAD_SUFFIX), id);
9133
+ writeStoredItem(this.#key(MINTED_SUFFIX + id), "1");
8177
9134
  return id;
8178
9135
  }
8179
9136
  isUnsent(threadId) {
@@ -8183,7 +9140,7 @@ var SessionStorageStore = class {
8183
9140
  return Promise.resolve(this.#readJson(this.#key(MESSAGES_SUFFIX + threadId)));
8184
9141
  }
8185
9142
  saveMessages(threadId, messages) {
8186
- sessionStorage.setItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
9143
+ writeStoredItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
8187
9144
  sessionStorage.removeItem(this.#key(MINTED_SUFFIX + threadId));
8188
9145
  this.#touchThread(threadId, messages);
8189
9146
  }
@@ -8196,7 +9153,7 @@ var SessionStorageStore = class {
8196
9153
  sessionStorage.removeItem(key);
8197
9154
  return;
8198
9155
  }
8199
- sessionStorage.setItem(key, JSON.stringify(checkpoint));
9156
+ writeStoredItem(key, JSON.stringify(checkpoint));
8200
9157
  }
8201
9158
  clear(threadId) {
8202
9159
  sessionStorage.removeItem(this.#key(MESSAGES_SUFFIX + threadId));
@@ -8212,7 +9169,7 @@ var SessionStorageStore = class {
8212
9169
  return Promise.resolve(metas);
8213
9170
  }
8214
9171
  setActiveThread(threadId) {
8215
- sessionStorage.setItem(this.#key(THREAD_SUFFIX), threadId);
9172
+ writeStoredItem(this.#key(THREAD_SUFFIX), threadId);
8216
9173
  }
8217
9174
  renameThread(threadId, title) {
8218
9175
  const threads = this.#readThreads();
@@ -8256,40 +9213,12 @@ var SessionStorageStore = class {
8256
9213
  sessionStorage.removeItem(key);
8257
9214
  return;
8258
9215
  }
8259
- sessionStorage.setItem(key, JSON.stringify(threads));
9216
+ writeStoredItem(key, JSON.stringify(threads));
8260
9217
  }
8261
9218
  /** This store's fully-qualified key for a suffix (namespaced when set). */
8262
9219
  #key(suffix) {
8263
9220
  return `${this.#root}:${suffix}`;
8264
9221
  }
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
9222
  /** Parse a stored JSON value, returning `null` when absent or corrupt. */
8294
9223
  #readJson(key) {
8295
9224
  const raw = sessionStorage.getItem(key);
@@ -8303,8 +9232,22 @@ var SessionStorageStore = class {
8303
9232
  }
8304
9233
  }
8305
9234
  };
9235
+ function ownedKeys(root) {
9236
+ const found = [];
9237
+ for (let index = 0; index < sessionStorage.length; index += 1) {
9238
+ const key = sessionStorage.key(index);
9239
+ if (key === null || !key.startsWith(root)) {
9240
+ continue;
9241
+ }
9242
+ const suffix = key.slice(root.length);
9243
+ if (isOwnedSuffix(suffix)) {
9244
+ found.push([key, suffix]);
9245
+ }
9246
+ }
9247
+ return found;
9248
+ }
8306
9249
  function isOwnedSuffix(suffix) {
8307
- return suffix === THREAD_SUFFIX || suffix === THREADS_SUFFIX || suffix.startsWith(MESSAGES_SUFFIX) || suffix.startsWith(CHECKPOINT_SUFFIX);
9250
+ return suffix === THREAD_SUFFIX || suffix === THREADS_SUFFIX || suffix.startsWith(MESSAGES_SUFFIX) || suffix.startsWith(CHECKPOINT_SUFFIX) || suffix.startsWith(MINTED_SUFFIX);
8308
9251
  }
8309
9252
  function deriveTitle(messages) {
8310
9253
  for (const message of messages) {
@@ -8349,12 +9292,27 @@ function mintThread(store) {
8349
9292
  store.setActiveThread(id);
8350
9293
  return id;
8351
9294
  }
9295
+ function warnOnCrossOriginCredentials(url, credentialNames, trustedOrigins, warned) {
9296
+ if (credentialNames.length === 0) {
9297
+ return;
9298
+ }
9299
+ const destination = new URL(String(url), location.href).origin;
9300
+ if (destination === location.origin || trustedOrigins.includes(destination) || warned.has(destination)) {
9301
+ return;
9302
+ }
9303
+ warned.add(destination);
9304
+ console.warn(
9305
+ `<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.`
9306
+ );
9307
+ }
8352
9308
 
8353
9309
  // src/core/create_http_agent.ts
8354
9310
  function createHttpAgent(options) {
9311
+ const staticHeaders = options.headers ?? {};
9312
+ const warned = /* @__PURE__ */ new Set();
8355
9313
  return new HttpAgent({
8356
9314
  url: options.endpoint,
8357
- headers: options.headers ?? {},
9315
+ headers: staticHeaders,
8358
9316
  initialState: { ...options.initialState ?? {} },
8359
9317
  // HttpAgent invokes its configured fetch as a method (`this.fetch(...)`),
8360
9318
  // rebinding the global `fetch` to the agent instance — "Illegal invocation"
@@ -8363,6 +9321,10 @@ function createHttpAgent(options) {
8363
9321
  // own config having no seam for either.
8364
9322
  fetch: (url, init) => {
8365
9323
  const fresh = options.getHeaders?.();
9324
+ const credentialNames = [
9325
+ .../* @__PURE__ */ new Set([...Object.keys(staticHeaders), ...Object.keys(fresh ?? {})])
9326
+ ].sort();
9327
+ warnOnCrossOriginCredentials(url, credentialNames, options.trustedOrigins ?? [], warned);
8366
9328
  if (fresh === void 0) {
8367
9329
  return fetch(url, withCredentials(init, options.credentials));
8368
9330
  }
@@ -8385,13 +9347,34 @@ var RemoteConversationStore = class {
8385
9347
  #headers;
8386
9348
  #local;
8387
9349
  #credentials;
9350
+ #cacheMessages;
8388
9351
  #dropped = /* @__PURE__ */ new Set();
8389
9352
  #renamed = /* @__PURE__ */ new Map();
8390
- constructor(url, headers = () => ({}), local = new SessionStorageStore(), credentials = () => void 0) {
9353
+ /**
9354
+ * @param cacheMessages Whether to mirror message bodies into the local store.
9355
+ *
9356
+ * `true` (the default, and the behaviour this class has always had) keeps a
9357
+ * local copy of every turn, so the transcript still replays when the thread
9358
+ * endpoint is unreachable. `false` is for the deployment that chose a
9359
+ * server-backed store precisely so transcripts do not sit in the browser:
9360
+ * regulated content, a shared workstation, an operator who has to be able to
9361
+ * say where the conversation lives. It is not the same as passing a local
9362
+ * store that does nothing — the local store also owns the active thread id,
9363
+ * the navigation checkpoint and the "nothing sent here yet" marker, all of
9364
+ * which must keep working — so the opt-out is scoped to the bodies alone.
9365
+ *
9366
+ * The cost is deliberate and worth stating: with no local copy there is
9367
+ * nothing to fall back to, so a failed request shows an empty transcript
9368
+ * rather than a stale one, and the drawer's offline list loses its previews
9369
+ * (a preview is an excerpt of a message, which is the very thing being kept
9370
+ * off the client).
9371
+ */
9372
+ constructor(url, headers = () => ({}), local = new SessionStorageStore(), credentials = () => void 0, cacheMessages = true) {
8391
9373
  this.#url = url.endsWith("/") ? url : `${url}/`;
8392
9374
  this.#headers = headers;
8393
9375
  this.#local = local;
8394
9376
  this.#credentials = credentials;
9377
+ this.#cacheMessages = cacheMessages;
8395
9378
  }
8396
9379
  threadId() {
8397
9380
  return this.#local.threadId();
@@ -8412,7 +9395,7 @@ var RemoteConversationStore = class {
8412
9395
  return this.#local.isUnsent?.(threadId) === true;
8413
9396
  }
8414
9397
  saveMessages(threadId, messages) {
8415
- this.#local.saveMessages(threadId, messages);
9398
+ this.#local.saveMessages(threadId, this.#cacheMessages ? messages : []);
8416
9399
  }
8417
9400
  loadCheckpoint(threadId) {
8418
9401
  return this.#local.loadCheckpoint(threadId);
@@ -8677,6 +9660,7 @@ var CONNECT_TIME_ATTRIBUTES = [
8677
9660
  "data-attachment-max-bytes",
8678
9661
  "data-transcribe-url",
8679
9662
  "data-threads-url",
9663
+ "data-threads-cache",
8680
9664
  "data-tools-url",
8681
9665
  "data-skills-url",
8682
9666
  "data-skills",
@@ -8693,6 +9677,8 @@ function isCredentialsMode(value) {
8693
9677
  var COLLAPSED_KEY = "ag-ui-chat:collapsed";
8694
9678
  var SIZE_KEY = "ag-ui-chat:size";
8695
9679
  var THEME_KEY = "ag-ui-chat:theme";
9680
+ var QUOTE_GAP = 6;
9681
+ var CLAIMED_NAMESPACES = /* @__PURE__ */ new Set();
8696
9682
  var AgUiChat = class extends HTMLElement {
8697
9683
  /** Agent factory; override to inject a custom or fake agent (tests). */
8698
9684
  agentFactory = createHttpAgent;
@@ -8716,6 +9702,22 @@ var AgUiChat = class extends HTMLElement {
8716
9702
  * `Authorization` are configured independently and neither drops the other.
8717
9703
  */
8718
9704
  getHeaders = null;
9705
+ /**
9706
+ * Origins, besides the page's own, that this element may send {@link headers}
9707
+ * and {@link getHeaders} credentials to without saying so on the console.
9708
+ *
9709
+ * Seven attributes name a URL, and every one of them carries these headers.
9710
+ * They are plain HTML, so a page that builds one from a query parameter or
9711
+ * from tenant-authored configuration has handed an attacker the destination,
9712
+ * and the token leaves on the element's first request. Naming the origins you
9713
+ * expect turns that from silent into either confirmed or reported.
9714
+ *
9715
+ * A notice rather than a refusal: a cross-origin agent is a documented
9716
+ * deployment, so refusing would break working installations to defend against
9717
+ * a page that is already interpolating untrusted data into its own markup.
9718
+ * Leaving this empty costs nothing but one console line per foreign origin.
9719
+ */
9720
+ trustedOrigins = [];
8719
9721
  /**
8720
9722
  * Permit `<img>` in rendered assistant markdown. **Off by default**: a
8721
9723
  * model-controlled image URL is fetched with no user interaction, which
@@ -8723,6 +9725,22 @@ var AgUiChat = class extends HTMLElement {
8723
9725
  * data. Enable only when the content source is trusted.
8724
9726
  */
8725
9727
  allowImages = false;
9728
+ /**
9729
+ * Replace the relative timestamps in the thread drawer and the checkpoint
9730
+ * panel -- `"5m ago"`, `"2d ago"` -- with the host's own formatting.
9731
+ *
9732
+ * The built-in is locale-neutral on purpose: there is no `Intl` anywhere in
9733
+ * this component, so it never disagrees with the page it is embedded in by
9734
+ * guessing a locale. That is a good default and a bad requirement, which is
9735
+ * what this is for.
9736
+ *
9737
+ * ```js
9738
+ * const rtf = new Intl.RelativeTimeFormat("de", { numeric: "auto" });
9739
+ * chat.formatRelativeTime = (ts) =>
9740
+ * rtf.format(Math.round((ts - Date.now()) / 60000), "minute");
9741
+ * ```
9742
+ */
9743
+ formatRelativeTime = null;
8726
9744
  /** When true, destructive tools execute without a confirmation modal. */
8727
9745
  autoConfirm = false;
8728
9746
  /**
@@ -8746,6 +9764,22 @@ var AgUiChat = class extends HTMLElement {
8746
9764
  * is enabled server-side; this only changes how the decision is collected.
8747
9765
  */
8748
9766
  approvalRenderer = null;
9767
+ /**
9768
+ * Let the user edit a gated call's arguments before approving it.
9769
+ *
9770
+ * Off by default and **an assertion about your server**, not a negotiation:
9771
+ * AG-UI carries `editedArgs` in the resume payload and gates it on the
9772
+ * agent's own `approveWithEdits` capability, which this component never sees
9773
+ * -- capabilities are not on the wire it reads. So the host says whether its
9774
+ * agent honours them. Turned on against a server that does not, the user
9775
+ * would edit arguments it silently discards, which is worse than not
9776
+ * offering.
9777
+ *
9778
+ * Only affects calls whose arguments are known here: an interrupt names a
9779
+ * `toolCallId`, and the tool card for that call is where the arguments still
9780
+ * are. An interrupt naming no card gets the plain approve/deny.
9781
+ */
9782
+ approveWithEdits = false;
8749
9783
  /**
8750
9784
  * Optional per-call confirmation predicate. When set it is authoritative,
8751
9785
  * deciding from the tool name and args whether this particular call needs
@@ -8851,13 +9885,37 @@ var AgUiChat = class extends HTMLElement {
8851
9885
  */
8852
9886
  resolvePageTarget = (target) => document.querySelector(target);
8853
9887
  /**
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.
9888
+ * The server tool catalog fetched from `data-tools-url`, keyed by tool
9889
+ * name. Cards label themselves from each entry's `summary`, the base
9890
+ * layer behind {@link toolSummaries}: an explicit entry in `toolSummaries`
9891
+ * wins, this fills the rest. Held as whole entries rather than labels so a
9892
+ * field the server sent is not lost on the way in. Populated once on connect.
8857
9893
  */
8858
9894
  #toolCatalog = {};
9895
+ /**
9896
+ * Foreign origins already reported, so the notice is once per origin per
9897
+ * element rather than once per request. Per-element rather than module-level,
9898
+ * because two elements on one page are two separate configurations.
9899
+ */
9900
+ #warnedOrigins = /* @__PURE__ */ new Set();
8859
9901
  /** The resolved string table (defaults ← `data-strings` ← `strings`). */
8860
9902
  #strings = DEFAULT_UI_STRINGS;
9903
+ /**
9904
+ * The tool names the current round handed the agent, captured as the catalog
9905
+ * went out.
9906
+ *
9907
+ * The registry is mount-wide but {@link getTools} is per-run, so a host is
9908
+ * free to scope what a given page offers — and a call naming a tool this run
9909
+ * withheld must not reach the handler that is merely still registered.
9910
+ * Snapshotted rather than re-asked at dispatch: a provider is a function, and
9911
+ * calling it again asks a question the run already answered, which is exactly
9912
+ * the window a scoped catalog exists to close.
9913
+ *
9914
+ * Empty until the first round advertises, which cannot precede a call: the
9915
+ * client builds `RunAgentInput.tools` at the top of every round, before the
9916
+ * calls that round produces are executed.
9917
+ */
9918
+ #advertisedTools = /* @__PURE__ */ new Set();
8861
9919
  #toolRegistry = new ClientToolRegistry();
8862
9920
  /** Tool-call cards awaiting execution, keyed by call id. */
8863
9921
  #toolCards = /* @__PURE__ */ new Map();
@@ -8867,7 +9925,16 @@ var AgUiChat = class extends HTMLElement {
8867
9925
  * the real output with the generic "executed on the server" fallback.
8868
9926
  */
8869
9927
  /** Whether a server-pushed chart activity is drawn. Off unless asked for. */
8870
- #chartActivity = false;
9928
+ /**
9929
+ * Which `activity_type`s this element can draw, by name.
9930
+ *
9931
+ * A registry rather than a branch because `activity_type` is an open string
9932
+ * the protocol does not enumerate. The two built-ins go through it like any
9933
+ * host registration, which is the test that the seam is real.
9934
+ */
9935
+ #activityRenderers = /* @__PURE__ */ new Map();
9936
+ /** Types that arrived with nobody registered to draw them. See {@link unhandledActivityTypes}. */
9937
+ #unhandledActivityTypes = /* @__PURE__ */ new Set();
8871
9938
  /** Card elements by call id, so a rendering handler can find its own card. */
8872
9939
  #cardElements = /* @__PURE__ */ new Map();
8873
9940
  /** Chart blocks by activity message id, so an update redraws in place. */
@@ -8879,7 +9946,75 @@ var AgUiChat = class extends HTMLElement {
8879
9946
  * Spans tool rounds and an approval interrupt; cleared when the event fires.
8880
9947
  */
8881
9948
  #runTools = [];
9949
+ /**
9950
+ * Keys announced during this interaction, de-duplicated in first-seen order.
9951
+ *
9952
+ * Per element, never module-level: a second mounted chat is a second run, and
9953
+ * sharing this would tell one page to refetch on the other's writes. Reset by
9954
+ * {@link AgUiChat.#dispatchRunFinished}, which is the one place that has read
9955
+ * it.
9956
+ */
9957
+ /**
9958
+ * Tool names the user waived confirmation for, for the life of this element.
9959
+ *
9960
+ * Per instance and never persisted: a session decision that outlived the tab
9961
+ * would be a permanent grant made by one click, which is the thing
9962
+ * `autoConfirm` already exists to say deliberately. Cleared with the element.
9963
+ */
9964
+ #sessionApproved = /* @__PURE__ */ new Set();
9965
+ /**
9966
+ * The one action row currently carrying Retry, if any.
9967
+ *
9968
+ * Retry belongs to the **last** turn only: re-running an older one is
9969
+ * branching, and for a page-driving agent editing a past turn is not neutral
9970
+ * -- those turns clicked buttons, and re-running turn 3 does not un-save what
9971
+ * turn 5 saved. Holding a single owner is what keeps exactly one offer on
9972
+ * screen without per-bubble bookkeeping.
9973
+ */
9974
+ #retryOwner = null;
9975
+ #runInvalidated = /* @__PURE__ */ new Set();
8882
9976
  #root;
9977
+ /** Screen-reader-only status region -- see {@link AgUiChat.#announce}. */
9978
+ #announcer = document.createElement("div");
9979
+ /** Return-to-foot affordance, shown only once something has been missed. */
9980
+ #jumpButton = document.createElement("button");
9981
+ /**
9982
+ * Offer to quote the current selection, floated beside it.
9983
+ *
9984
+ * Shares {@link AgUiChat.#messagesWrap} with the jump button for the same
9985
+ * reason: it is positioned against the transcript, and must not scroll away
9986
+ * with the words it is pointing at.
9987
+ */
9988
+ #quoteButton = document.createElement("button");
9989
+ /** What {@link AgUiChat.#quoteButton} would quote, while it is showing. */
9990
+ #quoting = "";
9991
+ /** The host-page offer, while one is attached; see {@link AgUiChat.offerQuoteInPage}. */
9992
+ #pageQuote = null;
9993
+ /**
9994
+ * Positioning context for {@link AgUiChat.#jumpButton}.
9995
+ *
9996
+ * The button cannot live in the scrolling list -- it would scroll away with
9997
+ * the content it is offering to scroll to -- and it cannot be positioned
9998
+ * against the panel either: the panel's foot is below the composer, the skill
9999
+ * chips and the footer, so `bottom` measured from there lands the button on
10000
+ * top of the composer rather than over the transcript. This wrapper is the
10001
+ * only box whose foot *is* the transcript's foot.
10002
+ */
10003
+ #messagesWrap = document.createElement("div");
10004
+ /** Follows the foot of the transcript, and stops when the reader scrolls away. */
10005
+ #scroller;
10006
+ /** Pending clear of {@link AgUiChat.#announcer}; see why it is cleared at all. */
10007
+ #announceTimer = null;
10008
+ /**
10009
+ * Whether this turn already announced how it ended.
10010
+ *
10011
+ * `onSettled` is the terminal guarantee and fires however the run ended, so
10012
+ * it is the only place that can promise the user hears *something*. But a
10013
+ * stopped or failed run has already said the truer thing from `onCancelled`
10014
+ * or `onError`, and "assistant answered" after "response stopped" is worse
10015
+ * than silence.
10016
+ */
10017
+ #announcedOutcome = false;
8883
10018
  #chat;
8884
10019
  #messages;
8885
10020
  #input;
@@ -8935,6 +10070,12 @@ var AgUiChat = class extends HTMLElement {
8935
10070
  // revealed progressively as it streamed, so the word reveal must not re-animate
8936
10071
  // it; ≤1 ⇒ it arrived at once and the word reveal is appropriate.
8937
10072
  #streamDeltas = 0;
10073
+ // The accumulated answer the next render will draw. Deltas overwrite it
10074
+ // (each one carries the whole answer), so a frame always draws the latest.
10075
+ #streamBuffer = "";
10076
+ // The frame that render is queued on, or `null` when nothing is queued —
10077
+ // also the flag saying a delta is still undrawn.
10078
+ #streamFrame = null;
8938
10079
  #pending = null;
8939
10080
  // The current assistant turn's grouping container. One `.answer`
8940
10081
  // wraps everything a single answer produces — streamed text, tool cards, the
@@ -8949,9 +10090,23 @@ var AgUiChat = class extends HTMLElement {
8949
10090
  #thoughts = null;
8950
10091
  #threadId = "";
8951
10092
  // 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.
10093
+ // size), so two instances on one origin don't clobber each other. Empty ⇒ the
10094
+ // pre-namespacing global keys (back-compat). Resolved on connect; the
10095
+ // conversation adds `user-key` on top of it, see #conversationNs.
8954
10096
  #storageNs = "";
10097
+ // The entry this element put in CLAIMED_NAMESPACES, to take back out on
10098
+ // disconnect. `null` when it claimed nothing (no id, no endpoint, or it lost
10099
+ // the claim to an element that mounted first).
10100
+ #claimedNs = null;
10101
+ // The fallback namespace minted when the preferred one was already claimed,
10102
+ // with the preferred value it was minted for — so the element keeps it across
10103
+ // remounts, but re-resolves if the host answers the warning with an `id`.
10104
+ #generatedNs = "";
10105
+ #generatedFor = "";
10106
+ // The `sessionStorage`-backed store, which the element may therefore re-scope
10107
+ // on a principal change. `null` when the host injected a store of its own
10108
+ // kind, whose keying the element does not know and must not guess at.
10109
+ #builtinStore = null;
8955
10110
  // Bumped on every #rehydrate; a replay whose generation is stale (a newer
8956
10111
  // thread switch started while it awaited a slow store) drops its result.
8957
10112
  #rehydrateGeneration = 0;
@@ -8977,6 +10132,23 @@ var AgUiChat = class extends HTMLElement {
8977
10132
  this.#launcher = document.createElement("button");
8978
10133
  this.#badge = document.createElement("span");
8979
10134
  this.#emptyWrap = document.createElement("div");
10135
+ this.registerActivityRenderer({
10136
+ type: COMPACTION_ACTIVITY_TYPE,
10137
+ render: (content) => {
10138
+ const removed = compactionRemoved(content);
10139
+ return removed === null ? null : renderRunNotice(
10140
+ "\u{1F5DC}",
10141
+ this.#strings.historyCompacted.replace("{count}", String(removed)),
10142
+ "compaction"
10143
+ );
10144
+ }
10145
+ });
10146
+ this.registerActivityRenderer({
10147
+ type: SUGGESTIONS_ACTIVITY_TYPE,
10148
+ render: (content) => renderSuggestionChips(content, this.#strings, (prompt) => {
10149
+ void this.sendMessage(prompt);
10150
+ })
10151
+ });
8980
10152
  this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
8981
10153
  this.#drawer = new ThreadDrawer({
8982
10154
  onSelect: (threadId) => {
@@ -9007,7 +10179,7 @@ var AgUiChat = class extends HTMLElement {
9007
10179
  if (this.#runIndex === null) {
9008
10180
  this.#runIndex = new RunIndex(
9009
10181
  url,
9010
- () => this.#requestHeaders(),
10182
+ () => this.#headersFor(url),
9011
10183
  () => this.#requestCredentials()
9012
10184
  );
9013
10185
  }
@@ -9041,6 +10213,7 @@ var AgUiChat = class extends HTMLElement {
9041
10213
  endpoint,
9042
10214
  headers: this.#requestHeaders(),
9043
10215
  getHeaders: () => this.#requestHeaders(),
10216
+ trustedOrigins: this.trustedOrigins,
9044
10217
  ...this.#credentialsOption(),
9045
10218
  threadId: this.#threadId,
9046
10219
  // The seed the endpoints assume: nothing. The snapshot is the history.
@@ -9049,7 +10222,7 @@ var AgUiChat = class extends HTMLElement {
9049
10222
  const client = new AgUiClient({
9050
10223
  agent,
9051
10224
  handlers: this.#handlers(),
9052
- getTools: () => this.getTools(),
10225
+ getTools: () => this.#advertiseTools(),
9053
10226
  getContext: () => this.#buildContext(),
9054
10227
  executeTool: (call) => this.#executeTool(call),
9055
10228
  resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
@@ -9060,11 +10233,12 @@ var AgUiChat = class extends HTMLElement {
9060
10233
  /** Load the checkpoint panel with the runs that can actually be continued. */
9061
10234
  async #refreshCheckpoints() {
9062
10235
  const index = this.#runs();
10236
+ this.#checkpoints.setRelativeTimeFormatter(this.formatRelativeTime);
9063
10237
  this.#checkpoints.setRuns(index === null ? [] : await index.continuable());
9064
10238
  }
9065
10239
  /** Attributes the element reacts to after it has been connected. */
9066
10240
  static get observedAttributes() {
9067
- return ["title-text", "placement", "credentials", ...CONNECT_TIME_ATTRIBUTES];
10241
+ return ["title-text", "placement", "credentials", "user-key", ...CONNECT_TIME_ATTRIBUTES];
9068
10242
  }
9069
10243
  attributeChangedCallback(name, previous, value) {
9070
10244
  if (name === "credentials") {
@@ -9084,6 +10258,12 @@ var AgUiChat = class extends HTMLElement {
9084
10258
  this.#title.textContent = value ?? this.#strings.title;
9085
10259
  return;
9086
10260
  }
10261
+ if (name === "user-key") {
10262
+ if (this.#connected && (previous ?? "") !== (value ?? "")) {
10263
+ this.#changePrincipal(previous ?? "", value ?? "");
10264
+ }
10265
+ return;
10266
+ }
9087
10267
  if (previous === value || !this.#connected) {
9088
10268
  return;
9089
10269
  }
@@ -9091,7 +10271,18 @@ var AgUiChat = class extends HTMLElement {
9091
10271
  `<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
10272
  );
9093
10273
  }
9094
- /** Declare a frontend tool the agent may call. */
10274
+ /**
10275
+ * Declare a frontend tool the agent may call.
10276
+ *
10277
+ * **A handler's thrown message leaves the browser.** When a handler rejects,
10278
+ * its `Error.message` is posted back as that call's tool result — into the
10279
+ * conversation, on to the AG-UI endpoint, persisted server-side, and
10280
+ * forwarded to the model provider on every later round. That is deliberate,
10281
+ * since it is what lets the agent recover from a failure it caused; but it
10282
+ * means an internal hostname, a signed URL or a stack-derived path in a
10283
+ * rethrown error is disclosed to parties the host never chose. Throw the
10284
+ * message you would be content for the model to read, and log the detail.
10285
+ */
9095
10286
  registerTool(tool) {
9096
10287
  this.#toolRegistry.register(tool);
9097
10288
  }
@@ -9239,9 +10430,21 @@ var AgUiChat = class extends HTMLElement {
9239
10430
  });
9240
10431
  this.#confirmAbort = null;
9241
10432
  this.#updateEmptyState();
9242
- this.#messages.scrollTop = this.#messages.scrollHeight;
10433
+ this.#scroller.follow();
9243
10434
  return answer;
9244
10435
  }
10436
+ /**
10437
+ * The catalog for the round about to start, remembering what it offered.
10438
+ *
10439
+ * Every path to a frontend tool goes through here first — the client asks
10440
+ * for `RunAgentInput.tools` at the top of each round — so this is the one
10441
+ * place that can know what the agent was actually told about.
10442
+ */
10443
+ #advertiseTools() {
10444
+ const tools = this.getTools();
10445
+ this.#advertisedTools = new Set(tools.map((tool) => tool.name));
10446
+ return tools;
10447
+ }
9245
10448
  /** Resolve a tool by name: built-in tools first, then the registry. */
9246
10449
  #resolveTool(name) {
9247
10450
  const builtin = this.#builtinTools().find((t) => t.name === name);
@@ -9260,6 +10463,37 @@ var AgUiChat = class extends HTMLElement {
9260
10463
  set endpoint(value) {
9261
10464
  this.setAttribute("endpoint", value);
9262
10465
  }
10466
+ /**
10467
+ * Who the stored conversation belongs to, from the `user-key` attribute.
10468
+ *
10469
+ * Set it to whatever identifies the signed-in principal — a user id, an
10470
+ * account id, a hash of one. The value joins the storage namespace, so two
10471
+ * principals in the same tab cannot read each other's transcript, and
10472
+ * **changing it purges what the previous one left behind**.
10473
+ *
10474
+ * That purge is the reason this is a live attribute rather than a
10475
+ * connect-time one. `sessionStorage` survives same-tab navigation, so it
10476
+ * survives a logout; and a single-page app signs out through its own router
10477
+ * without remounting anything, so there is no other moment at which the
10478
+ * element could find out. The host naming the new principal — or dropping the
10479
+ * attribute — is the signal.
10480
+ *
10481
+ * Absent means exactly today's behaviour, which is why nothing breaks by
10482
+ * leaving it off: the conversation is scoped to the element and to nobody in
10483
+ * particular, and on a shared workstation it carries into whoever signs in
10484
+ * next in the same tab.
10485
+ *
10486
+ * The first value to arrive is treated as a host naming the user who was
10487
+ * already there, not as a handover: the conversation in progress moves into
10488
+ * the principal's namespace rather than being destroyed, so an element
10489
+ * configured by an async auth handshake keeps what is on screen.
10490
+ */
10491
+ get userKey() {
10492
+ return this.getAttribute("user-key") ?? "";
10493
+ }
10494
+ set userKey(value) {
10495
+ this.setAttribute("user-key", value);
10496
+ }
9263
10497
  /**
9264
10498
  * Cookie policy for **every** request this element makes, as `fetch`'s own
9265
10499
  * `credentials` mode (`"omit"` / `"same-origin"` / `"include"`). Mirrored to
@@ -9304,6 +10538,24 @@ var AgUiChat = class extends HTMLElement {
9304
10538
  #requestHeaders() {
9305
10539
  return { ...this.headers, ...this.getHeaders?.() };
9306
10540
  }
10541
+ /**
10542
+ * The request headers, having first reported the destination if it is foreign.
10543
+ *
10544
+ * Every caller that sends these headers knows its URL, and `#requestHeaders`
10545
+ * does not -- so the check lives here, on the path that has both, rather than
10546
+ * being repeated at each call site with a chance to be forgotten at the next
10547
+ * one added.
10548
+ */
10549
+ #headersFor(url) {
10550
+ const headers = this.#requestHeaders();
10551
+ warnOnCrossOriginCredentials(
10552
+ url,
10553
+ Object.keys(headers),
10554
+ this.trustedOrigins,
10555
+ this.#warnedOrigins
10556
+ );
10557
+ return headers;
10558
+ }
9307
10559
  /** The configured cookie policy as `fetch` spells it; `undefined` when unset. */
9308
10560
  #requestCredentials() {
9309
10561
  return this.credentials ?? void 0;
@@ -9322,8 +10574,8 @@ var AgUiChat = class extends HTMLElement {
9322
10574
  return credentials === void 0 ? {} : { credentials };
9323
10575
  }
9324
10576
  /** The `fetch` init for the element's own plain GETs (catalogs). */
9325
- #fetchInit() {
9326
- return withCredentials({ headers: this.#requestHeaders() }, this.#requestCredentials());
10577
+ #fetchInit(url) {
10578
+ return withCredentials({ headers: this.#headersFor(url) }, this.#requestCredentials());
9327
10579
  }
9328
10580
  /**
9329
10581
  * How much detail tool-call cards show, from the `data-tool-display`
@@ -9344,7 +10596,7 @@ var AgUiChat = class extends HTMLElement {
9344
10596
  this.setAttribute("data-tool-display", value);
9345
10597
  }
9346
10598
  connectedCallback() {
9347
- this.#storageNs = this.id !== "" ? this.id : this.endpoint;
10599
+ this.#storageNs = this.#claimNamespace();
9348
10600
  this.#applySize(this.#readSize());
9349
10601
  requestAnimationFrame(() => this.#syncResizeAnchor());
9350
10602
  this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
@@ -9362,8 +10614,10 @@ var AgUiChat = class extends HTMLElement {
9362
10614
  }
9363
10615
  this.#syncLauncher();
9364
10616
  this.#initSkills();
9365
- if (this.#storageNs !== "" && this.conversationStore instanceof SessionStorageStore) {
9366
- this.conversationStore = new SessionStorageStore(this.#storageNs);
10617
+ if (this.conversationStore instanceof SessionStorageStore) {
10618
+ const namespace = this.#conversationNs();
10619
+ this.#builtinStore = namespace === "" ? this.conversationStore : new SessionStorageStore(namespace);
10620
+ this.conversationStore = this.#builtinStore;
9367
10621
  }
9368
10622
  this.#wireThreadStore();
9369
10623
  this.#wireAttachments();
@@ -9427,9 +10681,20 @@ var AgUiChat = class extends HTMLElement {
9427
10681
  */
9428
10682
  disconnectedCallback() {
9429
10683
  this.#connected = false;
10684
+ if (this.#claimedNs !== null) {
10685
+ CLAIMED_NAMESPACES.delete(this.#claimedNs);
10686
+ this.#claimedNs = null;
10687
+ }
9430
10688
  this.#cancelRun();
10689
+ this.#pageQuote?.detach();
10690
+ this.#pageQuote = null;
9431
10691
  this.#attachTray?.dispose();
9432
10692
  this.#voice?.dispose();
10693
+ this.#scroller.dispose();
10694
+ if (this.#announceTimer !== null) {
10695
+ clearTimeout(this.#announceTimer);
10696
+ this.#announceTimer = null;
10697
+ }
9433
10698
  }
9434
10699
  /**
9435
10700
  * Read an opt-in flag attribute the way HTML reads a boolean attribute.
@@ -9496,7 +10761,7 @@ var AgUiChat = class extends HTMLElement {
9496
10761
  }
9497
10762
  return (file, onProgress, signal) => uploadAttachment(file, {
9498
10763
  url,
9499
- headers: this.#requestHeaders(),
10764
+ headers: this.#headersFor(url),
9500
10765
  ...this.#credentialsOption(),
9501
10766
  onProgress,
9502
10767
  signal
@@ -9529,7 +10794,7 @@ var AgUiChat = class extends HTMLElement {
9529
10794
  }
9530
10795
  return (audio) => transcribeAudio(audio, {
9531
10796
  url,
9532
- headers: this.#requestHeaders(),
10797
+ headers: this.#headersFor(url),
9533
10798
  ...this.#credentialsOption()
9534
10799
  });
9535
10800
  }
@@ -9540,6 +10805,111 @@ var AgUiChat = class extends HTMLElement {
9540
10805
  this.#onInput();
9541
10806
  this.#input.focus();
9542
10807
  }
10808
+ /**
10809
+ * Put `text` into the composer as a markdown quotation, and focus it.
10810
+ *
10811
+ * Deliberately **not** a send. Quoting is how a question narrows to one part
10812
+ * of an answer, so the quotation is the preamble and the question is what
10813
+ * comes next -- the caret is left after it, on its own line.
10814
+ *
10815
+ * This is also the seam for the half of this feature the component cannot
10816
+ * build: selection in the **host page**. A widget mounted beside a table can
10817
+ * be asked about a row, and nothing in a chat's own transcript can offer
10818
+ * that. A host reads its own selection, however it likes, and calls this.
10819
+ *
10820
+ * No-ops on text that is empty or only whitespace.
10821
+ */
10822
+ quote(text3) {
10823
+ const quoted = asQuote(text3);
10824
+ if (quoted === "") {
10825
+ return;
10826
+ }
10827
+ const current = this.#input.value.replace(/\s+$/, "");
10828
+ this.#input.value = current === "" ? quoted : `${current}
10829
+
10830
+ ${quoted}`;
10831
+ this.#autoGrow();
10832
+ this.#input.focus();
10833
+ const end = this.#input.value.length;
10834
+ this.#input.setSelectionRange(end, end);
10835
+ }
10836
+ /**
10837
+ * Offer to quote what the user selects in the **host page**, not just in the
10838
+ * transcript. Returns a function that stops offering.
10839
+ *
10840
+ * The same select-then-offer gesture, over a table, a diff, a report -- the
10841
+ * surface the user actually works in, which is the half of quoting no hosted
10842
+ * chat can reach. Opt-in, because it listens on the host's document and that
10843
+ * is theirs to grant.
10844
+ *
10845
+ * Deliberately **not** a four-line recipe, which is how this shipped first
10846
+ * and was wrong: a page listener that quotes every settled selection appends
10847
+ * to the composer on every drag made to read, to copy or to fix a typo -- and
10848
+ * it cannot tell a selection in the page's prose from one inside the user's
10849
+ * own half-typed `<input>`, because Chrome reports a field's internal
10850
+ * selection as an ordinary range over the field's *wrapper*. See
10851
+ * {@link attachQuoteOffer} for the guards.
10852
+ *
10853
+ * Detached automatically when the element leaves the document; a host that
10854
+ * re-mounts it calls this again.
10855
+ */
10856
+ offerQuoteInPage(within = document.body) {
10857
+ this.#pageQuote?.detach();
10858
+ const offer = attachQuoteOffer({
10859
+ within,
10860
+ label: this.#strings.quoteSelection,
10861
+ exclude: this,
10862
+ onQuote: (text3) => this.quote(text3)
10863
+ });
10864
+ this.#pageQuote = offer;
10865
+ return () => {
10866
+ offer.detach();
10867
+ if (this.#pageQuote === offer) {
10868
+ this.#pageQuote = null;
10869
+ }
10870
+ };
10871
+ }
10872
+ /** Whether the transcript offers to quote what the user selects. */
10873
+ #quoteEnabled() {
10874
+ return this.getAttribute("data-quote-selection") !== "false";
10875
+ }
10876
+ /**
10877
+ * Offer to quote the settled selection, or retire the offer.
10878
+ *
10879
+ * `event` is passed for its coordinates and only those: they say which line
10880
+ * of a selection spanning several messages the offer should hang from. A
10881
+ * keyboard selection has none, and the first line is used instead.
10882
+ */
10883
+ #onSelectionSettled(event) {
10884
+ if (!this.#quoteEnabled()) {
10885
+ return;
10886
+ }
10887
+ const near = event === void 0 ? void 0 : { x: event.clientX, y: event.clientY };
10888
+ const selected = quotableSelection(this.#messages, [this.#root], near);
10889
+ if (selected === null) {
10890
+ this.#hideQuote();
10891
+ return;
10892
+ }
10893
+ this.#quoting = selected.text;
10894
+ this.#placeQuote(selected.rect);
10895
+ }
10896
+ /** Float the offer beside `rect`, kept inside the transcript's own box. */
10897
+ #placeQuote(rect) {
10898
+ this.#quoteButton.hidden = false;
10899
+ const wrap = this.#messagesWrap.getBoundingClientRect();
10900
+ const top = rect.top - wrap.top;
10901
+ const below = top < QUOTE_GAP + this.#quoteButton.offsetHeight;
10902
+ this.#quoteButton.dataset["below"] = String(below);
10903
+ this.#quoteButton.style.top = `${below ? rect.bottom - wrap.top + QUOTE_GAP : top - QUOTE_GAP}px`;
10904
+ const half = this.#quoteButton.offsetWidth / 2;
10905
+ const centre = rect.left + rect.width / 2 - wrap.left;
10906
+ this.#quoteButton.style.left = `${Math.min(Math.max(centre, half), wrap.width - half)}px`;
10907
+ }
10908
+ /** Retire the offer, and forget what it was pointing at. */
10909
+ #hideQuote() {
10910
+ this.#quoteButton.hidden = true;
10911
+ this.#quoting = "";
10912
+ }
9543
10913
  /** The client-side upload size cap from `data-attachment-max-bytes`. */
9544
10914
  #attachmentMaxBytes() {
9545
10915
  const attr = this.getAttribute("data-attachment-max-bytes");
@@ -9584,15 +10954,21 @@ var AgUiChat = class extends HTMLElement {
9584
10954
  * delete through that server endpoint (wrapping the current store as the
9585
10955
  * client-only fallback), so the history drawer shows durable, cross-device
9586
10956
  * threads. Without it, the client store's per-tab threads are used.
10957
+ *
10958
+ * `data-threads-cache="false"` drops the local copy of the message bodies —
10959
+ * for the deployment that pointed history at a server precisely so that
10960
+ * transcripts do not sit in the browser. The client-only concerns (the active
10961
+ * thread id, the navigation checkpoint) keep their local store either way.
9587
10962
  */
9588
10963
  #wireThreadStore() {
9589
10964
  const url = this.getAttribute("data-threads-url");
9590
10965
  if (url !== null) {
9591
10966
  this.conversationStore = new RemoteConversationStore(
9592
10967
  url,
9593
- () => this.#requestHeaders(),
10968
+ () => this.#headersFor(url),
9594
10969
  this.conversationStore,
9595
- () => this.#requestCredentials()
10970
+ () => this.#requestCredentials(),
10971
+ this.getAttribute("data-threads-cache") !== "false"
9596
10972
  );
9597
10973
  }
9598
10974
  }
@@ -9603,7 +10979,7 @@ var AgUiChat = class extends HTMLElement {
9603
10979
  return;
9604
10980
  }
9605
10981
  try {
9606
- const response = await fetch(url, this.#fetchInit());
10982
+ const response = await fetch(url, this.#fetchInit(url));
9607
10983
  this.#toolCatalog = parseToolCatalog(await response.json());
9608
10984
  } catch {
9609
10985
  }
@@ -9646,7 +11022,7 @@ var AgUiChat = class extends HTMLElement {
9646
11022
  return;
9647
11023
  }
9648
11024
  try {
9649
- const response = await fetch(url, this.#fetchInit());
11025
+ const response = await fetch(url, this.#fetchInit(url));
9650
11026
  this.#backendSkills = parseSkills(await response.json());
9651
11027
  this.#recomputeSkills();
9652
11028
  } catch {
@@ -9729,7 +11105,7 @@ var AgUiChat = class extends HTMLElement {
9729
11105
  } else {
9730
11106
  this.removeAttribute("collapsed");
9731
11107
  }
9732
- sessionStorage.setItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
11108
+ writeStoredItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
9733
11109
  this.#setUnread(0);
9734
11110
  this.dispatchEvent(
9735
11111
  new CustomEvent(TOGGLE_EVENT, {
@@ -9761,7 +11137,7 @@ var AgUiChat = class extends HTMLElement {
9761
11137
  toggleTheme() {
9762
11138
  const next = this.getAttribute("theme") === "dark" ? "light" : "dark";
9763
11139
  this.setAttribute("theme", next);
9764
- sessionStorage.setItem(this.#storageKey(THEME_KEY), next);
11140
+ writeStoredItem(this.#storageKey(THEME_KEY), next);
9765
11141
  this.#syncThemeGlyph();
9766
11142
  }
9767
11143
  /**
@@ -9863,7 +11239,7 @@ var AgUiChat = class extends HTMLElement {
9863
11239
  /** Persist a dragged size per tab, alongside the collapsed/theme preferences. */
9864
11240
  #persistSize(size) {
9865
11241
  const stored = { ...this.#readSize(), ...size };
9866
- sessionStorage.setItem(this.#storageKey(SIZE_KEY), JSON.stringify(stored));
11242
+ writeStoredItem(this.#storageKey(SIZE_KEY), JSON.stringify(stored));
9867
11243
  }
9868
11244
  /** The persisted size for this instance, or an empty record. */
9869
11245
  #readSize() {
@@ -9878,6 +11254,106 @@ var AgUiChat = class extends HTMLElement {
9878
11254
  return {};
9879
11255
  }
9880
11256
  }
11257
+ /**
11258
+ * Claim this element's storage namespace: its `id`, else its `endpoint`.
11259
+ *
11260
+ * The endpoint fallback exists so a lone widget restores its conversation
11261
+ * across reloads with nothing asked of the page author. It stops working the
11262
+ * moment there are two of them — a docked support panel and an inline page
11263
+ * assistant against one agent mount, neither carrying an `id`, which nothing
11264
+ * requires — because both resolve to the same string and then share a thread
11265
+ * pointer, a drawer index and every message key. Whichever mounts second
11266
+ * adopts the first's active thread and rehydrates its transcript into its own
11267
+ * panel: one conversation's content inside another, on the same page.
11268
+ *
11269
+ * So the namespace is claimed by the first element to mount under it, and a
11270
+ * second is given one of its own plus a warning naming the fix. The first
11271
+ * element keeps the endpoint namespace, which is what leaves the ordinary
11272
+ * single-element case exactly as it was.
11273
+ *
11274
+ * The generated namespace is random rather than derived from mount order.
11275
+ * That costs the second element its history across reloads — the warning says
11276
+ * so, and an `id` fixes it — which is the honest trade against an order-based
11277
+ * name that would silently hand a stored conversation to whichever element
11278
+ * happened to mount second on the next load.
11279
+ */
11280
+ #claimNamespace() {
11281
+ const preferred = this.id !== "" ? this.id : this.endpoint;
11282
+ if (preferred === "") {
11283
+ return "";
11284
+ }
11285
+ if (this.#generatedFor === preferred) {
11286
+ return this.#generatedNs;
11287
+ }
11288
+ if (!CLAIMED_NAMESPACES.has(preferred)) {
11289
+ CLAIMED_NAMESPACES.add(preferred);
11290
+ this.#claimedNs = preferred;
11291
+ return preferred;
11292
+ }
11293
+ this.#generatedFor = preferred;
11294
+ this.#generatedNs = `${preferred}~${randomUUID5()}`;
11295
+ console.warn(
11296
+ `<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.`
11297
+ );
11298
+ return this.#generatedNs;
11299
+ }
11300
+ /**
11301
+ * The conversation store's namespace: this element's, scoped to the principal
11302
+ * {@link userKey} names.
11303
+ *
11304
+ * Only the conversation is principal-scoped. The panel's own collapsed / size
11305
+ * / theme preferences stay on `#storageNs`, because they are this element's
11306
+ * UI state rather than anyone's data — they carry no word of what was said —
11307
+ * and because they are read once while connecting, so re-scoping them under a
11308
+ * live element would rearrange the panel around a user who had only just
11309
+ * signed in.
11310
+ */
11311
+ #conversationNs(key = this.userKey) {
11312
+ return key === "" ? this.#storageNs : `${this.#storageNs}#${key}`;
11313
+ }
11314
+ /**
11315
+ * Move the element's client state from one principal to another.
11316
+ *
11317
+ * The whole reason {@link userKey} is live: `sessionStorage` outlives a
11318
+ * logout, because a logout is a navigation (or, in a single-page app, not
11319
+ * even that) rather than a tab close. Nothing remounts, so the host naming
11320
+ * the new principal is the only signal the element will ever get.
11321
+ */
11322
+ #changePrincipal(previousKey, nextKey) {
11323
+ const previous = this.#conversationNs(previousKey);
11324
+ const next = this.#conversationNs(nextKey);
11325
+ if (previousKey === "") {
11326
+ SessionStorageStore.adopt(previous, next);
11327
+ this.#rescopeStore(next);
11328
+ return;
11329
+ }
11330
+ SessionStorageStore.purge(previous);
11331
+ this.#rescopeStore(next);
11332
+ this.#cancelRun();
11333
+ this.#resetState();
11334
+ this.#setRunning(false);
11335
+ this.#setUnread(0);
11336
+ this.#threadId = this.conversationStore.threadId();
11337
+ void this.#rehydrate();
11338
+ void this.#refreshDrawer();
11339
+ }
11340
+ /**
11341
+ * Rebuild the `sessionStorage` store under `namespace`, re-wrapping it for
11342
+ * `data-threads-url` exactly as connecting did.
11343
+ *
11344
+ * A store of the host's own kind is left alone: a store that holds its data
11345
+ * somewhere the element cannot see has to scope itself. The transcript on
11346
+ * screen is still cleared either way — the host swapped principals, and that
11347
+ * much is the element's to act on.
11348
+ */
11349
+ #rescopeStore(namespace) {
11350
+ if (this.#builtinStore === null) {
11351
+ return;
11352
+ }
11353
+ this.#builtinStore = new SessionStorageStore(namespace);
11354
+ this.conversationStore = this.#builtinStore;
11355
+ this.#wireThreadStore();
11356
+ }
9881
11357
  /** This instance's namespaced form of an origin-scoped storage key. */
9882
11358
  #storageKey(base) {
9883
11359
  return this.#storageNs === "" ? base : `${base}:${this.#storageNs}`;
@@ -9963,7 +11439,18 @@ var AgUiChat = class extends HTMLElement {
9963
11439
  /** Drop the in-memory run + transcript, leaving the thread id untouched. */
9964
11440
  #resetState() {
9965
11441
  this.#client = null;
9966
- this.#streamingBubble = null;
11442
+ this.#clearTranscript();
11443
+ this.#initialMessages = [];
11444
+ }
11445
+ /**
11446
+ * Wipe the rendered transcript and everything that indexes into it.
11447
+ *
11448
+ * Split from {@link #resetState} because a retry re-renders the transcript
11449
+ * while keeping the *client*: dropping the client there would take the
11450
+ * agent's message list with it, which is the thing being truncated.
11451
+ */
11452
+ #clearTranscript() {
11453
+ this.#endStream();
9967
11454
  this.#currentGroup = null;
9968
11455
  this.#thoughts = null;
9969
11456
  this.#hidePending();
@@ -9971,11 +11458,43 @@ var AgUiChat = class extends HTMLElement {
9971
11458
  this.#serverSettled.clear();
9972
11459
  this.#cardElements.clear();
9973
11460
  this.#activityBlocks.clear();
9974
- this.#initialMessages = [];
11461
+ this.#retryOwner = null;
9975
11462
  this.#attachTray?.clear();
9976
11463
  this.#messages.replaceChildren(this.#emptyWrap);
9977
11464
  this.#updateEmptyState();
9978
11465
  }
11466
+ /**
11467
+ * Ask the same question again and replace the answer.
11468
+ *
11469
+ * History is truncated to the most recent user message inclusive and the run
11470
+ * repeats, so the agent answers what it was asked rather than being told its
11471
+ * last answer was wrong. Returns `false` when there is nothing to retry or a
11472
+ * run is already in flight.
11473
+ *
11474
+ * Public because a host with its own message UI wants the same button, and
11475
+ * because the failed-run notice reaches it from outside the action row.
11476
+ *
11477
+ * **A retried turn re-runs its tools**, which for a page-driving agent is not
11478
+ * neutral: the previous attempt already clicked what it clicked, and this
11479
+ * does not undo it. Confirmation still applies, so a destructive tool asks
11480
+ * again -- unless the user waived it for this session.
11481
+ */
11482
+ async retryLastTurn() {
11483
+ if (this.#running) {
11484
+ return false;
11485
+ }
11486
+ const client = this.#ensureClient();
11487
+ const kept = client.truncateToLastUser();
11488
+ if (kept === null) {
11489
+ return false;
11490
+ }
11491
+ this.#clearTranscript();
11492
+ for (const message of kept) {
11493
+ this.#renderHistoricMessage(message);
11494
+ }
11495
+ await client.resume();
11496
+ return true;
11497
+ }
9979
11498
  /** Switch the active conversation to an existing thread and replay it. */
9980
11499
  async #switchThread(threadId) {
9981
11500
  if (threadId === this.#threadId) {
@@ -10004,6 +11523,7 @@ var AgUiChat = class extends HTMLElement {
10004
11523
  }
10005
11524
  /** Reload the drawer's thread list, marking the active thread. */
10006
11525
  async #refreshDrawer() {
11526
+ this.#drawer.setRelativeTimeFormatter(this.formatRelativeTime);
10007
11527
  this.#drawer.setThreads(await this.conversationStore.listThreads(), this.#threadId);
10008
11528
  }
10009
11529
  /**
@@ -10095,7 +11615,9 @@ var AgUiChat = class extends HTMLElement {
10095
11615
  }
10096
11616
  if (message.role === MESSAGE_ROLE.ASSISTANT) {
10097
11617
  if (text3 !== "") {
10098
- this.appendMessage(MESSAGE_ROLE.ASSISTANT, text3).classList.add("message--restored");
11618
+ const restoredBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, text3);
11619
+ restoredBubble.classList.add("message--restored");
11620
+ this.#attachActions(restoredBubble);
10099
11621
  }
10100
11622
  for (const call of restoredToolCalls(message.toolCalls)) {
10101
11623
  const restored = {
@@ -10116,8 +11638,8 @@ var AgUiChat = class extends HTMLElement {
10116
11638
  }
10117
11639
  if (message.role === "activity") {
10118
11640
  const activity = message;
10119
- if (activity.activityType === CHART_ACTIVITY_TYPE && this.#chartActivity) {
10120
- this.#drawActivityChart(message.id, activity.content);
11641
+ if (typeof activity.activityType === "string") {
11642
+ this.#drawActivity(message.id, activity.activityType, activity.content);
10121
11643
  }
10122
11644
  return;
10123
11645
  }
@@ -10182,7 +11704,11 @@ var AgUiChat = class extends HTMLElement {
10182
11704
  this.#messages.appendChild(bubble);
10183
11705
  }
10184
11706
  this.#updateEmptyState();
10185
- this.#messages.scrollTop = this.#messages.scrollHeight;
11707
+ if (role === MESSAGE_ROLE.USER) {
11708
+ this.#scroller.jump();
11709
+ } else {
11710
+ this.#scroller.follow();
11711
+ }
10186
11712
  return bubble;
10187
11713
  }
10188
11714
  /**
@@ -10204,8 +11730,6 @@ var AgUiChat = class extends HTMLElement {
10204
11730
  return this.#currentGroup;
10205
11731
  }
10206
11732
  #render() {
10207
- const style = document.createElement("style");
10208
- style.textContent = STYLES;
10209
11733
  this.#chat.className = "chat";
10210
11734
  this.#chat.setAttribute("part", "panel");
10211
11735
  const header = document.createElement("div");
@@ -10229,8 +11753,8 @@ var AgUiChat = class extends HTMLElement {
10229
11753
  checkpoints.addEventListener("click", () => this.toggleCheckpoints());
10230
11754
  const newChat = this.#headerButton("new", this.#strings.newChat, "\u271A");
10231
11755
  newChat.addEventListener("click", () => this.newChat());
10232
- const collapse = this.#headerButton("collapse", this.#strings.collapse, "\u2014");
10233
- collapse.addEventListener("click", () => this.toggleCollapsed());
11756
+ const collapse2 = this.#headerButton("collapse", this.#strings.collapse, "\u2014");
11757
+ collapse2.addEventListener("click", () => this.toggleCollapsed());
10234
11758
  if (this.#runs() !== null) {
10235
11759
  controls.append(history, checkpoints, newChat);
10236
11760
  } else {
@@ -10246,13 +11770,46 @@ var AgUiChat = class extends HTMLElement {
10246
11770
  this.#syncThemeGlyph();
10247
11771
  controls.append(this.#themeToggle);
10248
11772
  }
10249
- controls.append(collapse);
11773
+ controls.append(collapse2);
10250
11774
  header.append(title, headerActions, controls);
10251
11775
  this.#messages.className = "messages";
10252
11776
  this.#messages.setAttribute("part", "messages");
10253
11777
  this.#messages.setAttribute("role", "log");
10254
- this.#messages.setAttribute("aria-live", "polite");
11778
+ this.#messages.setAttribute("aria-live", "off");
10255
11779
  this.#messages.setAttribute("aria-label", this.#strings.conversation);
11780
+ this.#jumpButton.className = "jump-latest";
11781
+ this.#jumpButton.type = "button";
11782
+ this.#jumpButton.setAttribute("part", "jump-latest");
11783
+ this.#jumpButton.textContent = this.#strings.jumpToLatest;
11784
+ this.#jumpButton.addEventListener("click", () => {
11785
+ this.#scroller.jump();
11786
+ });
11787
+ this.#quoteButton.className = "quote-selection";
11788
+ this.#quoteButton.type = "button";
11789
+ this.#quoteButton.setAttribute("part", "quote-selection");
11790
+ this.#quoteButton.textContent = this.#strings.quoteSelection;
11791
+ this.#quoteButton.hidden = true;
11792
+ this.#quoteButton.addEventListener("mousedown", (event) => {
11793
+ event.preventDefault();
11794
+ });
11795
+ this.#quoteButton.addEventListener("click", () => {
11796
+ this.quote(this.#quoting);
11797
+ window.getSelection()?.removeAllRanges();
11798
+ this.#hideQuote();
11799
+ });
11800
+ this.#messages.addEventListener("mouseup", (event) => this.#onSelectionSettled(event));
11801
+ this.#messages.addEventListener("keyup", () => this.#onSelectionSettled());
11802
+ this.#messages.addEventListener("mousedown", () => this.#hideQuote());
11803
+ this.#scroller = createStickToBottom({
11804
+ viewport: this.#messages,
11805
+ onMissedContent: (missed) => {
11806
+ this.#jumpButton.dataset["missed"] = String(missed);
11807
+ }
11808
+ });
11809
+ this.#announcer.className = "sr-only";
11810
+ this.#announcer.setAttribute("role", "status");
11811
+ this.#announcer.setAttribute("aria-live", "polite");
11812
+ this.#announcer.setAttribute("aria-atomic", "true");
10256
11813
  this.#emptyWrap.className = "empty";
10257
11814
  this.#emptyWrap.setAttribute("part", "empty");
10258
11815
  const emptySlot = document.createElement("slot");
@@ -10315,9 +11872,11 @@ var AgUiChat = class extends HTMLElement {
10315
11872
  tools.append(this.#attachButton, this.#voiceSlot, this.#send);
10316
11873
  composer.append(this.#input, tools);
10317
11874
  inputRow.append(composer, this.#fileInput);
11875
+ this.#messagesWrap.className = "messages-wrap";
11876
+ this.#messagesWrap.append(this.#messages, this.#jumpButton, this.#quoteButton);
10318
11877
  this.#chat.append(
10319
11878
  header,
10320
- this.#messages,
11879
+ this.#messagesWrap,
10321
11880
  this.#skillsMenu.palette,
10322
11881
  this.#skillsMenu.chips,
10323
11882
  this.#skillHint,
@@ -10363,7 +11922,66 @@ var AgUiChat = class extends HTMLElement {
10363
11922
  label: this.#strings.resizePanel
10364
11923
  })
10365
11924
  );
10366
- this.#root.append(style, this.#chat, this.#launcher);
11925
+ this.#adoptStyles();
11926
+ this.#root.append(this.#announcer, this.#chat, this.#launcher);
11927
+ }
11928
+ /**
11929
+ * Attach the stylesheet without an inline `<style>` element.
11930
+ *
11931
+ * A host with a strict `style-src` and no `'unsafe-inline'` drops an injected
11932
+ * `<style>` silently: the component mounts, functions, and renders completely
11933
+ * unstyled, with nothing in the console to point at. `adoptedStyleSheets`
11934
+ * carries no inline-style origin, so it is unaffected by that policy.
11935
+ *
11936
+ * The sheet is constructed **per instance** rather than shared at module
11937
+ * scope. A shared sheet would additionally avoid re-parsing the stylesheet
11938
+ * once per mounted element, which is what `adoptedStyleSheets` is usually
11939
+ * reached for -- but a module-level singleton is exactly what this package
11940
+ * forbids, and the CSP defect is fixed either way. Per instance is no worse
11941
+ * than the `<style>` element it replaces, which also parsed once per mount.
11942
+ *
11943
+ * No fallback: constructible `CSSStyleSheet` is Chrome 73, Firefox 101 and
11944
+ * Safari 16.4, all below this package's declared Safari 17 runtime target. A
11945
+ * guard here would be code no supported browser can reach, and the only way
11946
+ * to keep it would be to exempt it from the coverage gate.
11947
+ */
11948
+ /**
11949
+ * Say one short thing to a screen reader, without touching the transcript.
11950
+ *
11951
+ * The transcript cannot do this job. It is rewritten on every animation
11952
+ * frame while an answer streams, so as a live region it re-announced the
11953
+ * whole answer tens of times per turn -- not merely unhelpful but actively
11954
+ * hostile. The published fix for this exact bug (Microsoft's Bot Framework
11955
+ * WebChat #3236) is architectural rather than a matter of tuning attributes:
11956
+ * demote the visible transcript out of live-region duty and put one
11957
+ * synthesised status per event into a separate invisible region. MDN and
11958
+ * Scott O'Hara prescribe the same empty-region-then-inject shape.
11959
+ *
11960
+ * Roughly four calls land per turn -- responding, answered, a card is waiting,
11961
+ * stopped or failed -- so the user is told what happened and reads the answer
11962
+ * itself by navigating the log, at their own pace, rather than having it
11963
+ * shouted at them a token at a time.
11964
+ *
11965
+ * **The clear is load-bearing, twice.** A reader announces a live region when
11966
+ * its content *changes*, so setting the same string twice running -- two turns
11967
+ * in a row both starting -- is not a change and is silently not announced.
11968
+ * Emptying first makes the next set a change again. It also stops a stale
11969
+ * status being read out when a reader later lands on the region.
11970
+ */
11971
+ #announce(message) {
11972
+ if (this.#announceTimer !== null) {
11973
+ clearTimeout(this.#announceTimer);
11974
+ }
11975
+ this.#announcer.textContent = message;
11976
+ this.#announceTimer = setTimeout(() => {
11977
+ this.#announceTimer = null;
11978
+ this.#announcer.textContent = "";
11979
+ }, ANNOUNCE_CLEAR_MS);
11980
+ }
11981
+ #adoptStyles() {
11982
+ const sheet = new CSSStyleSheet();
11983
+ sheet.replaceSync(STYLES);
11984
+ this.#root.adoptedStyleSheets = [sheet];
10367
11985
  }
10368
11986
  /**
10369
11987
  * Build a header control button: a named slot a host can project markup into,
@@ -10644,6 +12262,7 @@ var AgUiChat = class extends HTMLElement {
10644
12262
  // token must still reach every request — the factory's fetch wrapper
10645
12263
  // re-reads this on each call.
10646
12264
  getHeaders: () => this.#requestHeaders(),
12265
+ trustedOrigins: this.trustedOrigins,
10647
12266
  ...this.#credentialsOption(),
10648
12267
  threadId: this.#threadId,
10649
12268
  initialMessages: this.#initialMessages,
@@ -10652,7 +12271,7 @@ var AgUiChat = class extends HTMLElement {
10652
12271
  this.#client = new AgUiClient({
10653
12272
  agent,
10654
12273
  handlers: this.#handlers(),
10655
- getTools: () => this.getTools(),
12274
+ getTools: () => this.#advertiseTools(),
10656
12275
  getContext: () => this.#buildContext(),
10657
12276
  executeTool: (call) => this.#executeTool(call),
10658
12277
  resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
@@ -10674,15 +12293,69 @@ var AgUiChat = class extends HTMLElement {
10674
12293
  })
10675
12294
  );
10676
12295
  }
10677
- /** Whether ``call`` should be gated behind the confirmation card. */
10678
- async #needsConfirmation(call, tool) {
12296
+ /**
12297
+ * Give a finished assistant bubble its action row, and hand it Retry.
12298
+ *
12299
+ * Every finished bubble gets copy and feedback -- both are safe on a message
12300
+ * of any age. Retry moves to the newest, because it is the only one where
12301
+ * re-running answers the same question rather than rewriting history.
12302
+ */
12303
+ #attachActions(bubble, options = {}) {
12304
+ attachMessageActions(bubble, {
12305
+ strings: this.#strings,
12306
+ // Read at click time, not captured: a bubble rendered from markdown holds
12307
+ // its text in the DOM, and that is what the user sees and means to copy.
12308
+ text: () => bubble.textContent,
12309
+ // A failed run is copyable -- error text is what people paste into a bug
12310
+ // report -- but not rateable: a rating is a statement about an *answer*,
12311
+ // and mixing "the connection dropped" into that signal makes the host's
12312
+ // feedback data say less than it did before.
12313
+ ...options.rateable === false ? {} : {
12314
+ onFeedback: (rating) => {
12315
+ this.dispatchEvent(
12316
+ new CustomEvent(FEEDBACK_EVENT, {
12317
+ detail: { content: bubble.textContent, rating },
12318
+ bubbles: true,
12319
+ composed: true
12320
+ })
12321
+ );
12322
+ }
12323
+ }
12324
+ });
12325
+ this.#moveRetryTo(messageActionBar(bubble, this.#strings));
12326
+ }
12327
+ /** Move the Retry button onto `bar`, taking it off whoever held it. */
12328
+ #moveRetryTo(bar) {
12329
+ this.#retryOwner?.querySelector(".message-action--retry")?.remove();
12330
+ const retry = messageActionButton("retry", this.#strings.retryMessage, "\u21BB");
12331
+ retry.addEventListener("click", () => {
12332
+ void this.retryLastTurn();
12333
+ });
12334
+ bar.prepend(retry);
12335
+ this.#retryOwner = bar;
12336
+ }
12337
+ /**
12338
+ * Which rule gates `call`, or `null` when it runs straight through.
12339
+ *
12340
+ * The rule, rather than a bare boolean, because it decides whether the user
12341
+ * may *waive* the prompt for the rest of the session. Only the default
12342
+ * `x-destructive` gate is waivable: `confirmPredicate` is documented as
12343
+ * authoritative, so letting one click retire it would silently defeat a host
12344
+ * policy — and the session allowlist is consulted on the same path it can
12345
+ * be added from, so the button is never offered where honouring it would be
12346
+ * refused.
12347
+ */
12348
+ async #confirmationRule(call, tool) {
10679
12349
  if (this.autoConfirm) {
10680
- return false;
12350
+ return null;
10681
12351
  }
10682
12352
  if (this.confirmPredicate !== null) {
10683
- return await this.confirmPredicate(call.name, call.args) === true;
12353
+ return await this.confirmPredicate(call.name, call.args) === true ? "predicate" : null;
12354
+ }
12355
+ if (this.#sessionApproved.has(call.name)) {
12356
+ return null;
10684
12357
  }
10685
- return isDestructive(tool.parameters);
12358
+ return isDestructive(tool.parameters) ? "destructive" : null;
10686
12359
  }
10687
12360
  async #executeTool(call) {
10688
12361
  if (skillNameFrom(call) !== null) {
@@ -10691,7 +12364,7 @@ var AgUiChat = class extends HTMLElement {
10691
12364
  const card = this.#cardFor(call);
10692
12365
  this.#toolCards.delete(call.id);
10693
12366
  this.#cardElements.set(call.id, card.element);
10694
- const tool = this.#resolveTool(call.name);
12367
+ const tool = this.#advertisedTools.has(call.name) ? this.#resolveTool(call.name) : null;
10695
12368
  if (tool === null) {
10696
12369
  if (!this.#serverSettled.has(call.id)) {
10697
12370
  card.settle(TOOL_CALL_STATUS.DONE, this.#strings.noResult);
@@ -10704,7 +12377,8 @@ var AgUiChat = class extends HTMLElement {
10704
12377
  this.#showPending();
10705
12378
  return { content: `Error: ${message}`, error: message };
10706
12379
  }
10707
- if (await this.#needsConfirmation(call, tool)) {
12380
+ const rule = await this.#confirmationRule(call, tool);
12381
+ if (rule !== null) {
10708
12382
  const request = { toolName: call.name, args: call.args };
10709
12383
  const confirmText = tool.parameters[X_CONFIRM_KEY];
10710
12384
  if (typeof confirmText === "string") {
@@ -10713,10 +12387,12 @@ var AgUiChat = class extends HTMLElement {
10713
12387
  this.#confirmAbort = new AbortController();
10714
12388
  const decision = requestConfirmation(this.#ensureGroup(), request, {
10715
12389
  signal: this.#confirmAbort.signal,
10716
- strings: this.#strings
12390
+ strings: this.#strings,
12391
+ // Offered only where it can be honoured -- see `#confirmationRule`.
12392
+ ...rule === "destructive" ? { onAlwaysAllow: () => this.#sessionApproved.add(call.name) } : {}
10717
12393
  });
10718
12394
  this.#updateEmptyState();
10719
- this.#messages.scrollTop = this.#messages.scrollHeight;
12395
+ this.#scroller.follow();
10720
12396
  const accepted = await decision;
10721
12397
  this.#confirmAbort = null;
10722
12398
  card.recordDecision(accepted ? "approved" : "declined");
@@ -10778,6 +12454,9 @@ var AgUiChat = class extends HTMLElement {
10778
12454
  */
10779
12455
  async #resolveInterrupts(interrupts) {
10780
12456
  this.#confirmAbort = new AbortController();
12457
+ this.#announce(
12458
+ this.#strings.announceAwaitingDecision.replace("{count}", String(interrupts.length))
12459
+ );
10781
12460
  this.#hidePending();
10782
12461
  const signal = this.#confirmAbort.signal;
10783
12462
  const answered = await Promise.all(
@@ -10792,10 +12471,20 @@ var AgUiChat = class extends HTMLElement {
10792
12471
  if (toolName !== null && toolName !== void 0) {
10793
12472
  request.toolName = toolName;
10794
12473
  }
12474
+ let editedArgs;
12475
+ const editable = this.approveWithEdits && card !== void 0;
12476
+ if (editable) {
12477
+ request.args = card.args;
12478
+ }
10795
12479
  card?.mark(TOOL_CALL_STATUS.DEFERRED);
10796
12480
  const approved = this.approvalRenderer !== null ? await this.approvalRenderer(request, { signal }) : await requestApproval(card?.approvalSlot ?? this.#ensureGroup(), request, {
10797
12481
  signal,
10798
- strings: this.#strings
12482
+ strings: this.#strings,
12483
+ ...editable ? {
12484
+ onEdit: (args) => {
12485
+ editedArgs = args;
12486
+ }
12487
+ } : {}
10799
12488
  });
10800
12489
  card?.recordDecision(approved ? "approved" : "declined");
10801
12490
  if (approved) {
@@ -10803,21 +12492,28 @@ var AgUiChat = class extends HTMLElement {
10803
12492
  } else {
10804
12493
  card?.settle(TOOL_CALL_STATUS.DECLINED, this.#strings.declinedAction);
10805
12494
  }
10806
- return { id: interrupt.id, approved };
12495
+ return { id: interrupt.id, approved, editedArgs };
10807
12496
  })
10808
12497
  );
10809
12498
  this.#updateEmptyState();
10810
- this.#messages.scrollTop = this.#messages.scrollHeight;
12499
+ this.#scroller.follow();
10811
12500
  this.#confirmAbort = null;
10812
12501
  const responses = {};
10813
- for (const { id, approved } of answered) {
10814
- responses[id] = approved ? { status: "resolved", payload: { approved: true } } : { status: "cancelled" };
12502
+ for (const { id, approved, editedArgs } of answered) {
12503
+ responses[id] = approved ? {
12504
+ status: "resolved",
12505
+ payload: editedArgs === void 0 ? { approved: true } : { approved: true, editedArgs }
12506
+ } : { status: "cancelled" };
10815
12507
  }
10816
12508
  return responses;
10817
12509
  }
10818
12510
  #handlers() {
10819
12511
  return {
10820
12512
  onRunStart: () => {
12513
+ if (!this.#running) {
12514
+ this.#announcedOutcome = false;
12515
+ this.#announce(this.#strings.announceResponding);
12516
+ }
10821
12517
  this.#setRunning(true);
10822
12518
  this.#ensureGroup();
10823
12519
  this.#showPending();
@@ -10834,7 +12530,7 @@ var AgUiChat = class extends HTMLElement {
10834
12530
  onTextDelta: (buffer) => {
10835
12531
  this.#hidePending();
10836
12532
  this.#thoughts?.collapse();
10837
- this.#streamInto(buffer);
12533
+ this.#queueStream(buffer);
10838
12534
  this.#streamDeltas += 1;
10839
12535
  },
10840
12536
  onTextEnd: (buffer) => {
@@ -10843,7 +12539,8 @@ var AgUiChat = class extends HTMLElement {
10843
12539
  this.#revealWords(bubble);
10844
12540
  }
10845
12541
  attachCopyButtons(bubble, this.#strings);
10846
- this.#streamingBubble = null;
12542
+ this.#attachActions(bubble);
12543
+ this.#endStream();
10847
12544
  this.#noteUnread();
10848
12545
  },
10849
12546
  onToolCall: (call) => {
@@ -10855,25 +12552,24 @@ var AgUiChat = class extends HTMLElement {
10855
12552
  this.#cardFor(call);
10856
12553
  },
10857
12554
  onActivity: (activityType, content, messageId) => {
10858
- if (activityType === CHART_ACTIVITY_TYPE) {
10859
- if (this.#chartActivity) {
10860
- this.#drawActivityChart(messageId, content);
10861
- }
10862
- return;
10863
- }
10864
- if (activityType !== COMPACTION_ACTIVITY_TYPE) {
10865
- return;
10866
- }
10867
- const removed = compactionRemoved(content);
10868
- if (removed === null) {
12555
+ this.#drawActivity(messageId, activityType, content);
12556
+ },
12557
+ onCustomEvent: (name, value) => {
12558
+ if (name === INVALIDATE_CUSTOM_NAME) {
12559
+ this.#dispatchInvalidation(value);
10869
12560
  return;
10870
12561
  }
10871
- this.#appendNotice(
10872
- "\u{1F5DC}",
10873
- this.#strings.historyCompacted.replace("{count}", String(removed)),
10874
- "compaction"
12562
+ this.dispatchEvent(
12563
+ new CustomEvent(CUSTOM_AGENT_EVENT, {
12564
+ detail: { name, value },
12565
+ bubbles: true,
12566
+ composed: true
12567
+ })
10875
12568
  );
10876
12569
  },
12570
+ onMessagesSnapshot: () => {
12571
+ this.#appendNotice("\u{1F504}", this.#strings.historyReplaced, "history-replaced");
12572
+ },
10877
12573
  onToolResult: (toolCallId, content) => {
10878
12574
  const card = this.#toolCards.get(toolCallId);
10879
12575
  if (card === void 0) {
@@ -10884,28 +12580,36 @@ var AgUiChat = class extends HTMLElement {
10884
12580
  this.#showPending();
10885
12581
  },
10886
12582
  onActivityChanged: (messageId, activityType, content) => {
10887
- if (activityType === CHART_ACTIVITY_TYPE && this.#chartActivity) {
10888
- this.#drawActivityChart(messageId, content);
10889
- }
12583
+ this.#drawActivity(messageId, activityType, content);
10890
12584
  },
10891
12585
  onRunEnd: () => {
10892
12586
  this.#hidePending();
10893
- this.#streamingBubble = null;
12587
+ this.#endStream();
10894
12588
  },
10895
12589
  onError: (message) => {
12590
+ this.#announcedOutcome = true;
12591
+ this.#announce(this.#strings.announceFailed);
10896
12592
  this.#hidePending();
10897
- this.#revealWords(this.appendMessage(MESSAGE_ROLE.ASSISTANT, `\u26A0\uFE0F ${message}`));
10898
- this.#streamingBubble = null;
12593
+ const bubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, `\u26A0\uFE0F ${message}`);
12594
+ bubble.classList.add("message--failed");
12595
+ this.#attachActions(bubble, { rateable: false });
12596
+ this.#revealWords(bubble);
12597
+ this.#endStream();
10899
12598
  },
10900
12599
  onCancelled: () => {
12600
+ this.#announcedOutcome = true;
12601
+ this.#announce(this.#strings.announceStopped);
10901
12602
  this.#hidePending();
10902
12603
  this.#appendStoppedNote();
10903
- this.#streamingBubble = null;
12604
+ this.#endStream();
10904
12605
  },
10905
12606
  onSettled: () => {
12607
+ if (!this.#announcedOutcome) {
12608
+ this.#announce(this.#strings.announceAnswerReady);
12609
+ }
10906
12610
  this.#hidePending();
10907
12611
  this.#setRunning(false);
10908
- this.#streamingBubble = null;
12612
+ this.#endStream();
10909
12613
  for (const card of this.#toolCards.values()) {
10910
12614
  if (!card.settled) {
10911
12615
  card.settle(TOOL_CALL_STATUS.DONE, this.#strings.noResult);
@@ -10936,9 +12640,42 @@ var AgUiChat = class extends HTMLElement {
10936
12640
  side: this.#serverSettled.has(id) ? "server" : "client"
10937
12641
  }));
10938
12642
  this.#runTools = [];
12643
+ const invalidated = [...this.#runInvalidated];
12644
+ this.#runInvalidated = /* @__PURE__ */ new Set();
10939
12645
  this.dispatchEvent(
10940
12646
  new CustomEvent(RUN_FINISHED_EVENT, {
10941
- detail: { tools },
12647
+ detail: { tools, invalidated },
12648
+ bubbles: true,
12649
+ composed: true
12650
+ })
12651
+ );
12652
+ }
12653
+ /**
12654
+ * Route one invalidation to the host, and remember it for the run summary.
12655
+ *
12656
+ * Dispatched immediately rather than only at the end, because that is what
12657
+ * makes a long multi-step run feel live -- the list refreshes as the third of
12658
+ * eight writes lands. The accumulated set rides
12659
+ * {@link RUN_FINISHED_EVENT} as well, so a host that would rather refetch once
12660
+ * upgrades by reading one extra field instead of adding a listener.
12661
+ *
12662
+ * Nothing is rendered, persisted or replayed. An invalidation is an
12663
+ * imperative: it has no place in the transcript and no meaning once acted on,
12664
+ * and replaying one on every thread load would be a refetch storm. That is the
12665
+ * whole reason the server sends it as `CUSTOM` rather than as an activity.
12666
+ */
12667
+ #dispatchInvalidation(value) {
12668
+ const payload = value ?? {};
12669
+ const keys = Array.isArray(payload.keys) ? payload.keys.filter((key) => typeof key === "string") : [];
12670
+ if (keys.length === 0) {
12671
+ return;
12672
+ }
12673
+ for (const key of keys) {
12674
+ this.#runInvalidated.add(key);
12675
+ }
12676
+ this.dispatchEvent(
12677
+ new CustomEvent(INVALIDATE_EVENT, {
12678
+ detail: { keys, reason: typeof payload.reason === "string" ? payload.reason : null },
10942
12679
  bubbles: true,
10943
12680
  composed: true
10944
12681
  })
@@ -10953,7 +12690,7 @@ var AgUiChat = class extends HTMLElement {
10953
12690
  note.textContent = this.#strings.stopped;
10954
12691
  this.#ensureGroup().appendChild(note);
10955
12692
  this.#updateEmptyState();
10956
- this.#messages.scrollTop = this.#messages.scrollHeight;
12693
+ this.#scroller.follow();
10957
12694
  }
10958
12695
  /**
10959
12696
  * Show a "thinking" indicator while the agent is being awaited — both the
@@ -10977,7 +12714,7 @@ var AgUiChat = class extends HTMLElement {
10977
12714
  this.#pending = pending;
10978
12715
  this.#ensureGroup().appendChild(pending);
10979
12716
  this.#updateEmptyState();
10980
- this.#messages.scrollTop = this.#messages.scrollHeight;
12717
+ this.#scroller.follow();
10981
12718
  }
10982
12719
  /** Remove the pending indicator if shown. */
10983
12720
  #hidePending() {
@@ -10995,19 +12732,76 @@ var AgUiChat = class extends HTMLElement {
10995
12732
  const group = this.#ensureGroup();
10996
12733
  group.insertBefore(this.#thoughts.element, group.firstChild);
10997
12734
  this.#updateEmptyState();
10998
- this.#messages.scrollTop = this.#messages.scrollHeight;
12735
+ this.#scroller.follow();
10999
12736
  }
11000
12737
  return this.#thoughts;
11001
12738
  }
11002
- #streamInto(buffer) {
12739
+ /**
12740
+ * The bubble the current answer streams into, opening it on first sight.
12741
+ *
12742
+ * Opened the moment a token arrives rather than on the frame that draws it,
12743
+ * so the answer's container replaces the pending dots straight away and the
12744
+ * turn never shows a gap while the first render waits for a frame.
12745
+ */
12746
+ #openStream() {
11003
12747
  if (this.#streamingBubble === null) {
11004
12748
  this.#streamingBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, "");
11005
12749
  this.#streamDeltas = 0;
11006
12750
  }
11007
- this.#streamingBubble.innerHTML = renderMarkdown(buffer, { allowImages: this.allowImages });
11008
- this.#messages.scrollTop = this.#messages.scrollHeight;
11009
12751
  return this.#streamingBubble;
11010
12752
  }
12753
+ /**
12754
+ * Queue a render of the answer so far, at most one per frame.
12755
+ *
12756
+ * Each `TEXT_MESSAGE_CONTENT` event carries the *whole* accumulated answer,
12757
+ * and drawing it means marked + DOMPurify over the entire document and a
12758
+ * wholesale replacement of the bubble's subtree. Once per token that is
12759
+ * quadratic in the answer's length — a long answer is agent-controlled, so
12760
+ * an ordinary run becomes a progressively stalling tab — and every rebuild
12761
+ * takes any selection or focus inside the bubble with it.
12762
+ *
12763
+ * A frame is the right grain: it is the fastest anything on screen can
12764
+ * change anyway, so a burst of tokens costs one parse and the text still
12765
+ * appears to flow rather than in visible chunks.
12766
+ */
12767
+ #queueStream(buffer) {
12768
+ this.#streamBuffer = buffer;
12769
+ this.#openStream();
12770
+ if (this.#streamFrame !== null) {
12771
+ return;
12772
+ }
12773
+ this.#streamFrame = requestAnimationFrame(() => {
12774
+ this.#streamFrame = null;
12775
+ this.#streamInto(this.#streamBuffer);
12776
+ });
12777
+ }
12778
+ /** Render `buffer` into the streaming bubble now, dropping any queued frame. */
12779
+ #streamInto(buffer) {
12780
+ if (this.#streamFrame !== null) {
12781
+ cancelAnimationFrame(this.#streamFrame);
12782
+ this.#streamFrame = null;
12783
+ }
12784
+ this.#streamBuffer = buffer;
12785
+ const bubble = this.#openStream();
12786
+ bubble.innerHTML = renderMarkdown(buffer, { allowImages: this.allowImages });
12787
+ this.#scroller.follow();
12788
+ return bubble;
12789
+ }
12790
+ /**
12791
+ * Close the current answer's streaming bubble.
12792
+ *
12793
+ * Draws a queued render first. A run that ends without a text end — a
12794
+ * cancel, an error, a round boundary — leaves the last delta sitting in the
12795
+ * queue, and simply dropping the bubble here would strand it: the partial
12796
+ * answer the user stopped mid-sentence would lose its final tokens, or be an
12797
+ * empty bubble above the stopped note.
12798
+ */
12799
+ #endStream() {
12800
+ if (this.#streamFrame !== null) {
12801
+ this.#streamInto(this.#streamBuffer);
12802
+ }
12803
+ this.#streamingBubble = null;
12804
+ }
11011
12805
  /**
11012
12806
  * The card for ``call``, creating and appending it on first sight.
11013
12807
  *
@@ -11038,7 +12832,7 @@ var AgUiChat = class extends HTMLElement {
11038
12832
  #appendNotice(icon, text3, kind) {
11039
12833
  this.#ensureGroup().appendChild(renderRunNotice(icon, text3, kind));
11040
12834
  this.#updateEmptyState();
11041
- this.#messages.scrollTop = this.#messages.scrollHeight;
12835
+ this.#scroller.follow();
11042
12836
  }
11043
12837
  /**
11044
12838
  * Turn on chart rendering, by whichever route this consumer wants.
@@ -11059,9 +12853,16 @@ var AgUiChat = class extends HTMLElement {
11059
12853
  * arrives is not something to switch on for everybody.
11060
12854
  */
11061
12855
  enableCharts(routes = ["tool", "activity"]) {
11062
- const first = !this.#chartActivity && !this.#toolRegistry.has(CHART_TOOL_NAME);
12856
+ const first = !this.#activityRenderers.has(CHART_ACTIVITY_TYPE) && !this.#toolRegistry.has(CHART_TOOL_NAME);
11063
12857
  if (routes.includes("activity")) {
11064
- this.#chartActivity = true;
12858
+ this.registerActivityRenderer({
12859
+ type: CHART_ACTIVITY_TYPE,
12860
+ render: (content) => {
12861
+ const spec = chartSpecFrom(content);
12862
+ return spec === null ? null : renderChart(spec);
12863
+ },
12864
+ removedNotice: this.#strings.chartUndrawable
12865
+ });
11065
12866
  }
11066
12867
  if (routes.includes("tool")) {
11067
12868
  this.registerTool(createChartTool());
@@ -11094,27 +12895,120 @@ var AgUiChat = class extends HTMLElement {
11094
12895
  this.#cardElements.get(call.id)?.after(node);
11095
12896
  this.#afterTranscriptGrew();
11096
12897
  }
11097
- /** Draw, or redraw in place, the chart for one activity message. */
11098
- #drawActivityChart(messageId, content) {
11099
- const spec = chartSpecFrom(content);
11100
- const block = spec === null ? null : renderChart(spec);
11101
- if (block === null) {
11102
- this.#activityBlocks.get(messageId)?.remove();
11103
- this.#activityBlocks.delete(messageId);
12898
+ /**
12899
+ * Teach this element to draw one kind of AG-UI activity.
12900
+ *
12901
+ * `activity_type` is one of exactly two fields the protocol leaves an open
12902
+ * string, and it is the **content** one: an activity is materialised into a
12903
+ * message, persisted with the thread, and replayed on every restore. Its
12904
+ * sibling `CUSTOM` carries an imperative and is dispatched to the page
12905
+ * instead ({@link CUSTOM_AGENT_EVENT}).
12906
+ *
12907
+ * That asymmetry decides which carrier a server should use. Content has a
12908
+ * place in the conversation and should come back; an imperative has no place
12909
+ * and no meaning once acted on.
12910
+ *
12911
+ * ```js
12912
+ * chat.registerActivityRenderer({
12913
+ * type: "build_status",
12914
+ * render: (content) => {
12915
+ * const el = document.createElement("div");
12916
+ * el.textContent = `Build ${content.status}`;
12917
+ * return el;
12918
+ * },
12919
+ * });
12920
+ * ```
12921
+ *
12922
+ * Registering a type twice replaces the earlier renderer, so a host can
12923
+ * override a built-in -- `chart` and `compaction` are registrations like any
12924
+ * other, not privileged branches.
12925
+ *
12926
+ * ⚠ `render` runs again on every thread load. See {@link ActivityRenderer}
12927
+ * for what that requires of it.
12928
+ */
12929
+ registerActivityRenderer(registration) {
12930
+ this.#activityRenderers.set(registration.type, registration);
12931
+ this.#unhandledActivityTypes.delete(registration.type);
12932
+ }
12933
+ /**
12934
+ * Activity types that arrived with nobody registered to draw them.
12935
+ *
12936
+ * Deliberately the only trace an unhandled activity leaves. Ignoring an
12937
+ * unknown name is the protocol's own answer and the whole point of an open
12938
+ * field, so warning would fire on every forward-compatible server -- but
12939
+ * "nothing happened and nothing was said" is impossible to debug, so the set
12940
+ * is readable. Accumulates for the element's lifetime, across threads.
12941
+ */
12942
+ get unhandledActivityTypes() {
12943
+ return [...this.#unhandledActivityTypes];
12944
+ }
12945
+ /**
12946
+ * Draw, replace or remove one activity, whatever kind it is.
12947
+ *
12948
+ * The single path for all three routes an activity arrives by -- pushed
12949
+ * (`onActivity`), patched (`onActivityChanged`) and replayed from history --
12950
+ * which is why the renderer contract has to be pure: the same content is
12951
+ * drawn again on every thread load.
12952
+ *
12953
+ * An unregistered type draws nothing and says nothing. That is the protocol's
12954
+ * own answer -- a client that does not know a name ignores the event -- and a
12955
+ * warning here would fire on every well-behaved forward-compatible server,
12956
+ * while a placeholder would put the protocol's growth in the user's face.
12957
+ * {@link unhandledActivityTypes} is the way to find out what arrived.
12958
+ */
12959
+ #drawActivity(messageId, activityType, content) {
12960
+ const registration = this.#activityRenderers.get(activityType);
12961
+ if (registration === void 0) {
12962
+ this.#unhandledActivityTypes.add(activityType);
12963
+ return;
12964
+ }
12965
+ let node;
12966
+ try {
12967
+ node = registration.render(content);
12968
+ } catch (error) {
12969
+ console.warn(`ag-ui-chat: render failed for activity ${activityType}`, error);
12970
+ node = null;
12971
+ }
12972
+ if (node === null) {
12973
+ this.#removeActivity(messageId, activityType, registration.removedNotice, content);
11104
12974
  return;
11105
12975
  }
11106
12976
  const existing = this.#activityBlocks.get(messageId);
11107
12977
  if (existing === void 0) {
11108
- this.#ensureGroup().appendChild(block);
12978
+ this.#ensureGroup().appendChild(node);
11109
12979
  } else {
11110
- existing.replaceWith(block);
12980
+ existing.replaceWith(node);
11111
12981
  }
11112
- this.#activityBlocks.set(messageId, block);
12982
+ this.#activityBlocks.set(messageId, node);
11113
12983
  this.#afterTranscriptGrew();
11114
12984
  }
12985
+ /**
12986
+ * Take away an activity whose content stopped being drawable.
12987
+ *
12988
+ * Leaving the old one up is the worst available answer: it shows values that
12989
+ * have been retracted, reading as current, and a reload drops it anyway
12990
+ * because the *stored* content is the version that could not be drawn. Live
12991
+ * and reload should agree, and both should say "gone".
12992
+ *
12993
+ * Removing is right; doing it in silence was not. A chart that had been drawn
12994
+ * simply disappeared, with no `console` call anywhere on the path -- which
12995
+ * nobody reports as a bug, they report as "the charts are flaky".
12996
+ */
12997
+ #removeActivity(messageId, activityType, notice, content) {
12998
+ const had = this.#activityBlocks.has(messageId);
12999
+ this.#activityBlocks.get(messageId)?.remove();
13000
+ this.#activityBlocks.delete(messageId);
13001
+ console.warn(
13002
+ `ag-ui-chat: activity ${messageId} (${activityType}) was not drawable and has been removed. A chart's points must each be a finite JSON number; a numeric column serialised as a string (a Decimal, typically) is rejected rather than coerced.`,
13003
+ content
13004
+ );
13005
+ if (had && notice !== void 0) {
13006
+ this.#appendNotice("\u{1F4C9}", notice, "chart-undrawable");
13007
+ }
13008
+ }
11115
13009
  #afterTranscriptGrew() {
11116
13010
  this.#updateEmptyState();
11117
- this.#messages.scrollTop = this.#messages.scrollHeight;
13011
+ this.#scroller.follow();
11118
13012
  }
11119
13013
  #cardFor(call) {
11120
13014
  const existing = this.#toolCards.get(call.id);
@@ -11122,12 +13016,12 @@ var AgUiChat = class extends HTMLElement {
11122
13016
  return existing;
11123
13017
  }
11124
13018
  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);
13019
+ const summary = typeof labelled === "string" ? labelled : this.toolSummaries[call.name] ?? this.#toolCatalog[call.name]?.summary ?? prettifyToolName(call.name);
11126
13020
  const card = new ToolCallCard(call.name, call.args, summary, this.#strings);
11127
13021
  this.#toolCards.set(call.id, card);
11128
13022
  this.#ensureGroup().appendChild(card.element);
11129
13023
  this.#updateEmptyState();
11130
- this.#messages.scrollTop = this.#messages.scrollHeight;
13024
+ this.#scroller.follow();
11131
13025
  return card;
11132
13026
  }
11133
13027
  };
@@ -11197,7 +13091,7 @@ function setControlValue(el2, value) {
11197
13091
  }
11198
13092
 
11199
13093
  // src/version.ts
11200
- var VERSION = "0.27.0";
13094
+ var VERSION = "0.29.0";
11201
13095
  export {
11202
13096
  ATTACHMENT_EVENT,
11203
13097
  AgUiChat,
@@ -11205,12 +13099,19 @@ export {
11205
13099
  CHART_ACTIVITY_TYPE,
11206
13100
  CHART_TOOL_NAME,
11207
13101
  COMPACTION_ACTIVITY_TYPE,
13102
+ CUSTOM_AGENT_EVENT,
11208
13103
  CheckpointMenu,
11209
13104
  ClientToolRegistry,
11210
13105
  ConnectionLostError,
11211
13106
  DEFAULT_UI_STRINGS,
11212
13107
  ELEMENT_TAG,
13108
+ FEEDBACK_EVENT,
13109
+ INVALIDATE_CUSTOM_NAME,
13110
+ INVALIDATE_EVENT,
11213
13111
  LOAD_CAPABILITY_TOOL,
13112
+ MAX_QUOTE_CHARS,
13113
+ MAX_SUGGESTIONS,
13114
+ MAX_SUGGESTION_CHARS,
11214
13115
  MAX_TOOL_ROUNDS,
11215
13116
  MESSAGE_ROLE,
11216
13117
  PAGE_ACTIONS,
@@ -11219,6 +13120,7 @@ export {
11219
13120
  RunIndex,
11220
13121
  STATE_EVENT,
11221
13122
  SUBMIT_EVENT,
13123
+ SUGGESTIONS_ACTIVITY_TYPE,
11222
13124
  SessionStorageStore,
11223
13125
  TOGGLE_EVENT,
11224
13126
  TOOL_CALL_STATUS,
@@ -11230,6 +13132,9 @@ export {
11230
13132
  X_DESTRUCTIVE_KEY,
11231
13133
  X_NAVIGATES_KEY,
11232
13134
  X_SUMMARY_KEY,
13135
+ asQuote,
13136
+ attachMessageActions,
13137
+ attachQuoteOffer,
11233
13138
  chartSpecFrom,
11234
13139
  clickElement,
11235
13140
  createHttpAgent,
@@ -11246,14 +13151,18 @@ export {
11246
13151
  isDestructive,
11247
13152
  isNavigates,
11248
13153
  mergeUiStrings,
13154
+ messageActionBar,
11249
13155
  messageAttachments,
11250
13156
  parseToolCatalog,
11251
13157
  prefersReducedMotion,
11252
13158
  pressButton,
11253
13159
  pressThenClick,
11254
13160
  prettifyToolName,
13161
+ quotableSelection,
13162
+ relativeTime,
11255
13163
  renderChart,
11256
13164
  renderMarkdown,
13165
+ renderSuggestionChips,
11257
13166
  requestApproval,
11258
13167
  requestConfirmation,
11259
13168
  requestQuestion,
@@ -11263,6 +13172,7 @@ export {
11263
13172
  setControlValue,
11264
13173
  setNativeChecked,
11265
13174
  setNativeValue,
13175
+ suggestionPrompts,
11266
13176
  toggleCheckbox,
11267
13177
  toggleControl,
11268
13178
  transcribeAudio,