@artooi/ag-ui-web-component 0.28.0 → 0.30.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 (65) hide show
  1. package/CHANGELOG.md +615 -1
  2. package/README.md +564 -35
  3. package/dist/ag-ui-web-component.bundle.js +491 -50
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +129 -1
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +232 -1
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +56 -1
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/index.d.ts +8 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2081 -98
  14. package/dist/index.js.map +4 -4
  15. package/dist/ui/approval_card.d.ts +18 -0
  16. package/dist/ui/approval_card.d.ts.map +1 -1
  17. package/dist/ui/checkpoint_menu.d.ts +10 -0
  18. package/dist/ui/checkpoint_menu.d.ts.map +1 -1
  19. package/dist/ui/confirmation_card.d.ts +16 -0
  20. package/dist/ui/confirmation_card.d.ts.map +1 -1
  21. package/dist/ui/message_actions.d.ts +56 -0
  22. package/dist/ui/message_actions.d.ts.map +1 -0
  23. package/dist/ui/page_quote_offer.d.ts +33 -0
  24. package/dist/ui/page_quote_offer.d.ts.map +1 -0
  25. package/dist/ui/quote_selection.d.ts +66 -0
  26. package/dist/ui/quote_selection.d.ts.map +1 -0
  27. package/dist/ui/relative_time.d.ts +10 -0
  28. package/dist/ui/relative_time.d.ts.map +1 -1
  29. package/dist/ui/stick_to_bottom.d.ts +55 -0
  30. package/dist/ui/stick_to_bottom.d.ts.map +1 -0
  31. package/dist/ui/styles.d.ts +1 -1
  32. package/dist/ui/styles.d.ts.map +1 -1
  33. package/dist/ui/subagent_panel.d.ts +92 -0
  34. package/dist/ui/subagent_panel.d.ts.map +1 -0
  35. package/dist/ui/subagent_update.d.ts +19 -0
  36. package/dist/ui/subagent_update.d.ts.map +1 -0
  37. package/dist/ui/suggestion_chips.d.ts +29 -0
  38. package/dist/ui/suggestion_chips.d.ts.map +1 -0
  39. package/dist/ui/thread_drawer.d.ts +10 -0
  40. package/dist/ui/thread_drawer.d.ts.map +1 -1
  41. package/dist/ui/tool_call_card.d.ts +81 -1
  42. package/dist/ui/tool_call_card.d.ts.map +1 -1
  43. package/dist/ui/ui_strings.d.ts +50 -0
  44. package/dist/ui/ui_strings.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/constants.ts +138 -1
  47. package/src/core/ag_ui_chat.ts +1081 -73
  48. package/src/core/agui_client.ts +89 -2
  49. package/src/index.ts +43 -0
  50. package/src/ui/approval_card.ts +90 -2
  51. package/src/ui/checkpoint_menu.ts +22 -5
  52. package/src/ui/confirmation_card.ts +29 -1
  53. package/src/ui/message_actions.ts +170 -0
  54. package/src/ui/page_quote_offer.ts +215 -0
  55. package/src/ui/quote_selection.ts +345 -0
  56. package/src/ui/relative_time.ts +11 -0
  57. package/src/ui/stick_to_bottom.ts +126 -0
  58. package/src/ui/styles.ts +410 -0
  59. package/src/ui/subagent_panel.ts +213 -0
  60. package/src/ui/subagent_update.ts +80 -0
  61. package/src/ui/suggestion_chips.ts +73 -0
  62. package/src/ui/thread_drawer.ts +22 -2
  63. package/src/ui/tool_call_card.ts +138 -3
  64. package/src/ui/ui_strings.ts +75 -0
  65. package/src/version.ts +1 -1
package/dist/index.js CHANGED
@@ -6,6 +6,19 @@ 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";
14
+ var SUBAGENT_CUSTOM_NAME = "ag_ui.subagent";
15
+ var SUBAGENT_PHASE = {
16
+ STARTED: "started",
17
+ TOOL_CALL: "tool_call",
18
+ TOOL_RESULT: "tool_result",
19
+ FINISHED: "finished",
20
+ FAILED: "failed"
21
+ };
9
22
  var MESSAGE_ROLE = {
10
23
  USER: "user",
11
24
  ASSISTANT: "assistant"
@@ -16,6 +29,11 @@ var X_SUMMARY_KEY = "x-summary";
16
29
  var X_NAVIGATES_KEY = "x-navigates";
17
30
  var READ_PAGE_TOOL = "read_page";
18
31
  var MAX_TOOL_ROUNDS = 10;
32
+ var MESSAGE_ACTIONS = {
33
+ COPY: "copy",
34
+ RETRY: "retry",
35
+ FEEDBACK: "feedback"
36
+ };
19
37
  var TOOL_CALL_STATUS = {
20
38
  PENDING: "pending",
21
39
  DEFERRED: "deferred",
@@ -47,6 +65,7 @@ var ICON_FILE_IMAGE = `<svg class="glyph" viewBox="0 0 24 24" aria-hidden="true"
47
65
  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
66
  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
67
  var CHART_ACTIVITY_TYPE = "chart";
68
+ var ANNOUNCE_CLEAR_MS = 150;
50
69
 
51
70
  // src/core/ag_ui_chat.ts
52
71
  import { randomUUID as randomUUID5 } from "@ag-ui/client";
@@ -537,6 +556,12 @@ var DEFAULT_UI_STRINGS = {
537
556
  forkRun: "Fork",
538
557
  forkedRun: "branched",
539
558
  conversation: "Conversation",
559
+ jumpToLatest: "Jump to latest",
560
+ announceResponding: "Assistant is responding",
561
+ announceAnswerReady: "Assistant answered",
562
+ announceAwaitingDecision: "{count} action is waiting for your approval",
563
+ announceStopped: "Response stopped",
564
+ announceFailed: "The response failed",
540
565
  thinking: "Assistant is thinking\u2026",
541
566
  thoughts: "Thoughts",
542
567
  stopped: "\u23F9 Stopped",
@@ -544,6 +569,8 @@ var DEFAULT_UI_STRINGS = {
544
569
  noResult: "No result returned.",
545
570
  declinedAction: "User declined the action.",
546
571
  navigating: "Navigating\u2026",
572
+ historyReplaced: "The server replaced this conversation's history. Reload to see the updated transcript.",
573
+ chartUndrawable: "A chart could not be drawn from the data sent, so it was removed.",
547
574
  historyCompacted: "Earlier turns condensed to fit the context window ({count} removed)",
548
575
  usingSkill: "Using skill {name}",
549
576
  runInterrupted: "The previous response didn\u2019t finish \u2014 the page changed before it arrived.",
@@ -573,7 +600,20 @@ var DEFAULT_UI_STRINGS = {
573
600
  errorLabel: "Error",
574
601
  declinedLabel: "Declined",
575
602
  details: "Details",
603
+ subAgentWorking: "Working\u2026",
604
+ subAgentSteps: "Steps the sub-agent took",
605
+ approvalEditArgs: "Edit the arguments before approving",
606
+ approvalArgsInvalid: "That is not valid JSON, so nothing was sent.",
607
+ approvalArgsNotAnObject: "Arguments have to be a JSON object.",
608
+ suggestions: "Suggested follow-ups",
609
+ messageActions: "Message actions",
610
+ quoteSelection: "Quote",
611
+ copyMessage: "Copy message",
612
+ retryMessage: "Try again",
613
+ feedbackUp: "Good answer",
614
+ feedbackDown: "Poor answer",
576
615
  confirmAction: "Confirm action",
616
+ confirmAlways: "Always allow",
577
617
  confirmRun: "Run \u201C{tool}\u201D?",
578
618
  confirm: "Confirm",
579
619
  cancel: "Cancel",
@@ -640,6 +680,7 @@ function requestApproval(host, request, options = {}) {
640
680
  body.className = "approval-body";
641
681
  body.setAttribute("part", "approval-body");
642
682
  body.textContent = request.message ?? strings.approvalPrompt;
683
+ const editor = buildEditor(request, options, strings);
643
684
  const actions = document.createElement("div");
644
685
  actions.className = "approval-actions";
645
686
  actions.setAttribute("part", "approval-actions");
@@ -657,10 +698,15 @@ function requestApproval(host, request, options = {}) {
657
698
  resolve(approved);
658
699
  };
659
700
  deny.addEventListener("click", () => close(false));
660
- approve.addEventListener("click", () => close(true));
701
+ approve.addEventListener("click", () => {
702
+ if (editor !== null && !editor.commit()) {
703
+ return;
704
+ }
705
+ close(true);
706
+ });
661
707
  options.signal?.addEventListener("abort", () => close(false), { once: true });
662
708
  actions.append(deny, approve);
663
- card.append(body, actions);
709
+ card.append(body, ...editor === null ? [] : [editor.root], actions);
664
710
  host.appendChild(card);
665
711
  if (options.signal?.aborted === true) {
666
712
  close(false);
@@ -669,6 +715,54 @@ function requestApproval(host, request, options = {}) {
669
715
  approve.focus();
670
716
  });
671
717
  }
718
+ function buildEditor(request, options, strings) {
719
+ const { onEdit } = options;
720
+ if (onEdit === void 0 || request.args === void 0) {
721
+ return null;
722
+ }
723
+ const original = JSON.stringify(request.args, null, 2);
724
+ const root = document.createElement("div");
725
+ root.className = "approval-edit";
726
+ root.setAttribute("part", "approval-edit");
727
+ const field = document.createElement("textarea");
728
+ field.className = "approval-args";
729
+ field.setAttribute("part", "approval-args");
730
+ field.setAttribute("aria-label", strings.approvalEditArgs);
731
+ field.rows = Math.min(10, original.split("\n").length);
732
+ field.value = original;
733
+ const error = document.createElement("div");
734
+ error.className = "approval-error";
735
+ error.setAttribute("part", "approval-error");
736
+ error.setAttribute("role", "alert");
737
+ error.hidden = true;
738
+ root.append(field, error);
739
+ return {
740
+ root,
741
+ commit: () => {
742
+ if (field.value === original) {
743
+ return true;
744
+ }
745
+ let parsed;
746
+ try {
747
+ parsed = JSON.parse(field.value);
748
+ } catch {
749
+ error.textContent = strings.approvalArgsInvalid;
750
+ error.hidden = false;
751
+ field.focus();
752
+ return false;
753
+ }
754
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
755
+ error.textContent = strings.approvalArgsNotAnObject;
756
+ error.hidden = false;
757
+ field.focus();
758
+ return false;
759
+ }
760
+ error.hidden = true;
761
+ onEdit(parsed);
762
+ return true;
763
+ }
764
+ };
765
+ }
672
766
 
673
767
  // src/ui/attach_copy_buttons.ts
674
768
  var CONFIRM_MS = 1500;
@@ -1399,6 +1493,7 @@ var CheckpointMenu = class {
1399
1493
  #heading;
1400
1494
  /** What had focus before the panel opened, restored on close. */
1401
1495
  #lastFocused = null;
1496
+ #formatRelativeTime = null;
1402
1497
  #strings;
1403
1498
  #runs = [];
1404
1499
  constructor(onPick, strings = DEFAULT_UI_STRINGS) {
@@ -1431,6 +1526,21 @@ var CheckpointMenu = class {
1431
1526
  this.#render();
1432
1527
  }
1433
1528
  /** Re-localize a panel built before the host's strings resolved. */
1529
+ /**
1530
+ * Replace the timestamp formatter, or restore the built-in with `null`.
1531
+ *
1532
+ * The built-in is deliberately locale-neutral -- there is no `Intl` anywhere
1533
+ * in this component, so it never disagrees with a host's own formatting by
1534
+ * guessing a locale. That is a defensible default and a poor requirement, so
1535
+ * this is the way out.
1536
+ */
1537
+ setRelativeTimeFormatter(format) {
1538
+ this.#formatRelativeTime = format;
1539
+ }
1540
+ /** This row's timestamp, through the host's formatter when it set one. */
1541
+ #formatTime(timestamp) {
1542
+ return this.#formatRelativeTime !== null ? this.#formatRelativeTime(timestamp) : relativeTime(timestamp, Date.now(), this.#strings);
1543
+ }
1434
1544
  setStrings(strings) {
1435
1545
  this.#strings = strings;
1436
1546
  this.element.setAttribute("aria-label", strings.checkpoints);
@@ -1532,7 +1642,7 @@ var CheckpointMenu = class {
1532
1642
  row.className = "checkpoint-row";
1533
1643
  row.setAttribute("part", "checkpoint-row");
1534
1644
  const preview = previewOf(run);
1535
- const time = run.started_at === null ? null : relativeTime(Date.parse(run.started_at), Date.now(), this.#strings);
1645
+ const time = run.started_at === null ? null : this.#formatTime(Date.parse(run.started_at));
1536
1646
  const label = document.createElement("span");
1537
1647
  label.className = "checkpoint-label";
1538
1648
  label.setAttribute("part", "checkpoint-label");
@@ -1613,6 +1723,7 @@ function requestConfirmation(host, request, options = {}) {
1613
1723
  actions.className = "confirm-actions";
1614
1724
  actions.setAttribute("part", "confirm-actions");
1615
1725
  const cancel = actionButton2("cancel", strings.cancel);
1726
+ const always = options.onAlwaysAllow === void 0 ? null : actionButton2("always", strings.confirmAlways.replace("{tool}", request.toolName));
1616
1727
  const confirm2 = actionButton2("confirm", strings.confirm);
1617
1728
  let settled = false;
1618
1729
  const close = (accepted) => {
@@ -1625,8 +1736,12 @@ function requestConfirmation(host, request, options = {}) {
1625
1736
  };
1626
1737
  cancel.addEventListener("click", () => close(false));
1627
1738
  confirm2.addEventListener("click", () => close(true));
1739
+ always?.addEventListener("click", () => {
1740
+ options.onAlwaysAllow?.();
1741
+ close(true);
1742
+ });
1628
1743
  options.signal?.addEventListener("abort", () => close(false), { once: true });
1629
- actions.append(cancel, confirm2);
1744
+ actions.append(cancel, ...always === null ? [] : [always], confirm2);
1630
1745
  card.append(body, args, actions);
1631
1746
  host.appendChild(card);
1632
1747
  if (options.signal?.aborted === true) {
@@ -1637,6 +1752,349 @@ function requestConfirmation(host, request, options = {}) {
1637
1752
  });
1638
1753
  }
1639
1754
 
1755
+ // src/ui/message_actions.ts
1756
+ var CONFIRM_MS2 = 1500;
1757
+ function attachMessageActions(bubble, options) {
1758
+ if (existingBar(bubble) !== null) {
1759
+ return;
1760
+ }
1761
+ const bar = messageActionBar(bubble, options.strings);
1762
+ const text3 = options.text;
1763
+ if (text3 !== void 0) {
1764
+ bar.appendChild(copyButton(options.strings, text3));
1765
+ }
1766
+ if (options.onFeedback !== void 0) {
1767
+ bar.append(
1768
+ feedbackButton("up", options.strings.feedbackUp, options.onFeedback),
1769
+ feedbackButton("down", options.strings.feedbackDown, options.onFeedback)
1770
+ );
1771
+ }
1772
+ }
1773
+ function messageActionBar(bubble, strings) {
1774
+ const existing = existingBar(bubble);
1775
+ if (existing !== null) {
1776
+ return existing;
1777
+ }
1778
+ const bar = document.createElement("div");
1779
+ bar.className = "message-actions";
1780
+ bar.setAttribute("part", "message-actions");
1781
+ bar.setAttribute("role", "group");
1782
+ bar.setAttribute("aria-label", strings.messageActions);
1783
+ bubble.after(bar);
1784
+ return bar;
1785
+ }
1786
+ function existingBar(bubble) {
1787
+ const next = bubble.nextElementSibling;
1788
+ return next?.classList.contains("message-actions") === true ? next : null;
1789
+ }
1790
+ function messageActionButton(modifier, label, glyph) {
1791
+ const button2 = document.createElement("button");
1792
+ button2.type = "button";
1793
+ button2.className = `message-action message-action--${modifier}`;
1794
+ button2.setAttribute("part", `message-action message-action-${modifier}`);
1795
+ button2.title = label;
1796
+ button2.setAttribute("aria-label", label);
1797
+ const icon = document.createElement("span");
1798
+ icon.setAttribute("aria-hidden", "true");
1799
+ icon.textContent = glyph;
1800
+ button2.appendChild(icon);
1801
+ return button2;
1802
+ }
1803
+ function copyButton(strings, text3) {
1804
+ const button2 = messageActionButton("copy", strings.copyMessage, "\u2398");
1805
+ button2.addEventListener("click", () => {
1806
+ void navigator.clipboard.writeText(text3()).then(
1807
+ () => flash2(button2, strings.copied, strings.copyMessage),
1808
+ // A denied clipboard permission is the common case, not an exception:
1809
+ // say so on the button rather than throwing into an unhandled rejection.
1810
+ () => flash2(button2, strings.copyFailed, strings.copyMessage)
1811
+ );
1812
+ });
1813
+ return button2;
1814
+ }
1815
+ function feedbackButton(rating, label, report) {
1816
+ const button2 = messageActionButton(
1817
+ rating === "up" ? "up" : "down",
1818
+ label,
1819
+ rating === "up" ? "\u{1F44D}" : "\u{1F44E}"
1820
+ );
1821
+ button2.addEventListener("click", () => {
1822
+ const pressed = button2.getAttribute("aria-pressed") === "true";
1823
+ button2.setAttribute("aria-pressed", pressed ? "false" : "true");
1824
+ report(rating);
1825
+ });
1826
+ button2.setAttribute("aria-pressed", "false");
1827
+ return button2;
1828
+ }
1829
+ function flash2(button2, message, label) {
1830
+ button2.title = message;
1831
+ button2.setAttribute("aria-label", message);
1832
+ button2.classList.add("message-action--confirmed");
1833
+ setTimeout(() => {
1834
+ button2.title = label;
1835
+ button2.setAttribute("aria-label", label);
1836
+ button2.classList.remove("message-action--confirmed");
1837
+ }, CONFIRM_MS2);
1838
+ }
1839
+
1840
+ // src/ui/quote_selection.ts
1841
+ var MAX_QUOTE_CHARS = 500;
1842
+ function quotableSelection(container, roots = [], near) {
1843
+ for (const endpoints of endpointCandidates(roots)) {
1844
+ if (!container.contains(endpoints.startContainer)) {
1845
+ continue;
1846
+ }
1847
+ if (!container.contains(endpoints.endContainer)) {
1848
+ continue;
1849
+ }
1850
+ const range = document.createRange();
1851
+ range.setStart(endpoints.startContainer, endpoints.startOffset);
1852
+ range.setEnd(endpoints.endContainer, endpoints.endOffset);
1853
+ const text3 = renderedText(range).trim();
1854
+ if (text3 === "") {
1855
+ continue;
1856
+ }
1857
+ return { text: text3, rect: lineToHangFrom(range, near) };
1858
+ }
1859
+ return null;
1860
+ }
1861
+ function asQuote(text3) {
1862
+ const lines = tidy(text3);
1863
+ if (lines.length === 0) {
1864
+ return "";
1865
+ }
1866
+ const capped = cap(lines.join("\n"));
1867
+ const quoted = capped.split("\n").map((line) => `> ${line}`.trimEnd()).join("\n");
1868
+ return `${quoted}
1869
+
1870
+ `;
1871
+ }
1872
+ function tidy(text3) {
1873
+ const lines = text3.split(/\r\n?|\n/).map((line) => line.trimEnd());
1874
+ const indents = lines.filter((line) => line !== "").map(indentOf);
1875
+ const shared = indents.length === 0 ? 0 : Math.min(...indents);
1876
+ const kept = [];
1877
+ for (const line of lines) {
1878
+ const dedented = line.slice(shared);
1879
+ if (dedented === "" && (kept.length === 0 || kept[kept.length - 1] === "")) {
1880
+ continue;
1881
+ }
1882
+ kept.push(dedented);
1883
+ }
1884
+ while (kept[kept.length - 1] === "") {
1885
+ kept.pop();
1886
+ }
1887
+ return kept;
1888
+ }
1889
+ function indentOf(line) {
1890
+ return line.length - line.trimStart().length;
1891
+ }
1892
+ function cap(text3) {
1893
+ return text3.length > MAX_QUOTE_CHARS ? `${text3.slice(0, MAX_QUOTE_CHARS).trimEnd()}...` : text3;
1894
+ }
1895
+ function renderedText(range) {
1896
+ let text3 = "";
1897
+ for (const node of textNodesIn(range)) {
1898
+ const parent = node.parentElement;
1899
+ if (!isRendered(parent)) {
1900
+ continue;
1901
+ }
1902
+ const from = node === range.startContainer ? range.startOffset : 0;
1903
+ const to = node === range.endContainer ? range.endOffset : node.data.length;
1904
+ text3 += collapse(node.data.slice(from, to), parent);
1905
+ }
1906
+ return text3;
1907
+ }
1908
+ function collapse(text3, parent) {
1909
+ if (PREFORMATTED.has(whiteSpaceOf(parent))) {
1910
+ return text3;
1911
+ }
1912
+ return text3.replace(/[^\S\n]*\n[^\S\n]*/g, "\n").replace(/[^\S\n]+/g, " ");
1913
+ }
1914
+ var PREFORMATTED = /* @__PURE__ */ new Set(["pre", "pre-wrap", "break-spaces"]);
1915
+ function whiteSpaceOf(element) {
1916
+ return window.getComputedStyle(element).whiteSpace;
1917
+ }
1918
+ function textNodesIn(range) {
1919
+ const root = range.commonAncestorContainer;
1920
+ if (root.nodeType === Node.TEXT_NODE) {
1921
+ return [root];
1922
+ }
1923
+ const found = [];
1924
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1925
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
1926
+ if (range.intersectsNode(node)) {
1927
+ found.push(node);
1928
+ }
1929
+ }
1930
+ return found;
1931
+ }
1932
+ function isRendered(element) {
1933
+ if (typeof element.checkVisibility !== "function") {
1934
+ return true;
1935
+ }
1936
+ return element.checkVisibility({
1937
+ contentVisibilityAuto: true,
1938
+ opacityProperty: true,
1939
+ visibilityProperty: true
1940
+ });
1941
+ }
1942
+ function lineToHangFrom(range, near) {
1943
+ const lines = [...range.getClientRects()];
1944
+ if (lines.length === 0) {
1945
+ return range.getBoundingClientRect();
1946
+ }
1947
+ if (near === void 0) {
1948
+ return lines[0];
1949
+ }
1950
+ let closest = lines[0];
1951
+ let shortest = distanceTo(closest, near);
1952
+ for (const line of lines.slice(1)) {
1953
+ const distance = distanceTo(line, near);
1954
+ if (distance < shortest) {
1955
+ shortest = distance;
1956
+ closest = line;
1957
+ }
1958
+ }
1959
+ return closest;
1960
+ }
1961
+ function distanceTo(rect, point) {
1962
+ const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
1963
+ const dy = Math.max(rect.top - point.y, 0, point.y - rect.bottom);
1964
+ return Math.hypot(dx, dy);
1965
+ }
1966
+ function endpointCandidates(roots) {
1967
+ const selection = window.getSelection();
1968
+ if (selection === null) {
1969
+ return [];
1970
+ }
1971
+ const candidates = [...composedRanges(selection, roots)];
1972
+ if (selection.rangeCount > 0) {
1973
+ candidates.push(selection.getRangeAt(0));
1974
+ }
1975
+ return candidates;
1976
+ }
1977
+ function composedRanges(selection, roots) {
1978
+ const composed = selection.getComposedRanges;
1979
+ if (composed === void 0) {
1980
+ return [];
1981
+ }
1982
+ try {
1983
+ return composed.call(selection, { shadowRoots: roots });
1984
+ } catch {
1985
+ return composed.call(selection, ...roots);
1986
+ }
1987
+ }
1988
+
1989
+ // src/ui/page_quote_offer.ts
1990
+ var GAP = 6;
1991
+ var OFFER_CSS = `
1992
+ .ag-ui-quote-offer {
1993
+ position: fixed;
1994
+ z-index: 2147483000;
1995
+ transform: translate(-50%, -100%);
1996
+ margin: 0;
1997
+ padding: 0.25em 0.7em;
1998
+ border: 1px solid rgb(0 0 0 / 0.15);
1999
+ border-radius: 999px;
2000
+ background: Canvas;
2001
+ color: CanvasText;
2002
+ font: inherit;
2003
+ font-size: 0.8rem;
2004
+ line-height: 1.6;
2005
+ white-space: nowrap;
2006
+ cursor: pointer;
2007
+ box-shadow: 0 2px 10px rgb(0 0 0 / 0.18);
2008
+ }
2009
+
2010
+ .ag-ui-quote-offer[data-below="true"] {
2011
+ transform: translate(-50%, 0);
2012
+ }
2013
+ `;
2014
+ function attachQuoteOffer(options) {
2015
+ const { within, exclude, onQuote } = options;
2016
+ const sheet = new CSSStyleSheet();
2017
+ sheet.replaceSync(OFFER_CSS);
2018
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
2019
+ const button2 = document.createElement("button");
2020
+ button2.type = "button";
2021
+ button2.className = "ag-ui-quote-offer";
2022
+ button2.textContent = options.label;
2023
+ button2.hidden = true;
2024
+ document.body.append(button2);
2025
+ let quoting = "";
2026
+ const hide = () => {
2027
+ button2.hidden = true;
2028
+ quoting = "";
2029
+ };
2030
+ const settled = (event) => {
2031
+ if (event.composedPath().includes(exclude)) {
2032
+ hide();
2033
+ return;
2034
+ }
2035
+ if (fieldHasFocus()) {
2036
+ hide();
2037
+ return;
2038
+ }
2039
+ const near = event instanceof MouseEvent ? { x: event.clientX, y: event.clientY } : void 0;
2040
+ const selected = quotableSelection(within, [], near);
2041
+ if (selected === null) {
2042
+ hide();
2043
+ return;
2044
+ }
2045
+ quoting = selected.text;
2046
+ place(button2, selected.rect);
2047
+ };
2048
+ const onMouseDown = (event) => {
2049
+ if (!button2.contains(event.target)) {
2050
+ hide();
2051
+ }
2052
+ };
2053
+ within.addEventListener("mouseup", settled);
2054
+ within.addEventListener("keyup", settled);
2055
+ within.addEventListener("mousedown", onMouseDown);
2056
+ document.addEventListener("scroll", hide, true);
2057
+ window.addEventListener("resize", hide);
2058
+ button2.addEventListener("mousedown", (event) => {
2059
+ event.preventDefault();
2060
+ });
2061
+ button2.addEventListener("click", () => {
2062
+ const text3 = quoting;
2063
+ window.getSelection()?.removeAllRanges();
2064
+ hide();
2065
+ onQuote(text3);
2066
+ });
2067
+ return {
2068
+ element: button2,
2069
+ detach() {
2070
+ within.removeEventListener("mouseup", settled);
2071
+ within.removeEventListener("keyup", settled);
2072
+ within.removeEventListener("mousedown", onMouseDown);
2073
+ document.removeEventListener("scroll", hide, true);
2074
+ window.removeEventListener("resize", hide);
2075
+ button2.remove();
2076
+ document.adoptedStyleSheets = document.adoptedStyleSheets.filter((each) => each !== sheet);
2077
+ }
2078
+ };
2079
+ }
2080
+ function fieldHasFocus() {
2081
+ const active = document.activeElement;
2082
+ if (active === null) {
2083
+ return false;
2084
+ }
2085
+ return active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.isContentEditable === true;
2086
+ }
2087
+ function place(button2, rect) {
2088
+ button2.hidden = false;
2089
+ const below = rect.top < GAP + button2.offsetHeight;
2090
+ button2.dataset["below"] = String(below);
2091
+ button2.style.top = `${below ? rect.bottom + GAP : rect.top - GAP}px`;
2092
+ const half = button2.offsetWidth / 2;
2093
+ const centre = rect.left + rect.width / 2;
2094
+ const width = document.documentElement.clientWidth;
2095
+ button2.style.left = `${Math.min(Math.max(centre, half), width - half)}px`;
2096
+ }
2097
+
1640
2098
  // src/ui/prettify_tool_name.ts
1641
2099
  function prettifyToolName(name) {
1642
2100
  const spaced = name.replace(/[._-]+/g, " ").trim();
@@ -2126,7 +2584,7 @@ function createDOMPurify() {
2126
2584
  const originalDocument = document2;
2127
2585
  const currentScript = originalDocument.currentScript;
2128
2586
  window2.DocumentFragment;
2129
- const HTMLTemplateElement = window2.HTMLTemplateElement, Node2 = window2.Node, Element = window2.Element, NodeFilter = window2.NodeFilter, _window$NamedNodeMap = window2.NamedNodeMap;
2587
+ const HTMLTemplateElement = window2.HTMLTemplateElement, Node2 = window2.Node, Element = window2.Element, NodeFilter2 = window2.NodeFilter, _window$NamedNodeMap = window2.NamedNodeMap;
2130
2588
  _window$NamedNodeMap === void 0 ? window2.NamedNodeMap || window2.MozNamedAttrMap : _window$NamedNodeMap;
2131
2589
  window2.HTMLFormElement;
2132
2590
  const DOMParser = window2.DOMParser, trustedTypes = window2.trustedTypes;
@@ -2723,7 +3181,7 @@ function createDOMPurify() {
2723
3181
  doc || root,
2724
3182
  root,
2725
3183
  // eslint-disable-next-line no-bitwise
2726
- NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_PROCESSING_INSTRUCTION | NodeFilter.SHOW_CDATA_SECTION,
3184
+ NodeFilter2.SHOW_ELEMENT | NodeFilter2.SHOW_COMMENT | NodeFilter2.SHOW_TEXT | NodeFilter2.SHOW_PROCESSING_INSTRUCTION | NodeFilter2.SHOW_CDATA_SECTION,
2727
3185
  null
2728
3186
  );
2729
3187
  };
@@ -2741,7 +3199,7 @@ function createDOMPurify() {
2741
3199
  doc || node,
2742
3200
  node,
2743
3201
  // eslint-disable-next-line no-bitwise
2744
- NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_CDATA_SECTION | NodeFilter.SHOW_PROCESSING_INSTRUCTION,
3202
+ NodeFilter2.SHOW_TEXT | NodeFilter2.SHOW_COMMENT | NodeFilter2.SHOW_CDATA_SECTION | NodeFilter2.SHOW_PROCESSING_INSTRUCTION,
2745
3203
  null
2746
3204
  );
2747
3205
  let currentNode = walker.nextNode();
@@ -4936,6 +5394,60 @@ var SkillsMenu = class {
4936
5394
  }
4937
5395
  };
4938
5396
 
5397
+ // src/ui/stick_to_bottom.ts
5398
+ var BOTTOM_SLACK_PX = 4;
5399
+ function createStickToBottom({
5400
+ viewport,
5401
+ onMissedContent
5402
+ }) {
5403
+ let isFollowing = true;
5404
+ let missed = false;
5405
+ const atBottom = () => viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= BOTTOM_SLACK_PX;
5406
+ const setMissed = (next) => {
5407
+ if (next === missed) {
5408
+ return;
5409
+ }
5410
+ missed = next;
5411
+ onMissedContent(missed);
5412
+ };
5413
+ const toBottom = () => {
5414
+ viewport.scrollTop = viewport.scrollHeight;
5415
+ };
5416
+ const onScroll = () => {
5417
+ isFollowing = atBottom();
5418
+ if (isFollowing) {
5419
+ setMissed(false);
5420
+ }
5421
+ };
5422
+ const follow = () => {
5423
+ if (isFollowing) {
5424
+ toBottom();
5425
+ return;
5426
+ }
5427
+ setMissed(true);
5428
+ };
5429
+ viewport.addEventListener("scroll", onScroll, { passive: true });
5430
+ const observer = new ResizeObserver(() => {
5431
+ if (isFollowing) {
5432
+ toBottom();
5433
+ }
5434
+ });
5435
+ observer.observe(viewport);
5436
+ return {
5437
+ follow,
5438
+ jump: () => {
5439
+ isFollowing = true;
5440
+ setMissed(false);
5441
+ toBottom();
5442
+ },
5443
+ following: () => isFollowing,
5444
+ dispose: () => {
5445
+ viewport.removeEventListener("scroll", onScroll);
5446
+ observer.disconnect();
5447
+ }
5448
+ };
5449
+ }
5450
+
4939
5451
  // src/ui/styles.ts
4940
5452
  var STYLES = `
4941
5453
  /* \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
@@ -5506,6 +6018,7 @@ var STYLES = `
5506
6018
  opacity: 0;
5507
6019
  }
5508
6020
 
6021
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) .messages-wrap,
5509
6022
  :host([collapsed]:is([placement="embedded"], [placement="page"])) .messages,
5510
6023
  :host([collapsed]:is([placement="embedded"], [placement="page"])) .input-row,
5511
6024
  :host([collapsed]:is([placement="embedded"], [placement="page"])) .skill-chips,
@@ -5514,9 +6027,113 @@ var STYLES = `
5514
6027
  display: none;
5515
6028
  }
5516
6029
 
6030
+ /* Jump-to-latest: shown only once the reader has scrolled away *and* missed
6031
+ something. Anchored to the panel rather than the list so it does not scroll
6032
+ with the content it is offering to scroll to. */
6033
+ /* The transcript's own box, and the only one whose foot is the transcript's
6034
+ foot. The panel's foot is below the composer, the chips and the footer. */
6035
+ .messages-wrap {
6036
+ position: relative;
6037
+ flex: 1;
6038
+ min-height: 0;
6039
+ display: flex;
6040
+ flex-direction: column;
6041
+ }
6042
+
6043
+ .jump-latest {
6044
+ position: absolute;
6045
+ left: 50%;
6046
+ transform: translateX(-50%);
6047
+ bottom: var(--_pad);
6048
+ z-index: 2;
6049
+ display: none;
6050
+ align-items: center;
6051
+ gap: 0.35em;
6052
+ padding: 0.4em 0.9em;
6053
+ border: 1px solid var(--_border);
6054
+ border-radius: 999px;
6055
+ /* A raised surface, not the panel's own background. Reusing --_bg made the
6056
+ pill the same colour as everything behind it, leaving a 1px border and a
6057
+ shadow to carry the whole affordance -- and a dark-on-dark shadow carries
6058
+ nothing. --_hover is the token that already means "lifted off the panel",
6059
+ and it separates in both themes without competing with the accent the send
6060
+ button owns. */
6061
+ background: var(--_hover);
6062
+ color: var(--_text);
6063
+ font: inherit;
6064
+ font-size: 0.85em;
6065
+ cursor: pointer;
6066
+ box-shadow: 0 2px 10px rgb(0 0 0 / 0.18);
6067
+ }
6068
+
6069
+ .jump-latest[data-missed="true"] {
6070
+ display: flex;
6071
+ }
6072
+
6073
+ .jump-latest:hover {
6074
+ border-color: var(--_accent);
6075
+ }
6076
+
6077
+ /* The offer to quote a selection. Positioned in script against the transcript
6078
+ box, which is the only ancestor whose top and foot are the transcript's --
6079
+ the same reason .jump-latest lives here. The translate is the half the
6080
+ script does not do: script sets the point the offer hangs from, CSS decides
6081
+ which corner of the offer that point is. */
6082
+ .quote-selection {
6083
+ position: absolute;
6084
+ z-index: 2;
6085
+ transform: translate(-50%, -100%);
6086
+ padding: 0.25em 0.7em;
6087
+ border: 1px solid var(--_border);
6088
+ border-radius: 999px;
6089
+ background: var(--_hover);
6090
+ color: var(--_text);
6091
+ font: inherit;
6092
+ font-size: 0.8em;
6093
+ line-height: 1.6;
6094
+ white-space: nowrap;
6095
+ cursor: pointer;
6096
+ box-shadow: 0 2px 10px rgb(0 0 0 / 0.18);
6097
+ }
6098
+
6099
+ /* Flipped under the selection when there was no room above it. Only the
6100
+ vertical half of the translate changes: it still hangs from its own centre
6101
+ horizontally. */
6102
+ .quote-selection[data-below="true"] {
6103
+ transform: translate(-50%, 0);
6104
+ }
6105
+
6106
+ .quote-selection:hover {
6107
+ border-color: var(--_accent);
6108
+ }
6109
+
6110
+ /* Screen-reader-only status region. Off-screen rather than display:none or
6111
+ visibility:hidden, both of which take the element out of the accessibility
6112
+ tree entirely -- a hidden live region announces nothing at all, which is the
6113
+ classic way this pattern is written wrong.
6114
+
6115
+ The 1px box with clip-path, rather than width/height 0, is the shape that
6116
+ survives: a zero-sized element is dropped from the tree by some engines. */
6117
+ .sr-only {
6118
+ position: absolute;
6119
+ width: 1px;
6120
+ height: 1px;
6121
+ margin: -1px;
6122
+ padding: 0;
6123
+ border: 0;
6124
+ overflow: hidden;
6125
+ white-space: nowrap;
6126
+ clip-path: inset(50%);
6127
+ }
6128
+
5517
6129
  .messages {
5518
6130
  flex: 1;
5519
6131
  overflow-y: auto;
6132
+ /* The browser's own scroll anchoring competes with the scroller for the same
6133
+ job and wins unpredictably -- it can hold the view still exactly when we
6134
+ want to follow. Turned off so following is decided in one place. Safari
6135
+ does not implement it, which is itself a reason not to depend on it. */
6136
+ overflow-anchor: none;
5520
6137
  padding: var(--_pad);
5521
6138
  display: flex;
5522
6139
  flex-direction: column;
@@ -6025,6 +6642,21 @@ var STYLES = `
6025
6642
  color: var(--_fg);
6026
6643
  }
6027
6644
 
6645
+ /* A region a host formatter took over, marked by the card. Preformatted
6646
+ whitespace is what makes the built-in block read as written, and it is the one
6647
+ thing a host cannot want: a table inherits it as mangled cell spacing, and a
6648
+ sentence as line breaks nobody typed.
6649
+
6650
+ Whitespace only. The card's own face, frame, padding and scroll cap stay,
6651
+ because the card is one visual object -- the head row and the status pill are
6652
+ monospaced too -- and a region that dropped the family would be the only part
6653
+ of it wearing a different one. A host that wants that restyles the
6654
+ tool-card-result part, which does not need the formatter at all. */
6655
+ .tool-call-args[data-formatted],
6656
+ .tool-call-result[data-formatted] {
6657
+ white-space: normal;
6658
+ }
6659
+
6028
6660
  /* Display modes are pure visibility over one DOM shape, selected from the host
6029
6661
  attribute rather than a value stamped on the card at build time, so flipping
6030
6662
  data-tool-display re-styles cards already on screen. See ToolCallCard.
@@ -6089,32 +6721,200 @@ var STYLES = `
6089
6721
  display: flex;
6090
6722
  }
6091
6723
 
6092
- .tool-call-toggle {
6093
- align-self: flex-start;
6724
+ /* A delegated sub-agent's progress, inside the card that delegated. Empty on
6725
+ every card that delegated nothing, so it collapses rather than adding a gap
6726
+ to each one -- the same shape the approval slot uses. */
6727
+ .tool-call-subagent:empty {
6728
+ display: none;
6729
+ }
6730
+
6731
+ .tool-call-subagent {
6732
+ display: flex;
6733
+ flex-direction: column;
6734
+ gap: 4px;
6735
+ }
6736
+
6737
+ .subagent {
6738
+ display: flex;
6739
+ flex-direction: column;
6740
+ gap: 4px;
6741
+ min-width: 0;
6742
+ }
6743
+
6744
+ /* The collapsed row is the status and the expander at once, which is what keeps
6745
+ a ten-step child one row until somebody opens it. Full width and left-aligned,
6746
+ because it is a line of the card rather than a button on it. */
6747
+ .subagent-row {
6748
+ display: flex;
6749
+ align-items: center;
6750
+ gap: 6px;
6751
+ width: 100%;
6752
+ box-sizing: border-box;
6753
+ padding: 2px 0;
6094
6754
  border: none;
6095
- padding: 0;
6096
6755
  background: none;
6097
6756
  font: inherit;
6098
- font-weight: 600;
6099
- color: var(--_accent);
6757
+ text-align: left;
6758
+ color: var(--_muted);
6100
6759
  cursor: pointer;
6101
6760
  }
6102
6761
 
6103
- .tool-call-toggle::before {
6104
- content: "\u25B8 ";
6762
+ /* Nothing behind the row yet -- a delegation that failed before calling
6763
+ anything. Drop the affordances rather than offer a control that expands onto
6764
+ an empty region, which is the refusal the card's own toggle already makes. */
6765
+ .subagent-row:disabled {
6766
+ cursor: default;
6105
6767
  }
6106
6768
 
6107
- .tool-call-toggle[aria-expanded="true"]::before {
6108
- content: "\u25BE ";
6769
+ .subagent-row::after {
6770
+ content: "\u25B8";
6771
+ flex: none;
6772
+ margin-left: auto;
6773
+ color: var(--_accent);
6109
6774
  }
6110
6775
 
6111
- /* Resize handle: a corner grip on a floating panel, an edge grip on a docked
6112
- one. Absent entirely where the placement is full-bleed, since there is
6113
- nothing to drag. */
6114
- .resize-handle {
6115
- position: absolute;
6116
- z-index: 2;
6117
- background: transparent;
6776
+ .subagent-row[aria-expanded="true"]::after {
6777
+ content: "\u25BE";
6778
+ }
6779
+
6780
+ .subagent-row:disabled::after {
6781
+ display: none;
6782
+ }
6783
+
6784
+ /* Empty in the DOM; the glyph is drawn here from the panel's data-phase, so a
6785
+ host re-themes it through the same tool-icon custom properties the card uses. */
6786
+ .subagent-icon {
6787
+ flex: none;
6788
+ box-sizing: border-box;
6789
+ display: inline-flex;
6790
+ align-items: center;
6791
+ justify-content: center;
6792
+ width: 10px;
6793
+ height: 10px;
6794
+ font-size: 10px;
6795
+ line-height: 1;
6796
+ }
6797
+
6798
+ /* Anything that is not a terminal phase is the child still working. Selected by
6799
+ what it is not, so a phase this client has not heard of still spins rather
6800
+ than rendering as a blank. */
6801
+ .subagent[data-phase]:not([data-phase="finished"]):not([data-phase="failed"]) .subagent-icon {
6802
+ border: 2px solid var(--_muted);
6803
+ border-top-color: transparent;
6804
+ border-radius: 50%;
6805
+ animation: ag-ui-tool-spin var(--_tool-spin-duration) linear infinite;
6806
+ }
6807
+
6808
+ .subagent[data-phase="finished"] .subagent-icon::before {
6809
+ content: var(--_tool-icon-done);
6810
+ color: var(--_success);
6811
+ }
6812
+
6813
+ .subagent[data-phase="failed"] .subagent-icon::before {
6814
+ content: var(--_tool-icon-error);
6815
+ color: var(--_danger);
6816
+ }
6817
+
6818
+ @media (prefers-reduced-motion: reduce) {
6819
+ .subagent .subagent-icon {
6820
+ animation: none;
6821
+ }
6822
+ }
6823
+
6824
+ /* The server's own pre-rendered line. Shrinks and wraps rather than pushing the
6825
+ chevron out of the card, which is what a fixed-width sibling in a flex row
6826
+ does to a panel at sidebar width. */
6827
+ .subagent-status {
6828
+ flex: 1 1 auto;
6829
+ min-width: 0;
6830
+ overflow-wrap: anywhere;
6831
+ }
6832
+
6833
+ /* The child's own calls. Indented and ruled, so the nesting is visible without
6834
+ a second card frame around it. */
6835
+ .subagent-steps {
6836
+ display: flex;
6837
+ flex-direction: column;
6838
+ gap: 2px;
6839
+ margin-left: 4px;
6840
+ padding-left: 10px;
6841
+ border-left: 1px solid var(--_border);
6842
+ }
6843
+
6844
+ .subagent-steps[hidden] {
6845
+ display: none;
6846
+ }
6847
+
6848
+ .subagent-step {
6849
+ display: flex;
6850
+ align-items: center;
6851
+ gap: 6px;
6852
+ min-width: 0;
6853
+ color: var(--_muted);
6854
+ }
6855
+
6856
+ .subagent-step-icon {
6857
+ flex: none;
6858
+ box-sizing: border-box;
6859
+ display: inline-flex;
6860
+ align-items: center;
6861
+ justify-content: center;
6862
+ width: 8px;
6863
+ height: 8px;
6864
+ font-size: 9px;
6865
+ line-height: 1;
6866
+ }
6867
+
6868
+ /* No outcome yet: the wire says null while the call is in flight, and the
6869
+ absence of the attribute is how that arrives here. A hollow ring, not a
6870
+ spinner -- several can be on screen at once and the row above already spins. */
6871
+ .subagent-step:not([data-ok]) .subagent-step-icon {
6872
+ border: 1px solid var(--_muted);
6873
+ border-radius: 50%;
6874
+ }
6875
+
6876
+ .subagent-step[data-ok="true"] .subagent-step-icon::before {
6877
+ content: var(--_tool-icon-done);
6878
+ color: var(--_success);
6879
+ }
6880
+
6881
+ .subagent-step[data-ok="false"] .subagent-step-icon::before {
6882
+ content: var(--_tool-icon-error);
6883
+ color: var(--_danger);
6884
+ }
6885
+
6886
+ .subagent-step-name {
6887
+ flex: 1 1 auto;
6888
+ min-width: 0;
6889
+ overflow-wrap: anywhere;
6890
+ }
6891
+
6892
+ .tool-call-toggle {
6893
+ align-self: flex-start;
6894
+ border: none;
6895
+ padding: 0;
6896
+ background: none;
6897
+ font: inherit;
6898
+ font-weight: 600;
6899
+ color: var(--_accent);
6900
+ cursor: pointer;
6901
+ }
6902
+
6903
+ .tool-call-toggle::before {
6904
+ content: "\u25B8 ";
6905
+ }
6906
+
6907
+ .tool-call-toggle[aria-expanded="true"]::before {
6908
+ content: "\u25BE ";
6909
+ }
6910
+
6911
+ /* Resize handle: a corner grip on a floating panel, an edge grip on a docked
6912
+ one. Absent entirely where the placement is full-bleed, since there is
6913
+ nothing to drag. */
6914
+ .resize-handle {
6915
+ position: absolute;
6916
+ z-index: 2;
6917
+ background: transparent;
6118
6918
  touch-action: none;
6119
6919
  }
6120
6920
 
@@ -6682,6 +7482,7 @@ var STYLES = `
6682
7482
 
6683
7483
  .confirm-actions {
6684
7484
  display: flex;
7485
+ flex-wrap: wrap;
6685
7486
  gap: 8px;
6686
7487
  justify-content: flex-end;
6687
7488
  }
@@ -6708,6 +7509,99 @@ var STYLES = `
6708
7509
  color: #ffffff;
6709
7510
  }
6710
7511
 
7512
+ /* The session waiver. Deliberately the quietest of the three: it is the widest
7513
+ decision on the card, so it should be reachable without being the one the eye
7514
+ lands on when the user means to say yes once. */
7515
+ .confirm-btn--always {
7516
+ font-weight: 500;
7517
+ opacity: 0.85;
7518
+ }
7519
+
7520
+ .confirm-btn--always:hover,
7521
+ .confirm-btn--always:focus-visible {
7522
+ opacity: 1;
7523
+ }
7524
+
7525
+ /* Editable arguments on an approval card. A plain field rather than a code
7526
+ editor: it holds the JSON a card already displays, and the only interaction
7527
+ is correcting a value before letting the call run. */
7528
+ .approval-edit {
7529
+ display: flex;
7530
+ flex-direction: column;
7531
+ gap: 6px;
7532
+ }
7533
+
7534
+ .approval-args {
7535
+ box-sizing: border-box;
7536
+ width: 100%;
7537
+ resize: vertical;
7538
+ border: 1px solid var(--_border);
7539
+ border-radius: 8px;
7540
+ padding: 8px;
7541
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
7542
+ font-size: 0.85em;
7543
+ background: var(--_bg);
7544
+ color: var(--_fg);
7545
+ }
7546
+
7547
+ .approval-args:focus-visible {
7548
+ border-color: var(--_accent);
7549
+ outline: none;
7550
+ }
7551
+
7552
+ .approval-error {
7553
+ font-size: 0.85em;
7554
+ color: var(--_danger);
7555
+ }
7556
+
7557
+ /* Message action row. Sits under a finished assistant bubble.
7558
+
7559
+ The wrap is insurance rather than a fix: these buttons are glyph-only, so at
7560
+ every width tested they fit on one line and removing the wrap changes
7561
+ nothing. It is here because the confirmation row one release earlier did
7562
+ overflow when it gained a third button, off the left edge and outside its own
7563
+ card, and the difference between the two rows is only that this one's labels
7564
+ are icons today. */
7565
+ .message-actions {
7566
+ display: flex;
7567
+ flex-wrap: wrap;
7568
+ gap: 4px;
7569
+ /* Negative, and that is the point. The answer group is a flex column with its
7570
+ own gap, so a positive margin here pushes the row further from the message
7571
+ it acts on than the next card is below it -- the buttons then read as
7572
+ belonging to whatever follows. Pulling back inside the gap is what makes
7573
+ them the message's own. */
7574
+ margin-top: -6px;
7575
+ }
7576
+
7577
+ .message-action {
7578
+ border: none;
7579
+ border-radius: 6px;
7580
+ padding: 2px 6px;
7581
+ font: inherit;
7582
+ line-height: 1.2;
7583
+ cursor: pointer;
7584
+ background: transparent;
7585
+ color: var(--_muted);
7586
+ opacity: 0.75;
7587
+ }
7588
+
7589
+ .message-action:hover,
7590
+ .message-action:focus-visible {
7591
+ opacity: 1;
7592
+ background: var(--_border);
7593
+ }
7594
+
7595
+ .message-action[aria-pressed="true"] {
7596
+ opacity: 1;
7597
+ color: var(--_accent);
7598
+ }
7599
+
7600
+ .message-action--confirmed {
7601
+ opacity: 1;
7602
+ color: var(--_accent);
7603
+ }
7604
+
6711
7605
  /* Approval card \u2014 the server-side-tool gate (approve/deny an interrupt). */
6712
7606
  .approval {
6713
7607
  align-self: stretch;
@@ -6852,6 +7746,34 @@ var STYLES = `
6852
7746
  border-color: var(--_accent);
6853
7747
  }
6854
7748
 
7749
+ /* Follow-up suggestion chips. Deliberately the skill chips' shape rather than a
7750
+ second chip vocabulary -- both are "a question you could ask", and the only
7751
+ difference is who chose it. Inside the transcript, so they scroll with the
7752
+ answer they follow instead of hovering above the composer. */
7753
+ .suggestions {
7754
+ display: flex;
7755
+ flex-wrap: wrap;
7756
+ gap: 6px;
7757
+ align-self: stretch;
7758
+ }
7759
+
7760
+ .suggestion-chip {
7761
+ border: 1px solid var(--_border);
7762
+ border-radius: 999px;
7763
+ padding: 4px 12px;
7764
+ font: inherit;
7765
+ font-size: 0.9em;
7766
+ text-align: left;
7767
+ cursor: pointer;
7768
+ background: var(--_assistant-bg);
7769
+ color: var(--_fg);
7770
+ }
7771
+
7772
+ .suggestion-chip:hover,
7773
+ .suggestion-chip:focus-visible {
7774
+ border-color: var(--_accent);
7775
+ }
7776
+
6855
7777
  .skill-palette {
6856
7778
  margin: 8px 12px 0;
6857
7779
  display: flex;
@@ -7266,6 +8188,195 @@ var STYLES = `
7266
8188
  }
7267
8189
  `;
7268
8190
 
8191
+ // src/ui/subagent_panel.ts
8192
+ var SubAgentPanel = class {
8193
+ /** The panel's root; append this into the delegating card's slot. */
8194
+ element;
8195
+ /**
8196
+ * The collapsed row, which is the expander as well as the status.
8197
+ *
8198
+ * Disabled while the child has called nothing, so a delegation that failed
8199
+ * before it started offers no control that expands onto an empty region —
8200
+ * the same refusal the card's own Details toggle already makes.
8201
+ */
8202
+ #row;
8203
+ #status;
8204
+ #steps;
8205
+ /** The child's tool calls, keyed by the child's own call id. */
8206
+ #stepRows = /* @__PURE__ */ new Map();
8207
+ constructor(strings = DEFAULT_UI_STRINGS) {
8208
+ this.element = document.createElement("div");
8209
+ this.element.className = "subagent";
8210
+ this.element.setAttribute("part", "subagent");
8211
+ this.#row = document.createElement("button");
8212
+ this.#row.type = "button";
8213
+ this.#row.className = "subagent-row";
8214
+ this.#row.setAttribute("part", "subagent-row");
8215
+ this.#row.setAttribute("aria-expanded", "false");
8216
+ this.#row.disabled = true;
8217
+ const icon = document.createElement("span");
8218
+ icon.className = "subagent-icon";
8219
+ icon.setAttribute("part", "subagent-icon");
8220
+ icon.setAttribute("aria-hidden", "true");
8221
+ this.#status = document.createElement("span");
8222
+ this.#status.className = "subagent-status";
8223
+ this.#status.setAttribute("part", "subagent-status");
8224
+ this.#status.textContent = strings.subAgentWorking;
8225
+ this.#row.append(icon, this.#status);
8226
+ this.#steps = document.createElement("div");
8227
+ this.#steps.className = "subagent-steps";
8228
+ this.#steps.setAttribute("part", "subagent-steps");
8229
+ this.#steps.setAttribute("role", "list");
8230
+ this.#steps.setAttribute("aria-label", strings.subAgentSteps);
8231
+ this.#steps.hidden = true;
8232
+ this.#row.addEventListener("click", () => {
8233
+ this.#setExpanded(this.#row.getAttribute("aria-expanded") !== "true");
8234
+ });
8235
+ this.element.append(this.#row, this.#steps);
8236
+ }
8237
+ /**
8238
+ * Fold one announcement in.
8239
+ *
8240
+ * Every field is applied only when the update actually carried it, so a phase
8241
+ * that says nothing about the agent or the status leaves both as they stand.
8242
+ * That is what lets `finished` be two keys wide on the wire without blanking
8243
+ * the row it closes.
8244
+ */
8245
+ report(update) {
8246
+ this.element.setAttribute("data-phase", update.phase);
8247
+ if (update.agent !== null) {
8248
+ this.element.setAttribute("data-agent", update.agent);
8249
+ }
8250
+ if (update.status !== null) {
8251
+ this.#status.textContent = update.status;
8252
+ }
8253
+ if (update.tool !== null) {
8254
+ this.#recordStep(update.tool);
8255
+ }
8256
+ }
8257
+ /**
8258
+ * Open or settle one of the child's calls, keyed by its own id.
8259
+ *
8260
+ * The absence of `data-ok` is what "still running" looks like, mirroring the
8261
+ * wire's `null` rather than inventing a third value for it — so the attribute
8262
+ * is removed on the way in and written on the way out.
8263
+ */
8264
+ #recordStep(tool) {
8265
+ const row = this.#stepRows.get(tool.toolCallId) ?? this.#createStep(tool);
8266
+ if (tool.ok === null) {
8267
+ row.removeAttribute("data-ok");
8268
+ return;
8269
+ }
8270
+ row.setAttribute("data-ok", String(tool.ok));
8271
+ }
8272
+ #createStep(tool) {
8273
+ const row = document.createElement("div");
8274
+ row.className = "subagent-step";
8275
+ row.setAttribute("part", "subagent-step");
8276
+ row.setAttribute("role", "listitem");
8277
+ row.setAttribute("data-tool-call-id", tool.toolCallId);
8278
+ const icon = document.createElement("span");
8279
+ icon.className = "subagent-step-icon";
8280
+ icon.setAttribute("part", "subagent-step-icon");
8281
+ icon.setAttribute("aria-hidden", "true");
8282
+ const name = document.createElement("span");
8283
+ name.className = "subagent-step-name";
8284
+ name.setAttribute("part", "subagent-step-name");
8285
+ name.textContent = tool.name;
8286
+ row.append(icon, name);
8287
+ this.#steps.appendChild(row);
8288
+ this.#stepRows.set(tool.toolCallId, row);
8289
+ this.#row.disabled = false;
8290
+ return row;
8291
+ }
8292
+ #setExpanded(expanded) {
8293
+ this.#steps.hidden = !expanded;
8294
+ this.#row.setAttribute("aria-expanded", String(expanded));
8295
+ }
8296
+ };
8297
+
8298
+ // src/ui/subagent_update.ts
8299
+ var PHASES = Object.values(SUBAGENT_PHASE);
8300
+ function asRecord(value) {
8301
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
8302
+ return null;
8303
+ }
8304
+ return value;
8305
+ }
8306
+ function asText(value) {
8307
+ return typeof value === "string" && value !== "" ? value : null;
8308
+ }
8309
+ function asTool(value) {
8310
+ const record = asRecord(value);
8311
+ if (record === null) {
8312
+ return null;
8313
+ }
8314
+ const toolCallId = asText(record["toolCallId"]);
8315
+ const name = asText(record["name"]);
8316
+ const ok = record["ok"];
8317
+ if (toolCallId === null || name === null) {
8318
+ return null;
8319
+ }
8320
+ if (ok !== null && typeof ok !== "boolean") {
8321
+ return null;
8322
+ }
8323
+ return { toolCallId, name, ok };
8324
+ }
8325
+ function subAgentUpdate(value) {
8326
+ const record = asRecord(value);
8327
+ if (record === null) {
8328
+ return null;
8329
+ }
8330
+ const delegationId = asText(record["delegationId"]);
8331
+ const phase = record["phase"];
8332
+ if (delegationId === null || typeof phase !== "string" || !PHASES.includes(phase)) {
8333
+ return null;
8334
+ }
8335
+ return {
8336
+ delegationId,
8337
+ phase,
8338
+ agent: asText(record["agent"]),
8339
+ status: asText(record["status"]),
8340
+ tool: asTool(record["tool"])
8341
+ };
8342
+ }
8343
+
8344
+ // src/ui/suggestion_chips.ts
8345
+ var MAX_SUGGESTIONS = 4;
8346
+ var MAX_SUGGESTION_CHARS = 120;
8347
+ function suggestionPrompts(content) {
8348
+ if (typeof content !== "object" || content === null) {
8349
+ return null;
8350
+ }
8351
+ const raw = content.prompts;
8352
+ if (!Array.isArray(raw)) {
8353
+ return null;
8354
+ }
8355
+ const prompts = raw.filter((prompt) => typeof prompt === "string").map((prompt) => prompt.trim()).filter((prompt) => prompt !== "" && prompt.length <= MAX_SUGGESTION_CHARS).slice(0, MAX_SUGGESTIONS);
8356
+ return prompts.length === 0 ? null : prompts;
8357
+ }
8358
+ function renderSuggestionChips(content, strings, onPick) {
8359
+ const prompts = suggestionPrompts(content);
8360
+ if (prompts === null) {
8361
+ return null;
8362
+ }
8363
+ const row = document.createElement("div");
8364
+ row.className = "suggestions";
8365
+ row.setAttribute("part", "suggestions");
8366
+ row.setAttribute("role", "group");
8367
+ row.setAttribute("aria-label", strings.suggestions);
8368
+ for (const prompt of prompts) {
8369
+ const chip = document.createElement("button");
8370
+ chip.type = "button";
8371
+ chip.className = "suggestion-chip";
8372
+ chip.setAttribute("part", "suggestion-chip");
8373
+ chip.textContent = prompt;
8374
+ chip.addEventListener("click", () => onPick(prompt));
8375
+ row.appendChild(chip);
8376
+ }
8377
+ return row;
8378
+ }
8379
+
7269
8380
  // src/ui/thoughts_block.ts
7270
8381
  var ThoughtsBlock = class {
7271
8382
  /** The block's root element; insert this at the top of the answer group. */
@@ -7332,6 +8443,7 @@ var ThreadDrawer = class {
7332
8443
  #heading;
7333
8444
  #newButton;
7334
8445
  #list;
8446
+ #formatRelativeTime = null;
7335
8447
  #strings;
7336
8448
  #threads = [];
7337
8449
  #activeId = "";
@@ -7379,6 +8491,21 @@ var ThreadDrawer = class {
7379
8491
  this.element.append(backdrop, this.#panel);
7380
8492
  }
7381
8493
  /** Re-localize the drawer's chrome and rows (the host calls this on connect). */
8494
+ /**
8495
+ * Replace the timestamp formatter, or restore the built-in with `null`.
8496
+ *
8497
+ * The built-in is deliberately locale-neutral -- there is no `Intl` anywhere
8498
+ * in this component, so it never disagrees with a host's own formatting by
8499
+ * guessing a locale. That is a defensible default and a poor requirement, so
8500
+ * this is the way out.
8501
+ */
8502
+ setRelativeTimeFormatter(format) {
8503
+ this.#formatRelativeTime = format;
8504
+ }
8505
+ /** This row's timestamp, through the host's formatter when it set one. */
8506
+ #formatTime(timestamp) {
8507
+ return this.#formatRelativeTime !== null ? this.#formatRelativeTime(timestamp) : relativeTime(timestamp, void 0, this.#strings);
8508
+ }
7382
8509
  setStrings(strings) {
7383
8510
  this.#strings = strings;
7384
8511
  this.#panel.setAttribute("aria-label", strings.chatHistory);
@@ -7478,7 +8605,7 @@ var ThreadDrawer = class {
7478
8605
  const time = document.createElement("span");
7479
8606
  time.className = "drawer-row-time";
7480
8607
  time.setAttribute("part", "drawer-row-time");
7481
- time.textContent = relativeTime(meta.updatedAt, void 0, this.#strings);
8608
+ time.textContent = this.#formatTime(meta.updatedAt);
7482
8609
  const preview = document.createElement("span");
7483
8610
  preview.className = "drawer-row-preview";
7484
8611
  preview.setAttribute("part", "drawer-row-preview");
@@ -7620,6 +8747,22 @@ var ToolCallCard = class {
7620
8747
  * Empty until used, and hidden while empty by the shadow CSS.
7621
8748
  */
7622
8749
  approvalSlot;
8750
+ /**
8751
+ * Where a *nested* run's progress renders — the sub-agent this call delegated
8752
+ * to, narrating itself while the card waits.
8753
+ *
8754
+ * A slot rather than a rendered thing, on the same reasoning as
8755
+ * {@link approvalSlot}: the card owns the position and something else owns the
8756
+ * content. What makes the position right is that the wire keys a delegation on
8757
+ * this card's own `toolCallId`, so the run being narrated is the one this card
8758
+ * already stands for.
8759
+ *
8760
+ * Placed above the Details toggle rather than inside the body, because the
8761
+ * body is what the display modes hide — and a progress line that only appears
8762
+ * in `full` mode would leave exactly the stall it exists to end. Empty on every
8763
+ * card that delegated nothing, and hidden while empty by the shadow CSS.
8764
+ */
8765
+ subagentSlot;
7623
8766
  #status;
7624
8767
  #decision;
7625
8768
  #toggle;
@@ -7627,9 +8770,23 @@ var ToolCallCard = class {
7627
8770
  #resultLabel;
7628
8771
  #resultBody;
7629
8772
  #strings;
8773
+ /**
8774
+ * The arguments this call was made with.
8775
+ *
8776
+ * Retained rather than only rendered, because an approval interrupt names a
8777
+ * `toolCallId` and nothing else -- so this card is the only place the args
8778
+ * still exist when the user is asked to approve, edit or deny the call.
8779
+ */
8780
+ args;
8781
+ /** The raw tool name, kept for the payloads handed to {@link ToolPayloadFormatter}. */
8782
+ #name;
8783
+ #formatPayload;
7630
8784
  #settled = false;
7631
- constructor(name, args, summary, strings = DEFAULT_UI_STRINGS) {
8785
+ constructor(name, args, summary, strings = DEFAULT_UI_STRINGS, options = {}) {
7632
8786
  this.#strings = strings;
8787
+ this.args = args;
8788
+ this.#name = name;
8789
+ this.#formatPayload = options.formatPayload ?? null;
7633
8790
  this.element = document.createElement("div");
7634
8791
  this.element.className = "tool-call";
7635
8792
  this.element.setAttribute("part", "tool-card");
@@ -7657,7 +8814,11 @@ var ToolCallCard = class {
7657
8814
  this.#decision.hidden = true;
7658
8815
  head.append(icon, label, this.#status, this.#decision);
7659
8816
  const argsSection = this.#section("args", strings.argumentsLabel);
7660
- argsSection.body.textContent = JSON.stringify(args, null, 2);
8817
+ this.#renderPayload(
8818
+ argsSection.body,
8819
+ { kind: "arguments", toolName: name, args },
8820
+ JSON.stringify(args, null, 2)
8821
+ );
7661
8822
  argsSection.root.hidden = Object.keys(args).length === 0;
7662
8823
  const resultSection = this.#section("result", strings.resultLabel);
7663
8824
  this.#resultSection = resultSection.root;
@@ -7678,7 +8839,10 @@ var ToolCallCard = class {
7678
8839
  this.approvalSlot = document.createElement("div");
7679
8840
  this.approvalSlot.className = "tool-call-approval";
7680
8841
  this.approvalSlot.setAttribute("part", "tool-card-approval");
7681
- this.element.append(head, this.#toggle, body, this.approvalSlot);
8842
+ this.subagentSlot = document.createElement("div");
8843
+ this.subagentSlot.className = "tool-call-subagent";
8844
+ this.subagentSlot.setAttribute("part", "tool-card-subagent");
8845
+ this.element.append(head, this.subagentSlot, this.#toggle, body, this.approvalSlot);
7682
8846
  }
7683
8847
  /**
7684
8848
  * Move between the two states that are not an outcome — `pending` (running)
@@ -7720,9 +8884,42 @@ var ToolCallCard = class {
7720
8884
  this.element.setAttribute("data-status", status);
7721
8885
  this.#status.textContent = statusLabels(this.#strings)[status];
7722
8886
  this.#resultLabel.textContent = resultLabels(this.#strings)[status];
7723
- this.#resultBody.textContent = formatPayload(text3);
8887
+ this.#renderPayload(
8888
+ this.#resultBody,
8889
+ { kind: "result", toolName: this.#name, status, text: text3 },
8890
+ formatPayload(text3)
8891
+ );
7724
8892
  this.#resultSection.hidden = false;
7725
8893
  }
8894
+ /**
8895
+ * Fill one body region, offering it to the host formatter first.
8896
+ *
8897
+ * `fallback` is computed by the caller rather than here because the two
8898
+ * regions build it differently -- arguments are already parsed, a result is a
8899
+ * string that may or may not be JSON -- and because a formatter that takes
8900
+ * the region over should not have paid for a pretty-print nobody sees. It is
8901
+ * cheap either way; the point is that the built-in rendering stays written
8902
+ * once, beside the payload it belongs to.
8903
+ *
8904
+ * The `data-formatted` marker is what the shadow CSS reads to relax the
8905
+ * preformatted whitespace on a region a host owns -- a table inherits it as
8906
+ * mangled cell spacing, a sentence as line breaks nobody typed. Marked for a
8907
+ * returned string too: one rule, and a summary line wants ordinary wrapping
8908
+ * as much as a table does.
8909
+ */
8910
+ #renderPayload(body, payload, fallback) {
8911
+ const rendered = this.#formatPayload === null ? null : this.#formatPayload(payload);
8912
+ if (rendered === null) {
8913
+ body.textContent = fallback;
8914
+ return;
8915
+ }
8916
+ body.setAttribute("data-formatted", "true");
8917
+ if (typeof rendered === "string") {
8918
+ body.textContent = rendered;
8919
+ return;
8920
+ }
8921
+ body.replaceChildren(rendered);
8922
+ }
7726
8923
  /** Build one labelled region of the body: a heading plus a payload block. */
7727
8924
  #section(kind, labelText) {
7728
8925
  const root = document.createElement("div");
@@ -7926,6 +9123,7 @@ var AgUiClient = class {
7926
9123
  */
7927
9124
  #closedMessageIds = /* @__PURE__ */ new Set();
7928
9125
  #connectionLostMessage;
9126
+ #maxToolRounds;
7929
9127
  // Set by cancel(); reset at the top of each #run(). Checked by the loop so
7930
9128
  // a cancel between frontend-tool rounds doesn't start another round.
7931
9129
  #cancelled = false;
@@ -7939,6 +9137,8 @@ var AgUiClient = class {
7939
9137
  this.#onPersist = config.onPersist ?? (() => {
7940
9138
  });
7941
9139
  this.#connectionLostMessage = config.connectionLostMessage ?? "Connection lost";
9140
+ const rounds = config.maxToolRounds ?? MAX_TOOL_ROUNDS;
9141
+ this.#maxToolRounds = rounds >= 1 ? Math.floor(rounds) : MAX_TOOL_ROUNDS;
7942
9142
  const onStateChanged = config.onStateChanged;
7943
9143
  if (onStateChanged !== void 0) {
7944
9144
  this.#agent.subscribe({
@@ -7969,7 +9169,8 @@ var AgUiClient = class {
7969
9169
  *
7970
9170
  * When the agent calls frontend tools, this executes them and re-runs the
7971
9171
  * agent with the results, looping until the agent stops calling frontend
7972
- * tools (bounded by {@link MAX_TOOL_ROUNDS}).
9172
+ * tools (bounded by {@link AgUiClientConfig.maxToolRounds}, which defaults to
9173
+ * {@link MAX_TOOL_ROUNDS}).
7973
9174
  *
7974
9175
  * `attachments` ride on the user message as a non-standard field so the
7975
9176
  * default store round-trips them for history replay; see
@@ -7984,6 +9185,41 @@ var AgUiClient = class {
7984
9185
  this.#onPersist(this.#agent.messages);
7985
9186
  await this.#run();
7986
9187
  }
9188
+ /**
9189
+ * Drop everything after the most recent user message, so the same question
9190
+ * can be asked again.
9191
+ *
9192
+ * Returns the retained history, or `null` when there is nothing to retry (no
9193
+ * user message has been sent yet). **Truncates only** -- the caller re-renders
9194
+ * from the returned list and then calls {@link resume}, because the transcript
9195
+ * belongs to the element and a client that reached into it would own two
9196
+ * things. Running here instead would stream the new answer in underneath the
9197
+ * old one.
9198
+ *
9199
+ * Re-running answers the question the agent was last asked, rather than
9200
+ * telling it its answer was wrong, which is what makes the result a
9201
+ * *different* answer instead of a conversation about the previous one.
9202
+ *
9203
+ * **A retried turn re-runs its tools.** For a page-driving agent that is not
9204
+ * neutral: the previous attempt already clicked what it clicked, and this
9205
+ * does not undo it.
9206
+ */
9207
+ truncateToLastUser() {
9208
+ const messages = [...this.#agent.messages];
9209
+ let lastUser = -1;
9210
+ for (const [index, message] of messages.entries()) {
9211
+ if (message.role === "user") {
9212
+ lastUser = index;
9213
+ }
9214
+ }
9215
+ if (lastUser === -1) {
9216
+ return null;
9217
+ }
9218
+ const kept = messages.slice(0, lastUser + 1);
9219
+ this.#agent.setMessages(kept);
9220
+ this.#onPersist(this.#agent.messages);
9221
+ return kept;
9222
+ }
7987
9223
  /**
7988
9224
  * Resume the run loop after a navigating tool's result was supplied
7989
9225
  * post-reload (via {@link addToolResult}). Unlike {@link send}, adds no user
@@ -8031,7 +9267,7 @@ var AgUiClient = class {
8031
9267
  }
8032
9268
  async #runLoop() {
8033
9269
  let resume;
8034
- for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
9270
+ for (let round = 0; round < this.#maxToolRounds; round += 1) {
8035
9271
  if (this.#cancelled) {
8036
9272
  return;
8037
9273
  }
@@ -8150,6 +9386,12 @@ var AgUiClient = class {
8150
9386
  // Emitted after the client has written the patched messages, which is the
8151
9387
  // first moment the result exists. Only the ids marked above are looked at,
8152
9388
  // so an ordinary text delta does not walk the transcript.
9389
+ onCustomEvent({ event }) {
9390
+ h.onCustomEvent(event.name, event.value);
9391
+ },
9392
+ onMessagesSnapshotEvent({ event }) {
9393
+ h.onMessagesSnapshot(event.messages);
9394
+ },
8153
9395
  onMessagesChanged({ messages }) {
8154
9396
  if (pendingDeltas.size === 0) {
8155
9397
  return;
@@ -8853,6 +10095,7 @@ function isCredentialsMode(value) {
8853
10095
  var COLLAPSED_KEY = "ag-ui-chat:collapsed";
8854
10096
  var SIZE_KEY = "ag-ui-chat:size";
8855
10097
  var THEME_KEY = "ag-ui-chat:theme";
10098
+ var QUOTE_GAP = 6;
8856
10099
  var CLAIMED_NAMESPACES = /* @__PURE__ */ new Set();
8857
10100
  var AgUiChat = class extends HTMLElement {
8858
10101
  /** Agent factory; override to inject a custom or fake agent (tests). */
@@ -8900,6 +10143,22 @@ var AgUiChat = class extends HTMLElement {
8900
10143
  * data. Enable only when the content source is trusted.
8901
10144
  */
8902
10145
  allowImages = false;
10146
+ /**
10147
+ * Replace the relative timestamps in the thread drawer and the checkpoint
10148
+ * panel -- `"5m ago"`, `"2d ago"` -- with the host's own formatting.
10149
+ *
10150
+ * The built-in is locale-neutral on purpose: there is no `Intl` anywhere in
10151
+ * this component, so it never disagrees with the page it is embedded in by
10152
+ * guessing a locale. That is a good default and a bad requirement, which is
10153
+ * what this is for.
10154
+ *
10155
+ * ```js
10156
+ * const rtf = new Intl.RelativeTimeFormat("de", { numeric: "auto" });
10157
+ * chat.formatRelativeTime = (ts) =>
10158
+ * rtf.format(Math.round((ts - Date.now()) / 60000), "minute");
10159
+ * ```
10160
+ */
10161
+ formatRelativeTime = null;
8903
10162
  /** When true, destructive tools execute without a confirmation modal. */
8904
10163
  autoConfirm = false;
8905
10164
  /**
@@ -8923,6 +10182,22 @@ var AgUiChat = class extends HTMLElement {
8923
10182
  * is enabled server-side; this only changes how the decision is collected.
8924
10183
  */
8925
10184
  approvalRenderer = null;
10185
+ /**
10186
+ * Let the user edit a gated call's arguments before approving it.
10187
+ *
10188
+ * Off by default and **an assertion about your server**, not a negotiation:
10189
+ * AG-UI carries `editedArgs` in the resume payload and gates it on the
10190
+ * agent's own `approveWithEdits` capability, which this component never sees
10191
+ * -- capabilities are not on the wire it reads. So the host says whether its
10192
+ * agent honours them. Turned on against a server that does not, the user
10193
+ * would edit arguments it silently discards, which is worse than not
10194
+ * offering.
10195
+ *
10196
+ * Only affects calls whose arguments are known here: an interrupt names a
10197
+ * `toolCallId`, and the tool card for that call is where the arguments still
10198
+ * are. An interrupt naming no card gets the plain approve/deny.
10199
+ */
10200
+ approveWithEdits = false;
8926
10201
  /**
8927
10202
  * Optional per-call confirmation predicate. When set it is authoritative,
8928
10203
  * deciding from the tool name and args whether this particular call needs
@@ -9013,6 +10288,30 @@ var AgUiChat = class extends HTMLElement {
9013
10288
  * schema never reaches the browser. Client tools should prefer `x-summary`.
9014
10289
  */
9015
10290
  toolSummaries = {};
10291
+ /**
10292
+ * Optional presentation hook for the two payload regions of a tool-call card
10293
+ * -- the arguments and the result. Unset (the default) leaves both
10294
+ * pretty-printed as JSON.
10295
+ *
10296
+ * The seam exists because a wide result has no good rendering as JSON: a
10297
+ * thirty-field row is a wall of text where the host wanted a table, or a
10298
+ * sentence. `ClientTool.render` cannot answer it -- it is handed the
10299
+ * *arguments* only, and a server-side tool has no `ClientTool` at all, so the
10300
+ * result region was the one part of the transcript a host could not reach.
10301
+ *
10302
+ * **Presentation, not translation.** The card and the model already read
10303
+ * separate copies of a tool result: the model's is maintained by
10304
+ * `@ag-ui/client` from the same event and persisted with the history, and the
10305
+ * card has always shown that string reformatted. So a formatter changes what
10306
+ * the person reads and nothing the agent reads -- which makes restyling safe
10307
+ * and *rewording* a way to make the card disagree with the prose beside it.
10308
+ * Rename a value on the server, where it reaches both.
10309
+ *
10310
+ * Read at render time rather than captured, so a host that sets it from a
10311
+ * framework effect after the first card still formats the results that settle
10312
+ * afterwards. See {@link ToolPayloadFormatter}.
10313
+ */
10314
+ formatToolPayload = null;
9016
10315
  /**
9017
10316
  * Localizable UI strings — a partial override merged over the English
9018
10317
  * {@link DEFAULT_UI_STRINGS}. Resolved once on connect (so set it before the
@@ -9062,13 +10361,32 @@ var AgUiChat = class extends HTMLElement {
9062
10361
  #toolRegistry = new ClientToolRegistry();
9063
10362
  /** Tool-call cards awaiting execution, keyed by call id. */
9064
10363
  #toolCards = /* @__PURE__ */ new Map();
10364
+ /**
10365
+ * The live delegation panels, keyed by the **parent's** `delegate_task` call
10366
+ * id — which is what the wire keys a sub-agent's progress on, so this map and
10367
+ * {@link #toolCards} answer to the same key.
10368
+ *
10369
+ * Kept beside the cards rather than on them, so a card stays a card: the tool
10370
+ * card holds the slot and this holds what went into it, the same division the
10371
+ * approval prompt already uses.
10372
+ */
10373
+ #subagentPanels = /* @__PURE__ */ new Map();
9065
10374
  /**
9066
10375
  * Call ids whose card was already settled from a streamed server-side result
9067
10376
  * (`TOOL_CALL_RESULT`), so the post-run executeTool sweep doesn't overwrite
9068
10377
  * the real output with the generic "executed on the server" fallback.
9069
10378
  */
9070
10379
  /** Whether a server-pushed chart activity is drawn. Off unless asked for. */
9071
- #chartActivity = false;
10380
+ /**
10381
+ * Which `activity_type`s this element can draw, by name.
10382
+ *
10383
+ * A registry rather than a branch because `activity_type` is an open string
10384
+ * the protocol does not enumerate. The two built-ins go through it like any
10385
+ * host registration, which is the test that the seam is real.
10386
+ */
10387
+ #activityRenderers = /* @__PURE__ */ new Map();
10388
+ /** Types that arrived with nobody registered to draw them. See {@link unhandledActivityTypes}. */
10389
+ #unhandledActivityTypes = /* @__PURE__ */ new Set();
9072
10390
  /** Card elements by call id, so a rendering handler can find its own card. */
9073
10391
  #cardElements = /* @__PURE__ */ new Map();
9074
10392
  /** Chart blocks by activity message id, so an update redraws in place. */
@@ -9080,7 +10398,75 @@ var AgUiChat = class extends HTMLElement {
9080
10398
  * Spans tool rounds and an approval interrupt; cleared when the event fires.
9081
10399
  */
9082
10400
  #runTools = [];
10401
+ /**
10402
+ * Keys announced during this interaction, de-duplicated in first-seen order.
10403
+ *
10404
+ * Per element, never module-level: a second mounted chat is a second run, and
10405
+ * sharing this would tell one page to refetch on the other's writes. Reset by
10406
+ * {@link AgUiChat.#dispatchRunFinished}, which is the one place that has read
10407
+ * it.
10408
+ */
10409
+ /**
10410
+ * Tool names the user waived confirmation for, for the life of this element.
10411
+ *
10412
+ * Per instance and never persisted: a session decision that outlived the tab
10413
+ * would be a permanent grant made by one click, which is the thing
10414
+ * `autoConfirm` already exists to say deliberately. Cleared with the element.
10415
+ */
10416
+ #sessionApproved = /* @__PURE__ */ new Set();
10417
+ /**
10418
+ * The one action row currently carrying Retry, if any.
10419
+ *
10420
+ * Retry belongs to the **last** turn only: re-running an older one is
10421
+ * branching, and for a page-driving agent editing a past turn is not neutral
10422
+ * -- those turns clicked buttons, and re-running turn 3 does not un-save what
10423
+ * turn 5 saved. Holding a single owner is what keeps exactly one offer on
10424
+ * screen without per-bubble bookkeeping.
10425
+ */
10426
+ #retryOwner = null;
10427
+ #runInvalidated = /* @__PURE__ */ new Set();
9083
10428
  #root;
10429
+ /** Screen-reader-only status region -- see {@link AgUiChat.#announce}. */
10430
+ #announcer = document.createElement("div");
10431
+ /** Return-to-foot affordance, shown only once something has been missed. */
10432
+ #jumpButton = document.createElement("button");
10433
+ /**
10434
+ * Offer to quote the current selection, floated beside it.
10435
+ *
10436
+ * Shares {@link AgUiChat.#messagesWrap} with the jump button for the same
10437
+ * reason: it is positioned against the transcript, and must not scroll away
10438
+ * with the words it is pointing at.
10439
+ */
10440
+ #quoteButton = document.createElement("button");
10441
+ /** What {@link AgUiChat.#quoteButton} would quote, while it is showing. */
10442
+ #quoting = "";
10443
+ /** The host-page offer, while one is attached; see {@link AgUiChat.offerQuoteInPage}. */
10444
+ #pageQuote = null;
10445
+ /**
10446
+ * Positioning context for {@link AgUiChat.#jumpButton}.
10447
+ *
10448
+ * The button cannot live in the scrolling list -- it would scroll away with
10449
+ * the content it is offering to scroll to -- and it cannot be positioned
10450
+ * against the panel either: the panel's foot is below the composer, the skill
10451
+ * chips and the footer, so `bottom` measured from there lands the button on
10452
+ * top of the composer rather than over the transcript. This wrapper is the
10453
+ * only box whose foot *is* the transcript's foot.
10454
+ */
10455
+ #messagesWrap = document.createElement("div");
10456
+ /** Follows the foot of the transcript, and stops when the reader scrolls away. */
10457
+ #scroller;
10458
+ /** Pending clear of {@link AgUiChat.#announcer}; see why it is cleared at all. */
10459
+ #announceTimer = null;
10460
+ /**
10461
+ * Whether this turn already announced how it ended.
10462
+ *
10463
+ * `onSettled` is the terminal guarantee and fires however the run ended, so
10464
+ * it is the only place that can promise the user hears *something*. But a
10465
+ * stopped or failed run has already said the truer thing from `onCancelled`
10466
+ * or `onError`, and "assistant answered" after "response stopped" is worse
10467
+ * than silence.
10468
+ */
10469
+ #announcedOutcome = false;
9084
10470
  #chat;
9085
10471
  #messages;
9086
10472
  #input;
@@ -9198,6 +10584,23 @@ var AgUiChat = class extends HTMLElement {
9198
10584
  this.#launcher = document.createElement("button");
9199
10585
  this.#badge = document.createElement("span");
9200
10586
  this.#emptyWrap = document.createElement("div");
10587
+ this.registerActivityRenderer({
10588
+ type: COMPACTION_ACTIVITY_TYPE,
10589
+ render: (content) => {
10590
+ const removed = compactionRemoved(content);
10591
+ return removed === null ? null : renderRunNotice(
10592
+ "\u{1F5DC}",
10593
+ this.#strings.historyCompacted.replace("{count}", String(removed)),
10594
+ "compaction"
10595
+ );
10596
+ }
10597
+ });
10598
+ this.registerActivityRenderer({
10599
+ type: SUGGESTIONS_ACTIVITY_TYPE,
10600
+ render: (content) => renderSuggestionChips(content, this.#strings, (prompt) => {
10601
+ void this.sendMessage(prompt);
10602
+ })
10603
+ });
9201
10604
  this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
9202
10605
  this.#drawer = new ThreadDrawer({
9203
10606
  onSelect: (threadId) => {
@@ -9282,6 +10685,7 @@ var AgUiChat = class extends HTMLElement {
9282
10685
  /** Load the checkpoint panel with the runs that can actually be continued. */
9283
10686
  async #refreshCheckpoints() {
9284
10687
  const index = this.#runs();
10688
+ this.#checkpoints.setRelativeTimeFormatter(this.formatRelativeTime);
9285
10689
  this.#checkpoints.setRuns(index === null ? [] : await index.continuable());
9286
10690
  }
9287
10691
  /** Attributes the element reacts to after it has been connected. */
@@ -9478,7 +10882,7 @@ var AgUiChat = class extends HTMLElement {
9478
10882
  });
9479
10883
  this.#confirmAbort = null;
9480
10884
  this.#updateEmptyState();
9481
- this.#messages.scrollTop = this.#messages.scrollHeight;
10885
+ this.#scroller.follow();
9482
10886
  return answer;
9483
10887
  }
9484
10888
  /**
@@ -9734,8 +11138,15 @@ var AgUiChat = class extends HTMLElement {
9734
11138
  this.#claimedNs = null;
9735
11139
  }
9736
11140
  this.#cancelRun();
11141
+ this.#pageQuote?.detach();
11142
+ this.#pageQuote = null;
9737
11143
  this.#attachTray?.dispose();
9738
11144
  this.#voice?.dispose();
11145
+ this.#scroller.dispose();
11146
+ if (this.#announceTimer !== null) {
11147
+ clearTimeout(this.#announceTimer);
11148
+ this.#announceTimer = null;
11149
+ }
9739
11150
  }
9740
11151
  /**
9741
11152
  * Read an opt-in flag attribute the way HTML reads a boolean attribute.
@@ -9846,6 +11257,142 @@ var AgUiChat = class extends HTMLElement {
9846
11257
  this.#onInput();
9847
11258
  this.#input.focus();
9848
11259
  }
11260
+ /**
11261
+ * Put `text` into the composer as a markdown quotation, and focus it.
11262
+ *
11263
+ * Deliberately **not** a send. Quoting is how a question narrows to one part
11264
+ * of an answer, so the quotation is the preamble and the question is what
11265
+ * comes next -- the caret is left after it, on its own line.
11266
+ *
11267
+ * This is also the seam for the half of this feature the component cannot
11268
+ * build: selection in the **host page**. A widget mounted beside a table can
11269
+ * be asked about a row, and nothing in a chat's own transcript can offer
11270
+ * that. A host reads its own selection, however it likes, and calls this.
11271
+ *
11272
+ * No-ops on text that is empty or only whitespace.
11273
+ */
11274
+ quote(text3) {
11275
+ const quoted = asQuote(text3);
11276
+ if (quoted === "") {
11277
+ return;
11278
+ }
11279
+ const current = this.#input.value.replace(/\s+$/, "");
11280
+ this.#input.value = current === "" ? quoted : `${current}
11281
+
11282
+ ${quoted}`;
11283
+ this.#autoGrow();
11284
+ this.#input.focus();
11285
+ const end = this.#input.value.length;
11286
+ this.#input.setSelectionRange(end, end);
11287
+ }
11288
+ /**
11289
+ * Offer to quote what the user selects in the **host page**, not just in the
11290
+ * transcript. Returns a function that stops offering.
11291
+ *
11292
+ * The same select-then-offer gesture, over a table, a diff, a report -- the
11293
+ * surface the user actually works in, which is the half of quoting no hosted
11294
+ * chat can reach. Opt-in, because it listens on the host's document and that
11295
+ * is theirs to grant.
11296
+ *
11297
+ * Deliberately **not** a four-line recipe, which is how this shipped first
11298
+ * and was wrong: a page listener that quotes every settled selection appends
11299
+ * to the composer on every drag made to read, to copy or to fix a typo -- and
11300
+ * it cannot tell a selection in the page's prose from one inside the user's
11301
+ * own half-typed `<input>`, because Chrome reports a field's internal
11302
+ * selection as an ordinary range over the field's *wrapper*. See
11303
+ * {@link attachQuoteOffer} for the guards.
11304
+ *
11305
+ * Detached automatically when the element leaves the document; a host that
11306
+ * re-mounts it calls this again.
11307
+ */
11308
+ offerQuoteInPage(within = document.body) {
11309
+ this.#pageQuote?.detach();
11310
+ const offer = attachQuoteOffer({
11311
+ within,
11312
+ label: this.#strings.quoteSelection,
11313
+ exclude: this,
11314
+ onQuote: (text3) => this.quote(text3)
11315
+ });
11316
+ this.#pageQuote = offer;
11317
+ return () => {
11318
+ offer.detach();
11319
+ if (this.#pageQuote === offer) {
11320
+ this.#pageQuote = null;
11321
+ }
11322
+ };
11323
+ }
11324
+ /** Whether the transcript offers to quote what the user selects. */
11325
+ #quoteEnabled() {
11326
+ return this.getAttribute("data-quote-selection") !== "false";
11327
+ }
11328
+ /**
11329
+ * Offer to quote the settled selection, or retire the offer.
11330
+ *
11331
+ * `event` is passed for its coordinates and only those: they say which line
11332
+ * of a selection spanning several messages the offer should hang from. A
11333
+ * keyboard selection has none, and the first line is used instead.
11334
+ */
11335
+ #onSelectionSettled(event) {
11336
+ if (!this.#quoteEnabled()) {
11337
+ return;
11338
+ }
11339
+ const near = event === void 0 ? void 0 : { x: event.clientX, y: event.clientY };
11340
+ const selected = quotableSelection(this.#messages, [this.#root], near);
11341
+ if (selected === null) {
11342
+ this.#hideQuote();
11343
+ return;
11344
+ }
11345
+ this.#quoting = selected.text;
11346
+ this.#placeQuote(selected.rect);
11347
+ }
11348
+ /** Float the offer beside `rect`, kept inside the transcript's own box. */
11349
+ #placeQuote(rect) {
11350
+ this.#quoteButton.hidden = false;
11351
+ const wrap = this.#messagesWrap.getBoundingClientRect();
11352
+ const top = rect.top - wrap.top;
11353
+ const below = top < QUOTE_GAP + this.#quoteButton.offsetHeight;
11354
+ this.#quoteButton.dataset["below"] = String(below);
11355
+ this.#quoteButton.style.top = `${below ? rect.bottom - wrap.top + QUOTE_GAP : top - QUOTE_GAP}px`;
11356
+ const half = this.#quoteButton.offsetWidth / 2;
11357
+ const centre = rect.left + rect.width / 2 - wrap.left;
11358
+ this.#quoteButton.style.left = `${Math.min(Math.max(centre, half), wrap.width - half)}px`;
11359
+ }
11360
+ /** Retire the offer, and forget what it was pointing at. */
11361
+ #hideQuote() {
11362
+ this.#quoteButton.hidden = true;
11363
+ this.#quoting = "";
11364
+ }
11365
+ /**
11366
+ * The tool-round budget from `data-max-tool-rounds`, for one send.
11367
+ *
11368
+ * Anything unparseable becomes `NaN`, which {@link AgUiClient} rejects along
11369
+ * with a bound below one -- so the two ways of setting this are validated in
11370
+ * one place rather than agreeing by coincidence.
11371
+ */
11372
+ #maxToolRounds() {
11373
+ const attr = this.getAttribute("data-max-tool-rounds");
11374
+ return attr === null ? MAX_TOOL_ROUNDS : Number.parseInt(attr, 10);
11375
+ }
11376
+ /**
11377
+ * Which message actions a finished bubble offers, from
11378
+ * `data-message-actions`.
11379
+ *
11380
+ * Absent means all of them, so the attribute only ever subtracts: the row
11381
+ * shipped without an off switch and a host that never sets this must keep
11382
+ * exactly what it had. A value names the survivors, which makes
11383
+ * `data-message-actions="false"` -- the spelling its sibling
11384
+ * `data-quote-selection` uses -- an empty set by falling out of the same rule
11385
+ * rather than by a case of its own.
11386
+ */
11387
+ #messageActions() {
11388
+ const attr = this.getAttribute("data-message-actions");
11389
+ if (attr === null) {
11390
+ return new Set(Object.values(MESSAGE_ACTIONS));
11391
+ }
11392
+ return new Set(
11393
+ attr.split(",").map((token) => token.trim()).filter((token) => token !== "")
11394
+ );
11395
+ }
9849
11396
  /** The client-side upload size cap from `data-attachment-max-bytes`. */
9850
11397
  #attachmentMaxBytes() {
9851
11398
  const attr = this.getAttribute("data-attachment-max-bytes");
@@ -10375,19 +11922,63 @@ var AgUiChat = class extends HTMLElement {
10375
11922
  /** Drop the in-memory run + transcript, leaving the thread id untouched. */
10376
11923
  #resetState() {
10377
11924
  this.#client = null;
11925
+ this.#clearTranscript();
11926
+ this.#initialMessages = [];
11927
+ }
11928
+ /**
11929
+ * Wipe the rendered transcript and everything that indexes into it.
11930
+ *
11931
+ * Split from {@link #resetState} because a retry re-renders the transcript
11932
+ * while keeping the *client*: dropping the client there would take the
11933
+ * agent's message list with it, which is the thing being truncated.
11934
+ */
11935
+ #clearTranscript() {
10378
11936
  this.#endStream();
10379
11937
  this.#currentGroup = null;
10380
11938
  this.#thoughts = null;
10381
11939
  this.#hidePending();
10382
11940
  this.#toolCards.clear();
11941
+ this.#subagentPanels.clear();
10383
11942
  this.#serverSettled.clear();
10384
11943
  this.#cardElements.clear();
10385
11944
  this.#activityBlocks.clear();
10386
- this.#initialMessages = [];
11945
+ this.#retryOwner = null;
10387
11946
  this.#attachTray?.clear();
10388
11947
  this.#messages.replaceChildren(this.#emptyWrap);
10389
11948
  this.#updateEmptyState();
10390
11949
  }
11950
+ /**
11951
+ * Ask the same question again and replace the answer.
11952
+ *
11953
+ * History is truncated to the most recent user message inclusive and the run
11954
+ * repeats, so the agent answers what it was asked rather than being told its
11955
+ * last answer was wrong. Returns `false` when there is nothing to retry or a
11956
+ * run is already in flight.
11957
+ *
11958
+ * Public because a host with its own message UI wants the same button, and
11959
+ * because the failed-run notice reaches it from outside the action row.
11960
+ *
11961
+ * **A retried turn re-runs its tools**, which for a page-driving agent is not
11962
+ * neutral: the previous attempt already clicked what it clicked, and this
11963
+ * does not undo it. Confirmation still applies, so a destructive tool asks
11964
+ * again -- unless the user waived it for this session.
11965
+ */
11966
+ async retryLastTurn() {
11967
+ if (this.#running) {
11968
+ return false;
11969
+ }
11970
+ const client = this.#ensureClient();
11971
+ const kept = client.truncateToLastUser();
11972
+ if (kept === null) {
11973
+ return false;
11974
+ }
11975
+ this.#clearTranscript();
11976
+ for (const message of kept) {
11977
+ this.#renderHistoricMessage(message);
11978
+ }
11979
+ await client.resume();
11980
+ return true;
11981
+ }
10391
11982
  /** Switch the active conversation to an existing thread and replay it. */
10392
11983
  async #switchThread(threadId) {
10393
11984
  if (threadId === this.#threadId) {
@@ -10416,6 +12007,7 @@ var AgUiChat = class extends HTMLElement {
10416
12007
  }
10417
12008
  /** Reload the drawer's thread list, marking the active thread. */
10418
12009
  async #refreshDrawer() {
12010
+ this.#drawer.setRelativeTimeFormatter(this.formatRelativeTime);
10419
12011
  this.#drawer.setThreads(await this.conversationStore.listThreads(), this.#threadId);
10420
12012
  }
10421
12013
  /**
@@ -10507,7 +12099,9 @@ var AgUiChat = class extends HTMLElement {
10507
12099
  }
10508
12100
  if (message.role === MESSAGE_ROLE.ASSISTANT) {
10509
12101
  if (text3 !== "") {
10510
- this.appendMessage(MESSAGE_ROLE.ASSISTANT, text3).classList.add("message--restored");
12102
+ const restoredBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, text3);
12103
+ restoredBubble.classList.add("message--restored");
12104
+ this.#attachActions(restoredBubble);
10511
12105
  }
10512
12106
  for (const call of restoredToolCalls(message.toolCalls)) {
10513
12107
  const restored = {
@@ -10528,8 +12122,8 @@ var AgUiChat = class extends HTMLElement {
10528
12122
  }
10529
12123
  if (message.role === "activity") {
10530
12124
  const activity = message;
10531
- if (activity.activityType === CHART_ACTIVITY_TYPE && this.#chartActivity) {
10532
- this.#drawActivityChart(message.id, activity.content);
12125
+ if (typeof activity.activityType === "string") {
12126
+ this.#drawActivity(message.id, activity.activityType, activity.content);
10533
12127
  }
10534
12128
  return;
10535
12129
  }
@@ -10594,7 +12188,11 @@ var AgUiChat = class extends HTMLElement {
10594
12188
  this.#messages.appendChild(bubble);
10595
12189
  }
10596
12190
  this.#updateEmptyState();
10597
- this.#messages.scrollTop = this.#messages.scrollHeight;
12191
+ if (role === MESSAGE_ROLE.USER) {
12192
+ this.#scroller.jump();
12193
+ } else {
12194
+ this.#scroller.follow();
12195
+ }
10598
12196
  return bubble;
10599
12197
  }
10600
12198
  /**
@@ -10616,8 +12214,6 @@ var AgUiChat = class extends HTMLElement {
10616
12214
  return this.#currentGroup;
10617
12215
  }
10618
12216
  #render() {
10619
- const style = document.createElement("style");
10620
- style.textContent = STYLES;
10621
12217
  this.#chat.className = "chat";
10622
12218
  this.#chat.setAttribute("part", "panel");
10623
12219
  const header = document.createElement("div");
@@ -10641,8 +12237,8 @@ var AgUiChat = class extends HTMLElement {
10641
12237
  checkpoints.addEventListener("click", () => this.toggleCheckpoints());
10642
12238
  const newChat = this.#headerButton("new", this.#strings.newChat, "\u271A");
10643
12239
  newChat.addEventListener("click", () => this.newChat());
10644
- const collapse = this.#headerButton("collapse", this.#strings.collapse, "\u2014");
10645
- collapse.addEventListener("click", () => this.toggleCollapsed());
12240
+ const collapse2 = this.#headerButton("collapse", this.#strings.collapse, "\u2014");
12241
+ collapse2.addEventListener("click", () => this.toggleCollapsed());
10646
12242
  if (this.#runs() !== null) {
10647
12243
  controls.append(history, checkpoints, newChat);
10648
12244
  } else {
@@ -10658,13 +12254,46 @@ var AgUiChat = class extends HTMLElement {
10658
12254
  this.#syncThemeGlyph();
10659
12255
  controls.append(this.#themeToggle);
10660
12256
  }
10661
- controls.append(collapse);
12257
+ controls.append(collapse2);
10662
12258
  header.append(title, headerActions, controls);
10663
12259
  this.#messages.className = "messages";
10664
12260
  this.#messages.setAttribute("part", "messages");
10665
12261
  this.#messages.setAttribute("role", "log");
10666
- this.#messages.setAttribute("aria-live", "polite");
12262
+ this.#messages.setAttribute("aria-live", "off");
10667
12263
  this.#messages.setAttribute("aria-label", this.#strings.conversation);
12264
+ this.#jumpButton.className = "jump-latest";
12265
+ this.#jumpButton.type = "button";
12266
+ this.#jumpButton.setAttribute("part", "jump-latest");
12267
+ this.#jumpButton.textContent = this.#strings.jumpToLatest;
12268
+ this.#jumpButton.addEventListener("click", () => {
12269
+ this.#scroller.jump();
12270
+ });
12271
+ this.#quoteButton.className = "quote-selection";
12272
+ this.#quoteButton.type = "button";
12273
+ this.#quoteButton.setAttribute("part", "quote-selection");
12274
+ this.#quoteButton.textContent = this.#strings.quoteSelection;
12275
+ this.#quoteButton.hidden = true;
12276
+ this.#quoteButton.addEventListener("mousedown", (event) => {
12277
+ event.preventDefault();
12278
+ });
12279
+ this.#quoteButton.addEventListener("click", () => {
12280
+ this.quote(this.#quoting);
12281
+ window.getSelection()?.removeAllRanges();
12282
+ this.#hideQuote();
12283
+ });
12284
+ this.#messages.addEventListener("mouseup", (event) => this.#onSelectionSettled(event));
12285
+ this.#messages.addEventListener("keyup", () => this.#onSelectionSettled());
12286
+ this.#messages.addEventListener("mousedown", () => this.#hideQuote());
12287
+ this.#scroller = createStickToBottom({
12288
+ viewport: this.#messages,
12289
+ onMissedContent: (missed) => {
12290
+ this.#jumpButton.dataset["missed"] = String(missed);
12291
+ }
12292
+ });
12293
+ this.#announcer.className = "sr-only";
12294
+ this.#announcer.setAttribute("role", "status");
12295
+ this.#announcer.setAttribute("aria-live", "polite");
12296
+ this.#announcer.setAttribute("aria-atomic", "true");
10668
12297
  this.#emptyWrap.className = "empty";
10669
12298
  this.#emptyWrap.setAttribute("part", "empty");
10670
12299
  const emptySlot = document.createElement("slot");
@@ -10727,9 +12356,11 @@ var AgUiChat = class extends HTMLElement {
10727
12356
  tools.append(this.#attachButton, this.#voiceSlot, this.#send);
10728
12357
  composer.append(this.#input, tools);
10729
12358
  inputRow.append(composer, this.#fileInput);
12359
+ this.#messagesWrap.className = "messages-wrap";
12360
+ this.#messagesWrap.append(this.#messages, this.#jumpButton, this.#quoteButton);
10730
12361
  this.#chat.append(
10731
12362
  header,
10732
- this.#messages,
12363
+ this.#messagesWrap,
10733
12364
  this.#skillsMenu.palette,
10734
12365
  this.#skillsMenu.chips,
10735
12366
  this.#skillHint,
@@ -10775,7 +12406,66 @@ var AgUiChat = class extends HTMLElement {
10775
12406
  label: this.#strings.resizePanel
10776
12407
  })
10777
12408
  );
10778
- this.#root.append(style, this.#chat, this.#launcher);
12409
+ this.#adoptStyles();
12410
+ this.#root.append(this.#announcer, this.#chat, this.#launcher);
12411
+ }
12412
+ /**
12413
+ * Attach the stylesheet without an inline `<style>` element.
12414
+ *
12415
+ * A host with a strict `style-src` and no `'unsafe-inline'` drops an injected
12416
+ * `<style>` silently: the component mounts, functions, and renders completely
12417
+ * unstyled, with nothing in the console to point at. `adoptedStyleSheets`
12418
+ * carries no inline-style origin, so it is unaffected by that policy.
12419
+ *
12420
+ * The sheet is constructed **per instance** rather than shared at module
12421
+ * scope. A shared sheet would additionally avoid re-parsing the stylesheet
12422
+ * once per mounted element, which is what `adoptedStyleSheets` is usually
12423
+ * reached for -- but a module-level singleton is exactly what this package
12424
+ * forbids, and the CSP defect is fixed either way. Per instance is no worse
12425
+ * than the `<style>` element it replaces, which also parsed once per mount.
12426
+ *
12427
+ * No fallback: constructible `CSSStyleSheet` is Chrome 73, Firefox 101 and
12428
+ * Safari 16.4, all below this package's declared Safari 17 runtime target. A
12429
+ * guard here would be code no supported browser can reach, and the only way
12430
+ * to keep it would be to exempt it from the coverage gate.
12431
+ */
12432
+ /**
12433
+ * Say one short thing to a screen reader, without touching the transcript.
12434
+ *
12435
+ * The transcript cannot do this job. It is rewritten on every animation
12436
+ * frame while an answer streams, so as a live region it re-announced the
12437
+ * whole answer tens of times per turn -- not merely unhelpful but actively
12438
+ * hostile. The published fix for this exact bug (Microsoft's Bot Framework
12439
+ * WebChat #3236) is architectural rather than a matter of tuning attributes:
12440
+ * demote the visible transcript out of live-region duty and put one
12441
+ * synthesised status per event into a separate invisible region. MDN and
12442
+ * Scott O'Hara prescribe the same empty-region-then-inject shape.
12443
+ *
12444
+ * Roughly four calls land per turn -- responding, answered, a card is waiting,
12445
+ * stopped or failed -- so the user is told what happened and reads the answer
12446
+ * itself by navigating the log, at their own pace, rather than having it
12447
+ * shouted at them a token at a time.
12448
+ *
12449
+ * **The clear is load-bearing, twice.** A reader announces a live region when
12450
+ * its content *changes*, so setting the same string twice running -- two turns
12451
+ * in a row both starting -- is not a change and is silently not announced.
12452
+ * Emptying first makes the next set a change again. It also stops a stale
12453
+ * status being read out when a reader later lands on the region.
12454
+ */
12455
+ #announce(message) {
12456
+ if (this.#announceTimer !== null) {
12457
+ clearTimeout(this.#announceTimer);
12458
+ }
12459
+ this.#announcer.textContent = message;
12460
+ this.#announceTimer = setTimeout(() => {
12461
+ this.#announceTimer = null;
12462
+ this.#announcer.textContent = "";
12463
+ }, ANNOUNCE_CLEAR_MS);
12464
+ }
12465
+ #adoptStyles() {
12466
+ const sheet = new CSSStyleSheet();
12467
+ sheet.replaceSync(STYLES);
12468
+ this.#root.adoptedStyleSheets = [sheet];
10779
12469
  }
10780
12470
  /**
10781
12471
  * Build a header control button: a named slot a host can project markup into,
@@ -11071,7 +12761,8 @@ var AgUiChat = class extends HTMLElement {
11071
12761
  resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
11072
12762
  onPersist: (messages) => this.conversationStore.saveMessages(this.#threadId, messages),
11073
12763
  onStateChanged: (state) => this.#onSharedStateChanged(state),
11074
- connectionLostMessage: this.#strings.connectionLost
12764
+ connectionLostMessage: this.#strings.connectionLost,
12765
+ maxToolRounds: this.#maxToolRounds()
11075
12766
  });
11076
12767
  }
11077
12768
  return this.#client;
@@ -11087,15 +12778,78 @@ var AgUiChat = class extends HTMLElement {
11087
12778
  })
11088
12779
  );
11089
12780
  }
11090
- /** Whether ``call`` should be gated behind the confirmation card. */
11091
- async #needsConfirmation(call, tool) {
12781
+ /**
12782
+ * Give a finished assistant bubble its action row, and hand it Retry.
12783
+ *
12784
+ * Every finished bubble gets copy and feedback -- both are safe on a message
12785
+ * of any age. Retry moves to the newest, because it is the only one where
12786
+ * re-running answers the same question rather than rewriting history.
12787
+ *
12788
+ * `data-message-actions` subtracts from that. The row is built only when
12789
+ * something survives to go in it: an empty row still takes its margin, still
12790
+ * answers to the `message-actions` part, and still reads to a screen reader
12791
+ * as a group of actions with none in it.
12792
+ */
12793
+ #attachActions(bubble, options = {}) {
12794
+ const enabled = this.#messageActions();
12795
+ const copyable = enabled.has(MESSAGE_ACTIONS.COPY);
12796
+ const rateable = options.rateable !== false && enabled.has(MESSAGE_ACTIONS.FEEDBACK);
12797
+ if (copyable || rateable) {
12798
+ attachMessageActions(bubble, {
12799
+ strings: this.#strings,
12800
+ // Read at click time, not captured: a bubble rendered from markdown
12801
+ // holds its text in the DOM, and that is what the user sees and means
12802
+ // to copy.
12803
+ ...copyable ? { text: () => bubble.textContent } : {},
12804
+ ...rateable ? {
12805
+ onFeedback: (rating) => {
12806
+ this.dispatchEvent(
12807
+ new CustomEvent(FEEDBACK_EVENT, {
12808
+ detail: { content: bubble.textContent, rating },
12809
+ bubbles: true,
12810
+ composed: true
12811
+ })
12812
+ );
12813
+ }
12814
+ } : {}
12815
+ });
12816
+ }
12817
+ if (enabled.has(MESSAGE_ACTIONS.RETRY)) {
12818
+ this.#moveRetryTo(messageActionBar(bubble, this.#strings));
12819
+ }
12820
+ }
12821
+ /** Move the Retry button onto `bar`, taking it off whoever held it. */
12822
+ #moveRetryTo(bar) {
12823
+ this.#retryOwner?.querySelector(".message-action--retry")?.remove();
12824
+ const retry = messageActionButton("retry", this.#strings.retryMessage, "\u21BB");
12825
+ retry.addEventListener("click", () => {
12826
+ void this.retryLastTurn();
12827
+ });
12828
+ bar.prepend(retry);
12829
+ this.#retryOwner = bar;
12830
+ }
12831
+ /**
12832
+ * Which rule gates `call`, or `null` when it runs straight through.
12833
+ *
12834
+ * The rule, rather than a bare boolean, because it decides whether the user
12835
+ * may *waive* the prompt for the rest of the session. Only the default
12836
+ * `x-destructive` gate is waivable: `confirmPredicate` is documented as
12837
+ * authoritative, so letting one click retire it would silently defeat a host
12838
+ * policy — and the session allowlist is consulted on the same path it can
12839
+ * be added from, so the button is never offered where honouring it would be
12840
+ * refused.
12841
+ */
12842
+ async #confirmationRule(call, tool) {
11092
12843
  if (this.autoConfirm) {
11093
- return false;
12844
+ return null;
11094
12845
  }
11095
12846
  if (this.confirmPredicate !== null) {
11096
- return await this.confirmPredicate(call.name, call.args) === true;
12847
+ return await this.confirmPredicate(call.name, call.args) === true ? "predicate" : null;
12848
+ }
12849
+ if (this.#sessionApproved.has(call.name)) {
12850
+ return null;
11097
12851
  }
11098
- return isDestructive(tool.parameters);
12852
+ return isDestructive(tool.parameters) ? "destructive" : null;
11099
12853
  }
11100
12854
  async #executeTool(call) {
11101
12855
  if (skillNameFrom(call) !== null) {
@@ -11117,7 +12871,8 @@ var AgUiChat = class extends HTMLElement {
11117
12871
  this.#showPending();
11118
12872
  return { content: `Error: ${message}`, error: message };
11119
12873
  }
11120
- if (await this.#needsConfirmation(call, tool)) {
12874
+ const rule = await this.#confirmationRule(call, tool);
12875
+ if (rule !== null) {
11121
12876
  const request = { toolName: call.name, args: call.args };
11122
12877
  const confirmText = tool.parameters[X_CONFIRM_KEY];
11123
12878
  if (typeof confirmText === "string") {
@@ -11126,10 +12881,12 @@ var AgUiChat = class extends HTMLElement {
11126
12881
  this.#confirmAbort = new AbortController();
11127
12882
  const decision = requestConfirmation(this.#ensureGroup(), request, {
11128
12883
  signal: this.#confirmAbort.signal,
11129
- strings: this.#strings
12884
+ strings: this.#strings,
12885
+ // Offered only where it can be honoured -- see `#confirmationRule`.
12886
+ ...rule === "destructive" ? { onAlwaysAllow: () => this.#sessionApproved.add(call.name) } : {}
11130
12887
  });
11131
12888
  this.#updateEmptyState();
11132
- this.#messages.scrollTop = this.#messages.scrollHeight;
12889
+ this.#scroller.follow();
11133
12890
  const accepted = await decision;
11134
12891
  this.#confirmAbort = null;
11135
12892
  card.recordDecision(accepted ? "approved" : "declined");
@@ -11191,6 +12948,9 @@ var AgUiChat = class extends HTMLElement {
11191
12948
  */
11192
12949
  async #resolveInterrupts(interrupts) {
11193
12950
  this.#confirmAbort = new AbortController();
12951
+ this.#announce(
12952
+ this.#strings.announceAwaitingDecision.replace("{count}", String(interrupts.length))
12953
+ );
11194
12954
  this.#hidePending();
11195
12955
  const signal = this.#confirmAbort.signal;
11196
12956
  const answered = await Promise.all(
@@ -11205,10 +12965,20 @@ var AgUiChat = class extends HTMLElement {
11205
12965
  if (toolName !== null && toolName !== void 0) {
11206
12966
  request.toolName = toolName;
11207
12967
  }
12968
+ let editedArgs;
12969
+ const editable = this.approveWithEdits && card !== void 0;
12970
+ if (editable) {
12971
+ request.args = card.args;
12972
+ }
11208
12973
  card?.mark(TOOL_CALL_STATUS.DEFERRED);
11209
12974
  const approved = this.approvalRenderer !== null ? await this.approvalRenderer(request, { signal }) : await requestApproval(card?.approvalSlot ?? this.#ensureGroup(), request, {
11210
12975
  signal,
11211
- strings: this.#strings
12976
+ strings: this.#strings,
12977
+ ...editable ? {
12978
+ onEdit: (args) => {
12979
+ editedArgs = args;
12980
+ }
12981
+ } : {}
11212
12982
  });
11213
12983
  card?.recordDecision(approved ? "approved" : "declined");
11214
12984
  if (approved) {
@@ -11216,21 +12986,28 @@ var AgUiChat = class extends HTMLElement {
11216
12986
  } else {
11217
12987
  card?.settle(TOOL_CALL_STATUS.DECLINED, this.#strings.declinedAction);
11218
12988
  }
11219
- return { id: interrupt.id, approved };
12989
+ return { id: interrupt.id, approved, editedArgs };
11220
12990
  })
11221
12991
  );
11222
12992
  this.#updateEmptyState();
11223
- this.#messages.scrollTop = this.#messages.scrollHeight;
12993
+ this.#scroller.follow();
11224
12994
  this.#confirmAbort = null;
11225
12995
  const responses = {};
11226
- for (const { id, approved } of answered) {
11227
- responses[id] = approved ? { status: "resolved", payload: { approved: true } } : { status: "cancelled" };
12996
+ for (const { id, approved, editedArgs } of answered) {
12997
+ responses[id] = approved ? {
12998
+ status: "resolved",
12999
+ payload: editedArgs === void 0 ? { approved: true } : { approved: true, editedArgs }
13000
+ } : { status: "cancelled" };
11228
13001
  }
11229
13002
  return responses;
11230
13003
  }
11231
13004
  #handlers() {
11232
13005
  return {
11233
13006
  onRunStart: () => {
13007
+ if (!this.#running) {
13008
+ this.#announcedOutcome = false;
13009
+ this.#announce(this.#strings.announceResponding);
13010
+ }
11234
13011
  this.#setRunning(true);
11235
13012
  this.#ensureGroup();
11236
13013
  this.#showPending();
@@ -11256,6 +13033,7 @@ var AgUiChat = class extends HTMLElement {
11256
13033
  this.#revealWords(bubble);
11257
13034
  }
11258
13035
  attachCopyButtons(bubble, this.#strings);
13036
+ this.#attachActions(bubble);
11259
13037
  this.#endStream();
11260
13038
  this.#noteUnread();
11261
13039
  },
@@ -11268,25 +13046,28 @@ var AgUiChat = class extends HTMLElement {
11268
13046
  this.#cardFor(call);
11269
13047
  },
11270
13048
  onActivity: (activityType, content, messageId) => {
11271
- if (activityType === CHART_ACTIVITY_TYPE) {
11272
- if (this.#chartActivity) {
11273
- this.#drawActivityChart(messageId, content);
11274
- }
11275
- return;
11276
- }
11277
- if (activityType !== COMPACTION_ACTIVITY_TYPE) {
13049
+ this.#drawActivity(messageId, activityType, content);
13050
+ },
13051
+ onCustomEvent: (name, value) => {
13052
+ if (name === INVALIDATE_CUSTOM_NAME) {
13053
+ this.#dispatchInvalidation(value);
11278
13054
  return;
11279
13055
  }
11280
- const removed = compactionRemoved(content);
11281
- if (removed === null) {
13056
+ if (name === SUBAGENT_CUSTOM_NAME) {
13057
+ this.#reportSubAgent(value);
11282
13058
  return;
11283
13059
  }
11284
- this.#appendNotice(
11285
- "\u{1F5DC}",
11286
- this.#strings.historyCompacted.replace("{count}", String(removed)),
11287
- "compaction"
13060
+ this.dispatchEvent(
13061
+ new CustomEvent(CUSTOM_AGENT_EVENT, {
13062
+ detail: { name, value },
13063
+ bubbles: true,
13064
+ composed: true
13065
+ })
11288
13066
  );
11289
13067
  },
13068
+ onMessagesSnapshot: () => {
13069
+ this.#appendNotice("\u{1F504}", this.#strings.historyReplaced, "history-replaced");
13070
+ },
11290
13071
  onToolResult: (toolCallId, content) => {
11291
13072
  const card = this.#toolCards.get(toolCallId);
11292
13073
  if (card === void 0) {
@@ -11297,25 +13078,33 @@ var AgUiChat = class extends HTMLElement {
11297
13078
  this.#showPending();
11298
13079
  },
11299
13080
  onActivityChanged: (messageId, activityType, content) => {
11300
- if (activityType === CHART_ACTIVITY_TYPE && this.#chartActivity) {
11301
- this.#drawActivityChart(messageId, content);
11302
- }
13081
+ this.#drawActivity(messageId, activityType, content);
11303
13082
  },
11304
13083
  onRunEnd: () => {
11305
13084
  this.#hidePending();
11306
13085
  this.#endStream();
11307
13086
  },
11308
13087
  onError: (message) => {
13088
+ this.#announcedOutcome = true;
13089
+ this.#announce(this.#strings.announceFailed);
11309
13090
  this.#hidePending();
11310
- this.#revealWords(this.appendMessage(MESSAGE_ROLE.ASSISTANT, `\u26A0\uFE0F ${message}`));
13091
+ const bubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, `\u26A0\uFE0F ${message}`);
13092
+ bubble.classList.add("message--failed");
13093
+ this.#attachActions(bubble, { rateable: false });
13094
+ this.#revealWords(bubble);
11311
13095
  this.#endStream();
11312
13096
  },
11313
13097
  onCancelled: () => {
13098
+ this.#announcedOutcome = true;
13099
+ this.#announce(this.#strings.announceStopped);
11314
13100
  this.#hidePending();
11315
13101
  this.#appendStoppedNote();
11316
13102
  this.#endStream();
11317
13103
  },
11318
13104
  onSettled: () => {
13105
+ if (!this.#announcedOutcome) {
13106
+ this.#announce(this.#strings.announceAnswerReady);
13107
+ }
11319
13108
  this.#hidePending();
11320
13109
  this.#setRunning(false);
11321
13110
  this.#endStream();
@@ -11349,14 +13138,86 @@ var AgUiChat = class extends HTMLElement {
11349
13138
  side: this.#serverSettled.has(id) ? "server" : "client"
11350
13139
  }));
11351
13140
  this.#runTools = [];
13141
+ const invalidated = [...this.#runInvalidated];
13142
+ this.#runInvalidated = /* @__PURE__ */ new Set();
11352
13143
  this.dispatchEvent(
11353
13144
  new CustomEvent(RUN_FINISHED_EVENT, {
11354
- detail: { tools },
13145
+ detail: { tools, invalidated },
13146
+ bubbles: true,
13147
+ composed: true
13148
+ })
13149
+ );
13150
+ }
13151
+ /**
13152
+ * Route one invalidation to the host, and remember it for the run summary.
13153
+ *
13154
+ * Dispatched immediately rather than only at the end, because that is what
13155
+ * makes a long multi-step run feel live -- the list refreshes as the third of
13156
+ * eight writes lands. The accumulated set rides
13157
+ * {@link RUN_FINISHED_EVENT} as well, so a host that would rather refetch once
13158
+ * upgrades by reading one extra field instead of adding a listener.
13159
+ *
13160
+ * Nothing is rendered, persisted or replayed. An invalidation is an
13161
+ * imperative: it has no place in the transcript and no meaning once acted on,
13162
+ * and replaying one on every thread load would be a refetch storm. That is the
13163
+ * whole reason the server sends it as `CUSTOM` rather than as an activity.
13164
+ */
13165
+ #dispatchInvalidation(value) {
13166
+ const payload = value ?? {};
13167
+ const keys = Array.isArray(payload.keys) ? payload.keys.filter((key) => typeof key === "string") : [];
13168
+ if (keys.length === 0) {
13169
+ return;
13170
+ }
13171
+ for (const key of keys) {
13172
+ this.#runInvalidated.add(key);
13173
+ }
13174
+ this.dispatchEvent(
13175
+ new CustomEvent(INVALIDATE_EVENT, {
13176
+ detail: { keys, reason: typeof payload.reason === "string" ? payload.reason : null },
11355
13177
  bubbles: true,
11356
13178
  composed: true
11357
13179
  })
11358
13180
  );
11359
13181
  }
13182
+ /**
13183
+ * Draw one step of a delegated sub-agent's progress, on the card that
13184
+ * delegated.
13185
+ *
13186
+ * `delegationId` is the parent's own `delegate_task` tool-call id, so the
13187
+ * attachment point is a card this element already drew on `TOOL_CALL_START`.
13188
+ * That is the whole design: a run that hands work to a sub-agent used to read
13189
+ * as a stall -- the card sat at "running…" for the child's entire duration --
13190
+ * and the fix is to narrate *into* the thing that was already standing there,
13191
+ * rather than to float a second element with the same identity.
13192
+ *
13193
+ * A progress event for a call this client never drew is dropped. It has no
13194
+ * card to attach to, and inventing a floating one is precisely the alternative
13195
+ * that was rejected: parent and child interleave in the transcript with
13196
+ * nothing marking whose is whose, and the persisted transcript -- which never
13197
+ * held the progress at all -- would not match what was on screen.
13198
+ *
13199
+ * Nothing here writes to the conversation store. `CUSTOM` never enters
13200
+ * `agent.messages`, so a reload mid-run leaves the tool card and loses the
13201
+ * nested detail, which is the intended behaviour rather than a gap.
13202
+ */
13203
+ #reportSubAgent(value) {
13204
+ const update = subAgentUpdate(value);
13205
+ if (update === null) {
13206
+ return;
13207
+ }
13208
+ const card = this.#toolCards.get(update.delegationId);
13209
+ if (card === void 0) {
13210
+ return;
13211
+ }
13212
+ let panel = this.#subagentPanels.get(update.delegationId);
13213
+ if (panel === void 0) {
13214
+ panel = new SubAgentPanel(this.#strings);
13215
+ this.#subagentPanels.set(update.delegationId, panel);
13216
+ card.subagentSlot.appendChild(panel.element);
13217
+ }
13218
+ panel.report(update);
13219
+ this.#scroller.follow();
13220
+ }
11360
13221
  /** A muted "⏹ Stopped" line in the transcript (distinct from the ⚠️ error bubble). */
11361
13222
  #appendStoppedNote() {
11362
13223
  const note = document.createElement("div");
@@ -11366,7 +13227,7 @@ var AgUiChat = class extends HTMLElement {
11366
13227
  note.textContent = this.#strings.stopped;
11367
13228
  this.#ensureGroup().appendChild(note);
11368
13229
  this.#updateEmptyState();
11369
- this.#messages.scrollTop = this.#messages.scrollHeight;
13230
+ this.#scroller.follow();
11370
13231
  }
11371
13232
  /**
11372
13233
  * Show a "thinking" indicator while the agent is being awaited — both the
@@ -11390,7 +13251,7 @@ var AgUiChat = class extends HTMLElement {
11390
13251
  this.#pending = pending;
11391
13252
  this.#ensureGroup().appendChild(pending);
11392
13253
  this.#updateEmptyState();
11393
- this.#messages.scrollTop = this.#messages.scrollHeight;
13254
+ this.#scroller.follow();
11394
13255
  }
11395
13256
  /** Remove the pending indicator if shown. */
11396
13257
  #hidePending() {
@@ -11408,7 +13269,7 @@ var AgUiChat = class extends HTMLElement {
11408
13269
  const group = this.#ensureGroup();
11409
13270
  group.insertBefore(this.#thoughts.element, group.firstChild);
11410
13271
  this.#updateEmptyState();
11411
- this.#messages.scrollTop = this.#messages.scrollHeight;
13272
+ this.#scroller.follow();
11412
13273
  }
11413
13274
  return this.#thoughts;
11414
13275
  }
@@ -11460,7 +13321,7 @@ var AgUiChat = class extends HTMLElement {
11460
13321
  this.#streamBuffer = buffer;
11461
13322
  const bubble = this.#openStream();
11462
13323
  bubble.innerHTML = renderMarkdown(buffer, { allowImages: this.allowImages });
11463
- this.#messages.scrollTop = this.#messages.scrollHeight;
13324
+ this.#scroller.follow();
11464
13325
  return bubble;
11465
13326
  }
11466
13327
  /**
@@ -11508,7 +13369,7 @@ var AgUiChat = class extends HTMLElement {
11508
13369
  #appendNotice(icon, text3, kind) {
11509
13370
  this.#ensureGroup().appendChild(renderRunNotice(icon, text3, kind));
11510
13371
  this.#updateEmptyState();
11511
- this.#messages.scrollTop = this.#messages.scrollHeight;
13372
+ this.#scroller.follow();
11512
13373
  }
11513
13374
  /**
11514
13375
  * Turn on chart rendering, by whichever route this consumer wants.
@@ -11529,9 +13390,16 @@ var AgUiChat = class extends HTMLElement {
11529
13390
  * arrives is not something to switch on for everybody.
11530
13391
  */
11531
13392
  enableCharts(routes = ["tool", "activity"]) {
11532
- const first = !this.#chartActivity && !this.#toolRegistry.has(CHART_TOOL_NAME);
13393
+ const first = !this.#activityRenderers.has(CHART_ACTIVITY_TYPE) && !this.#toolRegistry.has(CHART_TOOL_NAME);
11533
13394
  if (routes.includes("activity")) {
11534
- this.#chartActivity = true;
13395
+ this.registerActivityRenderer({
13396
+ type: CHART_ACTIVITY_TYPE,
13397
+ render: (content) => {
13398
+ const spec = chartSpecFrom(content);
13399
+ return spec === null ? null : renderChart(spec);
13400
+ },
13401
+ removedNotice: this.#strings.chartUndrawable
13402
+ });
11535
13403
  }
11536
13404
  if (routes.includes("tool")) {
11537
13405
  this.registerTool(createChartTool());
@@ -11564,27 +13432,120 @@ var AgUiChat = class extends HTMLElement {
11564
13432
  this.#cardElements.get(call.id)?.after(node);
11565
13433
  this.#afterTranscriptGrew();
11566
13434
  }
11567
- /** Draw, or redraw in place, the chart for one activity message. */
11568
- #drawActivityChart(messageId, content) {
11569
- const spec = chartSpecFrom(content);
11570
- const block = spec === null ? null : renderChart(spec);
11571
- if (block === null) {
11572
- this.#activityBlocks.get(messageId)?.remove();
11573
- this.#activityBlocks.delete(messageId);
13435
+ /**
13436
+ * Teach this element to draw one kind of AG-UI activity.
13437
+ *
13438
+ * `activity_type` is one of exactly two fields the protocol leaves an open
13439
+ * string, and it is the **content** one: an activity is materialised into a
13440
+ * message, persisted with the thread, and replayed on every restore. Its
13441
+ * sibling `CUSTOM` carries an imperative and is dispatched to the page
13442
+ * instead ({@link CUSTOM_AGENT_EVENT}).
13443
+ *
13444
+ * That asymmetry decides which carrier a server should use. Content has a
13445
+ * place in the conversation and should come back; an imperative has no place
13446
+ * and no meaning once acted on.
13447
+ *
13448
+ * ```js
13449
+ * chat.registerActivityRenderer({
13450
+ * type: "build_status",
13451
+ * render: (content) => {
13452
+ * const el = document.createElement("div");
13453
+ * el.textContent = `Build ${content.status}`;
13454
+ * return el;
13455
+ * },
13456
+ * });
13457
+ * ```
13458
+ *
13459
+ * Registering a type twice replaces the earlier renderer, so a host can
13460
+ * override a built-in -- `chart` and `compaction` are registrations like any
13461
+ * other, not privileged branches.
13462
+ *
13463
+ * ⚠ `render` runs again on every thread load. See {@link ActivityRenderer}
13464
+ * for what that requires of it.
13465
+ */
13466
+ registerActivityRenderer(registration) {
13467
+ this.#activityRenderers.set(registration.type, registration);
13468
+ this.#unhandledActivityTypes.delete(registration.type);
13469
+ }
13470
+ /**
13471
+ * Activity types that arrived with nobody registered to draw them.
13472
+ *
13473
+ * Deliberately the only trace an unhandled activity leaves. Ignoring an
13474
+ * unknown name is the protocol's own answer and the whole point of an open
13475
+ * field, so warning would fire on every forward-compatible server -- but
13476
+ * "nothing happened and nothing was said" is impossible to debug, so the set
13477
+ * is readable. Accumulates for the element's lifetime, across threads.
13478
+ */
13479
+ get unhandledActivityTypes() {
13480
+ return [...this.#unhandledActivityTypes];
13481
+ }
13482
+ /**
13483
+ * Draw, replace or remove one activity, whatever kind it is.
13484
+ *
13485
+ * The single path for all three routes an activity arrives by -- pushed
13486
+ * (`onActivity`), patched (`onActivityChanged`) and replayed from history --
13487
+ * which is why the renderer contract has to be pure: the same content is
13488
+ * drawn again on every thread load.
13489
+ *
13490
+ * An unregistered type draws nothing and says nothing. That is the protocol's
13491
+ * own answer -- a client that does not know a name ignores the event -- and a
13492
+ * warning here would fire on every well-behaved forward-compatible server,
13493
+ * while a placeholder would put the protocol's growth in the user's face.
13494
+ * {@link unhandledActivityTypes} is the way to find out what arrived.
13495
+ */
13496
+ #drawActivity(messageId, activityType, content) {
13497
+ const registration = this.#activityRenderers.get(activityType);
13498
+ if (registration === void 0) {
13499
+ this.#unhandledActivityTypes.add(activityType);
13500
+ return;
13501
+ }
13502
+ let node;
13503
+ try {
13504
+ node = registration.render(content);
13505
+ } catch (error) {
13506
+ console.warn(`ag-ui-chat: render failed for activity ${activityType}`, error);
13507
+ node = null;
13508
+ }
13509
+ if (node === null) {
13510
+ this.#removeActivity(messageId, activityType, registration.removedNotice, content);
11574
13511
  return;
11575
13512
  }
11576
13513
  const existing = this.#activityBlocks.get(messageId);
11577
13514
  if (existing === void 0) {
11578
- this.#ensureGroup().appendChild(block);
13515
+ this.#ensureGroup().appendChild(node);
11579
13516
  } else {
11580
- existing.replaceWith(block);
13517
+ existing.replaceWith(node);
11581
13518
  }
11582
- this.#activityBlocks.set(messageId, block);
13519
+ this.#activityBlocks.set(messageId, node);
11583
13520
  this.#afterTranscriptGrew();
11584
13521
  }
13522
+ /**
13523
+ * Take away an activity whose content stopped being drawable.
13524
+ *
13525
+ * Leaving the old one up is the worst available answer: it shows values that
13526
+ * have been retracted, reading as current, and a reload drops it anyway
13527
+ * because the *stored* content is the version that could not be drawn. Live
13528
+ * and reload should agree, and both should say "gone".
13529
+ *
13530
+ * Removing is right; doing it in silence was not. A chart that had been drawn
13531
+ * simply disappeared, with no `console` call anywhere on the path -- which
13532
+ * nobody reports as a bug, they report as "the charts are flaky".
13533
+ */
13534
+ #removeActivity(messageId, activityType, notice, content) {
13535
+ const had = this.#activityBlocks.has(messageId);
13536
+ this.#activityBlocks.get(messageId)?.remove();
13537
+ this.#activityBlocks.delete(messageId);
13538
+ console.warn(
13539
+ `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.`,
13540
+ content
13541
+ );
13542
+ if (had && notice !== void 0) {
13543
+ this.#appendNotice("\u{1F4C9}", notice, "chart-undrawable");
13544
+ }
13545
+ }
11585
13546
  #afterTranscriptGrew() {
11586
13547
  this.#updateEmptyState();
11587
- this.#messages.scrollTop = this.#messages.scrollHeight;
13548
+ this.#scroller.follow();
11588
13549
  }
11589
13550
  #cardFor(call) {
11590
13551
  const existing = this.#toolCards.get(call.id);
@@ -11593,11 +13554,16 @@ var AgUiChat = class extends HTMLElement {
11593
13554
  }
11594
13555
  const labelled = this.#resolveTool(call.name)?.parameters[X_SUMMARY_KEY];
11595
13556
  const summary = typeof labelled === "string" ? labelled : this.toolSummaries[call.name] ?? this.#toolCatalog[call.name]?.summary ?? prettifyToolName(call.name);
11596
- const card = new ToolCallCard(call.name, call.args, summary, this.#strings);
13557
+ const card = new ToolCallCard(call.name, call.args, summary, this.#strings, {
13558
+ // A thunk over the live property, not the property itself: the card keeps
13559
+ // this for the life of the call, and the result region is filled when the
13560
+ // tool settles -- which can be long after a host set the hook.
13561
+ formatPayload: (payload) => this.formatToolPayload?.(payload) ?? null
13562
+ });
11597
13563
  this.#toolCards.set(call.id, card);
11598
13564
  this.#ensureGroup().appendChild(card.element);
11599
13565
  this.#updateEmptyState();
11600
- this.#messages.scrollTop = this.#messages.scrollHeight;
13566
+ this.#scroller.follow();
11601
13567
  return card;
11602
13568
  }
11603
13569
  };
@@ -11667,7 +13633,7 @@ function setControlValue(el2, value) {
11667
13633
  }
11668
13634
 
11669
13635
  // src/version.ts
11670
- var VERSION = "0.28.0";
13636
+ var VERSION = "0.30.0";
11671
13637
  export {
11672
13638
  ATTACHMENT_EVENT,
11673
13639
  AgUiChat,
@@ -11675,13 +13641,21 @@ export {
11675
13641
  CHART_ACTIVITY_TYPE,
11676
13642
  CHART_TOOL_NAME,
11677
13643
  COMPACTION_ACTIVITY_TYPE,
13644
+ CUSTOM_AGENT_EVENT,
11678
13645
  CheckpointMenu,
11679
13646
  ClientToolRegistry,
11680
13647
  ConnectionLostError,
11681
13648
  DEFAULT_UI_STRINGS,
11682
13649
  ELEMENT_TAG,
13650
+ FEEDBACK_EVENT,
13651
+ INVALIDATE_CUSTOM_NAME,
13652
+ INVALIDATE_EVENT,
11683
13653
  LOAD_CAPABILITY_TOOL,
13654
+ MAX_QUOTE_CHARS,
13655
+ MAX_SUGGESTIONS,
13656
+ MAX_SUGGESTION_CHARS,
11684
13657
  MAX_TOOL_ROUNDS,
13658
+ MESSAGE_ACTIONS,
11685
13659
  MESSAGE_ROLE,
11686
13660
  PAGE_ACTIONS,
11687
13661
  RUN_FINISHED_EVENT,
@@ -11689,6 +13663,7 @@ export {
11689
13663
  RunIndex,
11690
13664
  STATE_EVENT,
11691
13665
  SUBMIT_EVENT,
13666
+ SUGGESTIONS_ACTIVITY_TYPE,
11692
13667
  SessionStorageStore,
11693
13668
  TOGGLE_EVENT,
11694
13669
  TOOL_CALL_STATUS,
@@ -11700,6 +13675,9 @@ export {
11700
13675
  X_DESTRUCTIVE_KEY,
11701
13676
  X_NAVIGATES_KEY,
11702
13677
  X_SUMMARY_KEY,
13678
+ asQuote,
13679
+ attachMessageActions,
13680
+ attachQuoteOffer,
11703
13681
  chartSpecFrom,
11704
13682
  clickElement,
11705
13683
  createHttpAgent,
@@ -11716,14 +13694,18 @@ export {
11716
13694
  isDestructive,
11717
13695
  isNavigates,
11718
13696
  mergeUiStrings,
13697
+ messageActionBar,
11719
13698
  messageAttachments,
11720
13699
  parseToolCatalog,
11721
13700
  prefersReducedMotion,
11722
13701
  pressButton,
11723
13702
  pressThenClick,
11724
13703
  prettifyToolName,
13704
+ quotableSelection,
13705
+ relativeTime,
11725
13706
  renderChart,
11726
13707
  renderMarkdown,
13708
+ renderSuggestionChips,
11727
13709
  requestApproval,
11728
13710
  requestConfirmation,
11729
13711
  requestQuestion,
@@ -11733,6 +13715,7 @@ export {
11733
13715
  setControlValue,
11734
13716
  setNativeChecked,
11735
13717
  setNativeValue,
13718
+ suggestionPrompts,
11736
13719
  toggleCheckbox,
11737
13720
  toggleControl,
11738
13721
  transcribeAudio,