@aparte/core 0.19.2 → 0.20.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 (151) hide show
  1. package/README.md +6 -1
  2. package/dist/client/aparte-client.d.ts +76 -97
  3. package/dist/client/aparte-client.d.ts.map +1 -1
  4. package/dist/client/stream-adapter.d.ts +7 -13
  5. package/dist/client/stream-adapter.d.ts.map +1 -1
  6. package/dist/client/wire-history.d.ts +4 -5
  7. package/dist/client/wire-history.d.ts.map +1 -1
  8. package/dist/components/bubble/aparte-chat-bubble.d.ts +89 -58
  9. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  10. package/dist/components/bubble/bubble-sync.d.ts +1 -1
  11. package/dist/components/chat/aparte-chat.d.ts +15 -13
  12. package/dist/components/chat/aparte-chat.d.ts.map +1 -1
  13. package/dist/components/chat/glide-gate.d.ts +13 -0
  14. package/dist/components/chat/glide-gate.d.ts.map +1 -0
  15. package/dist/components/composer/aparte-composer-action.d.ts +3 -3
  16. package/dist/components/composer/aparte-composer-add-attachment.d.ts +2 -2
  17. package/dist/components/composer/aparte-composer-cancel.d.ts +1 -1
  18. package/dist/components/composer/aparte-composer-input.d.ts +4 -7
  19. package/dist/components/composer/aparte-composer-input.d.ts.map +1 -1
  20. package/dist/components/composer/aparte-composer-send.d.ts +9 -11
  21. package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
  22. package/dist/components/composer/aparte-composer.d.ts +50 -80
  23. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  24. package/dist/components/context/aparte-context.d.ts +13 -7
  25. package/dist/components/context/aparte-context.d.ts.map +1 -1
  26. package/dist/components/conversation-list/aparte-conversation-list.d.ts +25 -13
  27. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  28. package/dist/components/elicitation/aparte-elicitation.d.ts +15 -22
  29. package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
  30. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts +10 -11
  31. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -1
  32. package/dist/components/sidebar/aparte-sidebar.d.ts +16 -3
  33. package/dist/components/sidebar/aparte-sidebar.d.ts.map +1 -1
  34. package/dist/components/split/aparte-split.d.ts +12 -3
  35. package/dist/components/split/aparte-split.d.ts.map +1 -1
  36. package/dist/components/status/aparte-chat-status.d.ts +5 -3
  37. package/dist/components/status/aparte-chat-status.d.ts.map +1 -1
  38. package/dist/components/suggestions/aparte-suggestions.d.ts +6 -9
  39. package/dist/components/suggestions/aparte-suggestions.d.ts.map +1 -1
  40. package/dist/components/viewport/aparte-chat-viewport.d.ts +76 -69
  41. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  42. package/dist/config/aparte-config.d.ts +169 -141
  43. package/dist/config/aparte-config.d.ts.map +1 -1
  44. package/dist/config/icon-provider.d.ts +19 -2
  45. package/dist/config/icon-provider.d.ts.map +1 -1
  46. package/dist/config/index.d.ts +1 -1
  47. package/dist/config/index.d.ts.map +1 -1
  48. package/dist/config/locale.d.ts +61 -89
  49. package/dist/config/locale.d.ts.map +1 -1
  50. package/dist/config/sanitize.d.ts.map +1 -1
  51. package/dist/conversations/conversation-controller.d.ts +18 -3
  52. package/dist/conversations/conversation-controller.d.ts.map +1 -1
  53. package/dist/conversations/conversation-manager.d.ts +6 -6
  54. package/dist/conversations/conversation-manager.d.ts.map +1 -1
  55. package/dist/conversations/index.d.ts +1 -1
  56. package/dist/conversations/index.d.ts.map +1 -1
  57. package/dist/custom-elements.json +5656 -4759
  58. package/dist/{dialog-triggers-CEazY76J.js → dialog-triggers-3Rk7PuZa.js} +555 -375
  59. package/dist/dialog-triggers-3Rk7PuZa.js.map +1 -0
  60. package/dist/elicitation/approval-panel.d.ts.map +1 -1
  61. package/dist/elicitation/index.d.ts +6 -1
  62. package/dist/elicitation/index.d.ts.map +1 -1
  63. package/dist/elicitation/panel.d.ts +11 -21
  64. package/dist/elicitation/panel.d.ts.map +1 -1
  65. package/dist/elicitation/types.d.ts +19 -27
  66. package/dist/elicitation/types.d.ts.map +1 -1
  67. package/dist/generated/element-attributes.d.ts +16 -10
  68. package/dist/generated/element-attributes.d.ts.map +1 -1
  69. package/dist/host/aparte-chat-host.d.ts +17 -32
  70. package/dist/host/aparte-chat-host.d.ts.map +1 -1
  71. package/dist/icons/extended.d.ts +1 -2
  72. package/dist/icons/extended.d.ts.map +1 -1
  73. package/dist/icons.d.ts +6 -8
  74. package/dist/icons.d.ts.map +1 -1
  75. package/dist/icons.js.map +1 -1
  76. package/dist/index.css +1 -1
  77. package/dist/index.d.ts +13 -10
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +775 -337
  80. package/dist/index.js.map +1 -1
  81. package/dist/index.node.d.ts +7 -4
  82. package/dist/index.node.d.ts.map +1 -1
  83. package/dist/index.node.js +2 -2
  84. package/dist/index.node.js.map +1 -1
  85. package/dist/interop/dialog-triggers.d.ts +2 -2
  86. package/dist/interop/element-props.d.ts +16 -27
  87. package/dist/interop/element-props.d.ts.map +1 -1
  88. package/dist/parsers/aparte-stream-parser.d.ts.map +1 -1
  89. package/dist/primitives/icon/aparte-icon.d.ts.map +1 -1
  90. package/dist/primitives/select/aparte-option.d.ts +3 -2
  91. package/dist/primitives/select/aparte-option.d.ts.map +1 -1
  92. package/dist/primitives/select/aparte-select.d.ts +24 -21
  93. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  94. package/dist/renderers/markdown-stream.d.ts.map +1 -1
  95. package/dist/renderers/segment-renderers.d.ts +11 -17
  96. package/dist/renderers/segment-renderers.d.ts.map +1 -1
  97. package/dist/renderers/segments/code.d.ts.map +1 -1
  98. package/dist/renderers/segments/error.d.ts.map +1 -1
  99. package/dist/renderers/segments/text.d.ts.map +1 -1
  100. package/dist/renderers/segments/thinking.d.ts.map +1 -1
  101. package/dist/renderers/segments/tool-call.d.ts.map +1 -1
  102. package/dist/runtime/message-repository.d.ts +8 -15
  103. package/dist/runtime/message-repository.d.ts.map +1 -1
  104. package/dist/transport/agent-handler.d.ts +2 -0
  105. package/dist/transport/agent-handler.d.ts.map +1 -1
  106. package/dist/transport/backend-handler.d.ts +4 -6
  107. package/dist/transport/backend-handler.d.ts.map +1 -1
  108. package/dist/transport/backend-transport.d.ts +15 -1
  109. package/dist/transport/backend-transport.d.ts.map +1 -1
  110. package/dist/transport/types.d.ts +7 -10
  111. package/dist/transport/types.d.ts.map +1 -1
  112. package/dist/types/chat.d.ts +10 -18
  113. package/dist/types/chat.d.ts.map +1 -1
  114. package/dist/types/element-attributes.d.ts +6 -12
  115. package/dist/types/element-attributes.d.ts.map +1 -1
  116. package/dist/types/element-map.d.ts +6 -19
  117. package/dist/types/element-map.d.ts.map +1 -1
  118. package/dist/types/event-map.d.ts +11 -17
  119. package/dist/types/event-map.d.ts.map +1 -1
  120. package/dist/types/events.d.ts +5 -14
  121. package/dist/types/events.d.ts.map +1 -1
  122. package/dist/types/imperative-api.d.ts +3 -1
  123. package/dist/types/imperative-api.d.ts.map +1 -1
  124. package/dist/types/index.d.ts +4 -4
  125. package/dist/types/index.d.ts.map +1 -1
  126. package/dist/types/model-provider.d.ts +7 -9
  127. package/dist/types/model-provider.d.ts.map +1 -1
  128. package/dist/types/segments.d.ts +29 -13
  129. package/dist/types/segments.d.ts.map +1 -1
  130. package/dist/types/tools.d.ts +21 -23
  131. package/dist/types/tools.d.ts.map +1 -1
  132. package/dist/utils/copy-text.d.ts +1 -2
  133. package/dist/utils/copy-text.d.ts.map +1 -1
  134. package/dist/utils/css-escape.d.ts +1 -1
  135. package/dist/utils/escape.d.ts +4 -19
  136. package/dist/utils/escape.d.ts.map +1 -1
  137. package/dist/utils/files-to-attachments.d.ts +2 -2
  138. package/dist/utils/fold-text.d.ts +13 -0
  139. package/dist/utils/fold-text.d.ts.map +1 -0
  140. package/dist/utils/is-awaiting-reply.d.ts +2 -5
  141. package/dist/utils/is-awaiting-reply.d.ts.map +1 -1
  142. package/dist/utils/presence.d.ts +5 -5
  143. package/dist/utils/segments.d.ts +54 -78
  144. package/dist/utils/segments.d.ts.map +1 -1
  145. package/dist/utils/tool-input.d.ts +1 -1
  146. package/dist/utils/tool-input.d.ts.map +1 -1
  147. package/dist/utils/transcript.d.ts +2 -2
  148. package/dist/utils/uuid.d.ts +0 -11
  149. package/dist/utils/uuid.d.ts.map +1 -1
  150. package/package.json +30 -4
  151. package/dist/dialog-triggers-CEazY76J.js.map +0 -1
@@ -84,7 +84,7 @@ const APARTE_DEFAULT_LOCALE = {
84
84
  copy: "Copy",
85
85
  copied: "Copied!",
86
86
  retry: "Retry",
87
- thinking: "Thinking...",
87
+ thinking: "Thinking",
88
88
  elicitationOther: "Other…",
89
89
  elicitationOtherPlaceholder: "Type your answer…",
90
90
  elicitationOtherLabel: "Custom answer",
@@ -92,6 +92,8 @@ const APARTE_DEFAULT_LOCALE = {
92
92
  stopButton: "Stop",
93
93
  elicitationRecommended: "Recommended",
94
94
  elicitationSkip: "Skip",
95
+ elicitationHide: "Hide for now",
96
+ elicitationWaiting: "Waiting for your answer",
95
97
  elicitationYes: "Yes",
96
98
  elicitationNo: "No",
97
99
  elicitationAnswerLabel: "Your answer",
@@ -113,10 +115,13 @@ const APARTE_DEFAULT_LOCALE = {
113
115
  approvalArgsLabel: "Arguments",
114
116
  actionUpload: "Attach file",
115
117
  removeAttachment: "Remove {name}",
118
+ messageFrom: "Message from {name}",
116
119
  selectSearchPlaceholder: "Search…",
117
120
  selectSearchLabel: "Search options",
118
121
  loading: "Loading…",
119
122
  download: "Download",
123
+ openArtifact: "Open",
124
+ modelContextWindow: "{n} context",
120
125
  previewPending: "Press Preview to run this artifact.",
121
126
  sandboxError: "The sandbox failed during generation.",
122
127
  sandboxErrorHint: "Common cause: the model produced invalid code (undefined variable, wrong argument type). Retry the request — the model may produce different code.",
@@ -124,7 +129,7 @@ const APARTE_DEFAULT_LOCALE = {
124
129
  code: "Code",
125
130
  generating: "Generating…",
126
131
  rebuildingPreview: "Rebuilding preview…",
127
- typing: "Typing...",
132
+ typing: "Typing",
128
133
  error: "Error",
129
134
  running: "Running...",
130
135
  run: "Run",
@@ -148,6 +153,8 @@ const APARTE_DEFAULT_LOCALE = {
148
153
  messageInfo: "Details",
149
154
  newChat: "New Chat",
150
155
  loadingConversations: "Loading conversations",
156
+ conversationSearchEmpty: "No conversation matches",
157
+ conversationList: "Conversations",
151
158
  loadingConversation: "Loading the conversation",
152
159
  deleteConversation: "Delete",
153
160
  archiveConversation: "Archive",
@@ -698,8 +705,7 @@ class AparteStreamParser {
698
705
  return {
699
706
  id: this._options.autoGenerateIds ? this._generateId() : "",
700
707
  type: "thinking",
701
- content,
702
- label: "Thinking"
708
+ content
703
709
  };
704
710
  }
705
711
  _isPartialMatch(buffer, pattern) {
@@ -1025,6 +1031,46 @@ function renumberSegments(segments) {
1025
1031
  segments[i] = { ...segment, index: i };
1026
1032
  }
1027
1033
  }
1034
+ const APARTE_HOST_ATTR = "data-aparte-host";
1035
+ const CONFIG_SLOT = Symbol.for("aparte.instanceConfig");
1036
+ function notifyConfigChanged(root, next, previous) {
1037
+ if (next === previous) return;
1038
+ const call = (el2) => {
1039
+ const hook = el2.aparteConfigChanged;
1040
+ if (typeof hook === "function") hook.call(el2, next, previous);
1041
+ };
1042
+ call(root);
1043
+ for (const el2 of root.querySelectorAll("*")) call(el2);
1044
+ }
1045
+ function attachConfig(el2, config) {
1046
+ const previous = el2[CONFIG_SLOT] ?? resolveConfig(el2.parentElement);
1047
+ el2[CONFIG_SLOT] = config;
1048
+ el2.setAttribute(APARTE_HOST_ATTR, "");
1049
+ notifyConfigChanged(el2, config, previous);
1050
+ }
1051
+ function detachConfig(el2) {
1052
+ const previous = el2[CONFIG_SLOT];
1053
+ delete el2[CONFIG_SLOT];
1054
+ el2.removeAttribute(APARTE_HOST_ATTR);
1055
+ if (previous) notifyConfigChanged(el2, resolveConfig(el2.parentElement), previous);
1056
+ }
1057
+ function resolveConfig(el2) {
1058
+ const host = el2?.closest?.(`[${APARTE_HOST_ATTR}]`);
1059
+ return host?.[CONFIG_SLOT] ?? aparteGlobalConfig;
1060
+ }
1061
+ let _renderConfig = null;
1062
+ function runWithConfig(config, fn) {
1063
+ const prev = _renderConfig;
1064
+ _renderConfig = config;
1065
+ try {
1066
+ return fn();
1067
+ } finally {
1068
+ _renderConfig = prev;
1069
+ }
1070
+ }
1071
+ function contextConfig(el2) {
1072
+ return _renderConfig ?? resolveConfig(el2);
1073
+ }
1028
1074
  function dispatchLifecycleEvent(target, name, detail, targetId) {
1029
1075
  const id = targetId ?? (target.id || void 0);
1030
1076
  const stamped = detail && typeof detail === "object" ? { targetId: id, ...detail } : detail;
@@ -1061,9 +1107,7 @@ function createStreamAdapter(opts) {
1061
1107
  const seg = {
1062
1108
  id: `think-${uuid()}`,
1063
1109
  type: "thinking",
1064
- content: thinkingContent,
1065
- collapsed: true,
1066
- label: "Thinking"
1110
+ content: thinkingContent
1067
1111
  };
1068
1112
  thinkingId = seg.id;
1069
1113
  streaming.add(seg.id);
@@ -1075,7 +1119,9 @@ function createStreamAdapter(opts) {
1075
1119
  }
1076
1120
  case "text-delta": {
1077
1121
  if (thinkingId && !thinkingCollapsed) {
1078
- target.updateSegment?.(thinkingId, { collapsed: true, isStreaming: false });
1122
+ const chatConfig = typeof Element !== "undefined" && target instanceof Element ? resolveConfig(target) : config;
1123
+ const keepOpen = chatConfig.getSegmentDefaults("thinking")?.collapsed === false;
1124
+ target.updateSegment?.(thinkingId, keepOpen ? { isStreaming: false } : { collapsed: true, isStreaming: false });
1079
1125
  thinkingCollapsed = true;
1080
1126
  }
1081
1127
  const result = parser.parse(e.delta);
@@ -1814,6 +1860,26 @@ const APARTE_DEFAULT_HOST_HANDLERS = {
1814
1860
  };
1815
1861
  const NOOP = () => {
1816
1862
  };
1863
+ const renderingVersions = /* @__PURE__ */ new WeakMap();
1864
+ function renderingVersionOf(cfg) {
1865
+ return renderingVersions.get(cfg) ?? 0;
1866
+ }
1867
+ function bumpRenderingVersion(cfg) {
1868
+ renderingVersions.set(cfg, renderingVersionOf(cfg) + 1);
1869
+ }
1870
+ function drawingOf(provider, name) {
1871
+ if (!provider || !(name in provider) || name in Object.prototype) return void 0;
1872
+ const fn = provider[name];
1873
+ return typeof fn === "function" ? fn.bind(provider) : void 0;
1874
+ }
1875
+ const iconLayers = /* @__PURE__ */ new WeakSet();
1876
+ function namesOf(provider) {
1877
+ const names = /* @__PURE__ */ new Set();
1878
+ for (let o = provider; o && o !== Object.prototype; o = Object.getPrototypeOf(o)) {
1879
+ for (const name of Object.getOwnPropertyNames(o)) if (name !== "constructor") names.add(name);
1880
+ }
1881
+ return [...names];
1882
+ }
1817
1883
  class AparteConfig {
1818
1884
  _markdownProvider;
1819
1885
  _streamingMarkdownProvider;
@@ -1845,15 +1911,13 @@ class AparteConfig {
1845
1911
  /**
1846
1912
  * What `refreshProviderModels()` last brought back, per provider.
1847
1913
  *
1848
- * Without it, `getCurrentModel()` read `provider.getModels()` only — the
1849
- * SYNCHRONOUS, hand-declared list, which every preset of
1850
- * `@aparte/provider-openai-compat` leaves empty because a compat endpoint's
1851
- * list is fetched at runtime. So the current model resolved to `undefined`
1852
- * for the documented primary path, and everything that reads a capability off
1853
- * it silently got nothing: `AparteClient._toolsForCurrentModel()` gates tools
1854
- * on `capabilities.includes('function_calling')`, so NO tool was ever sent to
1855
- * the model. A registered tool, an approval gate, `<aparte-elicitation>` — all
1856
- * dead, with the model answering, correctly, that it had been given no tools.
1914
+ * `getCurrentModel()` reads it beside `provider.getModels()`, the SYNCHRONOUS,
1915
+ * hand-declared list, which every preset of `@aparte/provider-openai-compat`
1916
+ * leaves empty because a compat endpoint's list is fetched at runtime. Without
1917
+ * the fetched list the current model resolves to `undefined`, and its declared
1918
+ * capabilities are unknown: `AparteClient._toolsForCurrentModel()` still sends the
1919
+ * tools then, and it is the fetched list that lets a model which declares no
1920
+ * `function_calling` be honoured.
1857
1921
  */
1858
1922
  _fetchedModels = /* @__PURE__ */ new Map();
1859
1923
  _modelConfig = {};
@@ -1888,9 +1952,8 @@ class AparteConfig {
1888
1952
  * silently, because the warning fires at most once per config.
1889
1953
  *
1890
1954
  * Each entry carries the element that registered it, so a request naming a `target`
1891
- * is routed to the presenter in the SAME chat. `AparteElicitationRequest.target` was
1892
- * already documented as "used to resolve WHICH instance presents"; the single slot
1893
- * is what made that impossible.
1955
+ * is routed to the presenter in the SAME chat, as `AparteElicitationRequest.target`
1956
+ * documents ("used to resolve WHICH instance presents").
1894
1957
  */
1895
1958
  _elicitationPresenters = [];
1896
1959
  /** Warn once per config, not once per request — a tool loop can ask repeatedly. */
@@ -1900,22 +1963,21 @@ class AparteConfig {
1900
1963
  *
1901
1964
  * One request reaches the presenter at a time — not because a presenter could not
1902
1965
  * cope, but because the COMPOSER cannot: it has one panel slot, and a second
1903
- * request used to clobber the first's DOM. The previous protection lived in the
1904
- * default presenter, which answered the second request `cancel` on the spot; that
1905
- * is a refusal invented for a question nobody was ever shown, the same class of
1906
- * lie as telling the model a stopped turn was refused. WAITING is the honest
1907
- * behaviour.
1966
+ * request would clobber the first's DOM. A second request WAITS: answering it
1967
+ * `cancel` on the spot would be a refusal invented for a question nobody was ever
1968
+ * shown.
1908
1969
  *
1909
1970
  * Here rather than in the presenter because this is the only place that sees
1910
- * EVERY request whatever raised it — a tool handler, an app's own button, and
1911
- * (soon) the tool-approval gate. A custom presenter registered by a consumer gets
1912
- * the same protection for free, which it previously had none of.
1971
+ * EVERY request whatever raised it — a tool handler, an app's own button, the
1972
+ * tool-approval gate. A custom presenter registered by a consumer gets the same
1973
+ * protection for free.
1913
1974
  */
1914
1975
  _elicitationQueue = /* @__PURE__ */ new Map();
1915
1976
  // Tool Registry
1916
1977
  _tools = /* @__PURE__ */ new Map();
1917
1978
  _toolRenderers = /* @__PURE__ */ new Map();
1918
1979
  _toolSummaries = /* @__PURE__ */ new Map();
1980
+ _toolInputFormatters = /* @__PURE__ */ new Map();
1919
1981
  /** Tagged blocks the stream parser recognises in the prose — see {@link registerStreamBlock}. */
1920
1982
  _streamBlocks = /* @__PURE__ */ new Map();
1921
1983
  _segmentDefaults = /* @__PURE__ */ new Map();
@@ -2008,6 +2070,8 @@ class AparteConfig {
2008
2070
  */
2009
2071
  setMarkdownProvider(fn) {
2010
2072
  this._markdownProvider = fn;
2073
+ bumpRenderingVersion(this);
2074
+ this._notify();
2011
2075
  }
2012
2076
  /**
2013
2077
  * Set an incremental (streaming) Markdown renderer provider. Optional —
@@ -2022,6 +2086,8 @@ class AparteConfig {
2022
2086
  */
2023
2087
  setStreamingMarkdownProvider(fn) {
2024
2088
  this._streamingMarkdownProvider = fn;
2089
+ bumpRenderingVersion(this);
2090
+ this._notify();
2025
2091
  }
2026
2092
  /**
2027
2093
  * Create an incremental Markdown renderer bound to `target`, or `null` when
@@ -2037,6 +2103,8 @@ class AparteConfig {
2037
2103
  */
2038
2104
  setHighlightProvider(fn) {
2039
2105
  this._highlightProvider = fn;
2106
+ bumpRenderingVersion(this);
2107
+ this._notify();
2040
2108
  }
2041
2109
  /**
2042
2110
  * Whether a syntax-highlight provider is registered. Lets consumers (e.g.
@@ -2054,7 +2122,7 @@ class AparteConfig {
2054
2122
  * @param sanitizer A sanitizer function (e.g. a DOMPurify wrapper) for
2055
2123
  * hardened coverage, or `null` to DISABLE sanitization. Disabling exposes
2056
2124
  * you to XSS from LLM-authored content — only do so for content you fully
2057
- * trust and have already sanitized upstream.
2125
+ * trust and have already sanitized upstream. `null` prints a console warning, once.
2058
2126
  * @example aparteGlobalConfig.setHtmlSanitizer((html) => DOMPurify.sanitize(html));
2059
2127
  */
2060
2128
  setHtmlSanitizer(sanitizer) {
@@ -2104,30 +2172,54 @@ class AparteConfig {
2104
2172
  return resolved.trim() || null;
2105
2173
  }
2106
2174
  /**
2107
- * Set a custom Icon provider (e.g., Lucide, FontAwesome, Material)
2175
+ * Set a custom Icon provider (e.g., Lucide, FontAwesome, Material). It replaces the
2176
+ * whole set, icons added with `registerIcons` included; a name it leaves out keeps
2177
+ * core's drawing.
2108
2178
  * @param provider Object implementing AparteIconProvider interface
2109
2179
  */
2110
2180
  setIconProvider(provider) {
2111
2181
  this._iconProvider = provider;
2112
2182
  this._notify();
2113
2183
  }
2184
+ /**
2185
+ * Add icons to the set, or redraw some of it, keeping the others. A name core does not
2186
+ * draw is welcome: an action's `icon`, `<aparte-composer-action icon>` and
2187
+ * `<aparte-icon name>` then find it. To type such a name, merge it into
2188
+ * `AparteIconProvider`: `declare module '@aparte/core' { interface AparteIconProvider { share?: () => string } }`.
2189
+ *
2190
+ * @example
2191
+ * aparteGlobalConfig.registerIcons({ share: () => '<svg class="aparte-icon" viewBox="0 0 24 24">…</svg>' });
2192
+ * aparteGlobalConfig.registerAction({ id: 'share', icon: 'share', label: 'Share', zones: ['bubble'] });
2193
+ */
2194
+ registerIcons(icons) {
2195
+ const current = this._iconProvider;
2196
+ if (current && iconLayers.has(current)) {
2197
+ Object.assign(current, icons);
2198
+ } else {
2199
+ const layer = Object.assign(Object.create(current ?? null), icons);
2200
+ iconLayers.add(layer);
2201
+ this._iconProvider = layer;
2202
+ }
2203
+ this._notify();
2204
+ }
2114
2205
  /**
2115
2206
  * The icon set as a **complete** provider: every name resolves, falling back
2116
2207
  * to `APARTE_DEFAULT_ICON_FALLBACKS` for anything the registered provider doesn't
2117
2208
  * implement. Callers (bubble action bar, composer controls) can therefore
2118
2209
  * invoke `icons.copy()` unconditionally.
2119
2210
  *
2120
- * It used to hand back the registered provider verbatim, so a provider that
2121
- * implemented a subset — which `getIcon()` has always supported, and which
2122
- * the type now states — crashed every other icon with
2123
- * "icons.retry is not a function".
2211
+ * A registered provider may implement only a subset of the names; the missing ones
2212
+ * fall back, so `icons.retry()` never throws "is not a function".
2124
2213
  */
2125
2214
  getIconProvider() {
2126
2215
  const registered = this._iconProvider;
2127
2216
  const complete = {};
2128
2217
  for (const name of Object.keys(APARTE_DEFAULT_ICON_FALLBACKS)) {
2129
- const fn = registered?.[name];
2130
- complete[name] = fn ?? (() => APARTE_DEFAULT_ICON_FALLBACKS[name]);
2218
+ complete[name] = drawingOf(registered, name) ?? (() => APARTE_DEFAULT_ICON_FALLBACKS[name]);
2219
+ }
2220
+ for (const name of namesOf(registered)) {
2221
+ const fn = drawingOf(registered, name);
2222
+ if (fn && !(name in complete)) complete[name] = fn;
2131
2223
  }
2132
2224
  return complete;
2133
2225
  }
@@ -2259,14 +2351,10 @@ class AparteConfig {
2259
2351
  this._locale = { ...this._locale, ...translations };
2260
2352
  this._notify();
2261
2353
  }
2262
- /**
2263
- * Get icon HTML string by name
2264
- * Falls back to textual representation if no provider is set
2265
- */
2266
2354
  getIcon(name) {
2267
- const icon = this._iconProvider?.[name];
2355
+ const icon = drawingOf(this._iconProvider, name);
2268
2356
  if (icon) return icon();
2269
- return APARTE_DEFAULT_ICON_FALLBACKS[name];
2357
+ return Object.prototype.hasOwnProperty.call(APARTE_DEFAULT_ICON_FALLBACKS, name) ? APARTE_DEFAULT_ICON_FALLBACKS[name] : void 0;
2270
2358
  }
2271
2359
  /**
2272
2360
  * Set a custom Key provider (e.g., AparteVault override)
@@ -2275,26 +2363,16 @@ class AparteConfig {
2275
2363
  this._keyProvider = provider;
2276
2364
  }
2277
2365
  /**
2278
- * Add a key source, without replacing the one `setKeyProvider` holds. Returns a
2279
- * teardown.
2280
- *
2281
- * This is how `AparteClient({ keyResolver })` — the channel the docs teach as
2282
- * primary — reaches everything that needs a key rather than the chat alone. It
2283
- * used to reach the chat ONLY, so the model selector, whose one data path is
2284
- * `refreshProviderModels`, showed an empty list on every cloud provider while
2285
- * the chat worked: `fetchModels` returns `[]` without a key, so there was
2286
- * nothing to see and nothing said.
2287
- *
2288
- * The direction matters (decision #9): the client registers a source ON the
2289
- * config, so the capability is never hostage to the client — a page that only
2290
- * ever calls `setKeyProvider` is unchanged, and a page that constructs no
2291
- * client at all still lists models.
2292
- *
2293
- * Every call is its own source, even when the same function is handed in
2294
- * twice: two panes sharing one module-level resolver are two mounts, and the
2295
- * first one to unmount must not take the second one's key with it. A `Set`
2296
- * keyed by the function itself made them one entry, which either teardown
2297
- * removed.
2366
+ * Add a key source beside the one {@link AparteConfig.setKeyProvider | setKeyProvider()}
2367
+ * holds, and return the function that removes it. The sources are asked in the order they
2368
+ * were added, then `setKeyProvider`'s, and the first answer wins. `AparteClient`'s
2369
+ * `keyResolver` is registered this way, so the model selector can list a cloud provider's
2370
+ * models too. Each call is a source of its own, even with the same function.
2371
+ * @example
2372
+ * const release = aparteGlobalConfig.registerKeyProvider((providerId) =>
2373
+ * providerId === 'openrouter' ? localStorage.getItem('openrouter-key') : undefined);
2374
+ * // when what owns the key goes away:
2375
+ * release();
2298
2376
  */
2299
2377
  registerKeyProvider(provider) {
2300
2378
  const entry = (providerId) => provider(providerId);
@@ -2330,9 +2408,10 @@ class AparteConfig {
2330
2408
  return void 0;
2331
2409
  }
2332
2410
  /**
2333
- * Refresh models for a specific provider
2334
- * Orchestrates: Key Retrieval -> Fetch -> Return
2335
- * This keeps UI components unaware of keys.
2411
+ * Fetch a provider's models, with the key and endpoint its key sources answer, so a
2412
+ * component that lists models never handles a key. The list is kept, and
2413
+ * `getCurrentModel()` reads the selected model's capabilities from it. Answers `[]` for an
2414
+ * unknown provider, one with no `fetchModels`, or a fetch that fails (with a console warning).
2336
2415
  */
2337
2416
  async refreshProviderModels(providerId) {
2338
2417
  const provider = this._aiProviders.get(providerId);
@@ -2351,7 +2430,13 @@ class AparteConfig {
2351
2430
  // AI Provider Management (BYORK - Bring Your Own Key)
2352
2431
  // ─────────────────────────────────────────────────────────────────────────
2353
2432
  /**
2354
- * Register one or more AI providers (e.g., OpenRouter, Gemini, Anthropic)
2433
+ * Register one or more AI providers (e.g., OpenRouter, Gemini, Anthropic).
2434
+ *
2435
+ * When this leaves exactly one provider registered, offering exactly one model it knows
2436
+ * without a request (a scripted or an in-browser provider), and no model is selected,
2437
+ * that model is selected. A provider that lists its models over the network selects
2438
+ * nothing: name one with {@link AparteConfig.setModelConfig | setModelConfig()}, or let the
2439
+ * person pick in `<aparte-model-selector>`.
2355
2440
  * @param providers AparteAIProvider implementations
2356
2441
  * @example
2357
2442
  * aparteGlobalConfig.registerAIProvider(OpenRouterProvider);
@@ -2416,11 +2501,14 @@ class AparteConfig {
2416
2501
  /**
2417
2502
  * Register a model preference provider for agnostic persistence.
2418
2503
  * The host app decides how/where to store the selected provider & model.
2504
+ * `load` answers `{ provider, model }` — the names {@link AparteModelPreference} reads —
2505
+ * and {@link AparteConfig.restoreModelPreference | restoreModelPreference()} applies it.
2419
2506
  * @example
2420
2507
  * aparteGlobalConfig.setModelPreferenceProvider({
2421
- * save: (p, m) => localStorage.setItem('model', JSON.stringify({p, m})),
2422
- * load: () => JSON.parse(localStorage.getItem('model') ?? 'null')
2508
+ * save: (provider, model) => localStorage.setItem('model', JSON.stringify({ provider, model })),
2509
+ * load: () => JSON.parse(localStorage.getItem('model') ?? 'null'),
2423
2510
  * });
2511
+ * aparteGlobalConfig.restoreModelPreference();
2424
2512
  */
2425
2513
  setModelPreferenceProvider(provider) {
2426
2514
  this._modelPreferenceProvider = provider;
@@ -2467,9 +2555,10 @@ class AparteConfig {
2467
2555
  return { verdict: tool?.needsApproval ? "ask" : "allow" };
2468
2556
  }
2469
2557
  /**
2470
- * Restore previously saved model preference via the registered provider.
2558
+ * Restore the saved model preference via the registered provider.
2471
2559
  * Should be called once at app startup, before any component mounts.
2472
- * No-op if no provider is registered or nothing was saved.
2560
+ * No-op if no provider is registered or nothing was saved. The saved selection is
2561
+ * applied without being saved again.
2473
2562
  */
2474
2563
  restoreModelPreference() {
2475
2564
  if (!this._modelPreferenceProvider) return null;
@@ -2482,7 +2571,8 @@ class AparteConfig {
2482
2571
  return null;
2483
2572
  }
2484
2573
  /**
2485
- * Set model selection configuration
2574
+ * Select the default provider and model, merged over the current selection. With a
2575
+ * model-preference provider registered and both set, the selection is saved through it.
2486
2576
  */
2487
2577
  setModelConfig(config) {
2488
2578
  this._modelConfig = { ...this._modelConfig, ...config };
@@ -2526,34 +2616,20 @@ class AparteConfig {
2526
2616
  hasSelectedModel() {
2527
2617
  return !!(this._modelConfig.defaultProvider && this._modelConfig.defaultModel);
2528
2618
  }
2529
- /**
2530
- * Opt-in UX: when enabled, `<aparte-composer>` blocks sending and greys out
2531
- * until {@link hasSelectedModel} is true (e.g. while the model selector is
2532
- * still fetching its list). Off by default so single-model / backend setups
2533
- * that never select a model are unaffected.
2534
- */
2535
2619
  /**
2536
2620
  * Elicitation policy: whether a choice offers a free-text "Other…" escape.
2537
2621
  *
2538
- * This is the HOST's decision, not the model's. `ask_user` used to carry an
2539
- * `allow_other` field in the schema it hands the model, which meant the model
2540
- * decided your UX — and a small model fills a field it does not understand: one
2541
- * sent two questions with `allow_other: true` and no options at all, so the
2542
- * panel rendered a radio list whose only entry was "Other…", twice.
2543
- *
2544
- * Every serious implementation of this pattern makes the escape hatch a property
2545
- * of the surface rather than of the request. Default `true`, which is what the
2546
- * panel did before, so nothing a user sees changes — only who gets to say so.
2547
- * A direct `requestUserInput` caller can still set `allowOther` per field; that
2548
- * is the app talking, and it wins.
2622
+ * This is the HOST's decision, not the model's: the `ask_user` schema handed to the
2623
+ * model carries no `allow_other` field, because a small model fills a field it does
2624
+ * not understand (a question with `allow_other: true` and no options renders a radio
2625
+ * list whose only entry is "Other…"). Default `true`. A direct `requestUserInput`
2626
+ * caller can still set `allowOther` per field; that is the app talking, and it wins.
2549
2627
  *
2550
2628
  * `layout` decides how a form of SEVERAL questions is presented.
2551
2629
  * `'stepped'` (default) asks them one at a time with a chip per question;
2552
- * `'stacked'` puts them all in the panel at once. Stacked was the only shape,
2553
- * inherited unexamined from MCP elicitation — which describes a FORM for
2554
- * collecting structured data, not two different questions asked mid-conversation.
2555
- * No product asks a person two questions by stacking them in one box; it is kept
2556
- * as an option because the form case is real, not because it was the right default.
2630
+ * `'stacked'` puts them all in the panel at once — the shape of an MCP elicitation
2631
+ * FORM collecting structured data, which is why it stays an option, but not the
2632
+ * right default for two different questions asked mid-conversation.
2557
2633
  *
2558
2634
  * `answerOnClick` decides how a question asked ON ITS OWN with a single choice — an
2559
2635
  * `enum` without `multiple` or a `default`, a `boolean` without a `default` — is
@@ -2590,6 +2666,12 @@ class AparteConfig {
2590
2666
  getElicitationFieldRenderer() {
2591
2667
  return this._elicitationFieldRenderer;
2592
2668
  }
2669
+ /**
2670
+ * Opt-in UX: when enabled, `<aparte-composer>` blocks sending and greys out
2671
+ * until {@link hasSelectedModel} is true (e.g. while the model selector is
2672
+ * still fetching its list). Off by default so single-model / backend setups
2673
+ * that never select a model are unaffected.
2674
+ */
2593
2675
  setRequireModelSelection(required) {
2594
2676
  if (this._requireModelSelection === required) return;
2595
2677
  this._requireModelSelection = required;
@@ -2625,7 +2707,8 @@ class AparteConfig {
2625
2707
  // Public Rendering Methods (with Fallbacks)
2626
2708
  // ─────────────────────────────────────────────────────────────────────────
2627
2709
  /**
2628
- * Get translated string by key
2710
+ * The locale's string for `key`, or the default (English) locale's when the locale
2711
+ * leaves it out; `''` for a key neither declares.
2629
2712
  */
2630
2713
  t(key) {
2631
2714
  const val = this._locale[key] || APARTE_DEFAULT_LOCALE[key];
@@ -2665,7 +2748,7 @@ class AparteConfig {
2665
2748
  }
2666
2749
  /**
2667
2750
  * Highlight code block
2668
- * Fallback: Returns raw code wrapped in <pre><code>
2751
+ * Fallback: Returns raw code wrapped in `<pre><code>`
2669
2752
  * Supports Promise if provider is async/streaming
2670
2753
  */
2671
2754
  async highlightCode(code, lang) {
@@ -2701,18 +2784,11 @@ class AparteConfig {
2701
2784
  return Array.from(this._tools.values()).map((e) => e.tool);
2702
2785
  }
2703
2786
  /**
2704
- * The registered tools' own `systemPrompt`s, joined, or `null` if none set one.
2705
- *
2706
- * `AparteTool.systemPrompt` has always been documented as "injected automatically when
2707
- * this tool is registered", on the type and in the tools guide — and nothing read it.
2708
- * A field that is documented and dead is worse than one that does not exist: a caller
2709
- * writes it, sees the tool work (the model has the schema either way), and never learns
2710
- * that the sentence explaining WHEN to reach for it was dropped. `@aparte/plugin-ask-user`
2711
- * sets one, so a shipped plugin was losing its instructions.
2712
- *
2713
- * Registration order, blank line between, because two tools' instructions are two
2714
- * paragraphs and not one run-on sentence. The app's own template stays a separate
2715
- * message — see `resolveSystemPrompt`, which is about the app and not about the tools.
2787
+ * The registered tools' own `systemPrompt`s, in registration order with a blank line
2788
+ * between, or `null` if none sets one. `AparteClient` sends them with every request, as a
2789
+ * system message after the app's own ({@link AparteConfig.resolveSystemPrompt | resolveSystemPrompt()});
2790
+ * a request you assemble yourself should send them the same way, or the model loses the
2791
+ * tools' instructions.
2716
2792
  */
2717
2793
  resolveToolSystemPrompts() {
2718
2794
  const prompts = Array.from(this._tools.values()).map((entry) => entry.tool.systemPrompt?.trim()).filter((prompt) => Boolean(prompt));
@@ -2753,8 +2829,8 @@ class AparteConfig {
2753
2829
  * is a string, so a consumer's own segment type is covered by the same call.
2754
2830
  *
2755
2831
  * The problem it solves: a consumer streaming a reply does not CONSTRUCT its
2756
- * segments — the parser does — so there was no hook at all for "reasoning blocks
2757
- * open in my app". Per-segment fields only reach segments you build yourself.
2832
+ * segments — the parser does — so a per-segment field cannot express "reasoning
2833
+ * blocks open in my app": per-segment fields only reach segments you build yourself.
2758
2834
  *
2759
2835
  * Applied where identity is stamped (`utils/segments.ts`), so it covers every
2760
2836
  * path a segment can arrive by — the parser, `addSegment`, and the segments
@@ -2795,8 +2871,8 @@ class AparteConfig {
2795
2871
  * Say what a call targets, in the default row, after the tool's name.
2796
2872
  *
2797
2873
  * The default row names the tool and keeps its arguments behind a disclosure, so a
2798
- * reader saw that a file was read, not which one. Replacing the row with
2799
- * `registerToolRenderer` for that one line meant re-implementing its six states,
2874
+ * reader sees that a file was read, not which one. Replacing the row with
2875
+ * `registerToolRenderer` for that one line would mean re-implementing its six states,
2800
2876
  * the spinner, the disclosure and the relabel contract. This keeps all of them and
2801
2877
  * adds the line. A tool with its own renderer draws its own row, so its summary is
2802
2878
  * never read.
@@ -2822,6 +2898,27 @@ class AparteConfig {
2822
2898
  getToolSummary(toolName) {
2823
2899
  return this._toolSummaries.get(toolName);
2824
2900
  }
2901
+ /**
2902
+ * Show a tool's arguments as a person reads them, in the approval panel and in the
2903
+ * default tool row's disclosure — one formatter for both. Without one, both show the
2904
+ * JSON the model sent.
2905
+ *
2906
+ * @example
2907
+ * // Approve "write_file notes.md (2 KB)" rather than a JSON object.
2908
+ * aparteGlobalConfig.registerToolInputFormatter('write_file', (input) =>
2909
+ * `${String(input['path'] ?? '')} (${Math.ceil(String(input['content'] ?? '').length / 1024)} KB)`);
2910
+ */
2911
+ registerToolInputFormatter(toolName, formatter) {
2912
+ this._toolInputFormatters.set(toolName, formatter);
2913
+ }
2914
+ /** Unregister a tool's input formatter. */
2915
+ unregisterToolInputFormatter(toolName) {
2916
+ this._toolInputFormatters.delete(toolName);
2917
+ }
2918
+ /** Get the input formatter for a tool name. Returns undefined if none registered. */
2919
+ getToolInputFormatter(toolName) {
2920
+ return this._toolInputFormatters.get(toolName);
2921
+ }
2825
2922
  /**
2826
2923
  * Teach the stream parser a tagged block: `<tag attr="…">…</tag>` in the model's
2827
2924
  * prose becomes the segment `toSegment` builds, streamed delta by delta.
@@ -2876,6 +2973,12 @@ class AparteConfig {
2876
2973
  * confirmation, a text field, a form) and resolves with the user's answer.
2877
2974
  * The `<aparte-elicitation>` Web Component registers itself here; an app can
2878
2975
  * override with its own framework-native presenter. Pass `null` to clear.
2976
+ *
2977
+ * Several can be registered, one per chat: `owner` is an element of the chat the
2978
+ * presenter belongs to (`<aparte-elicitation>` passes itself). A request whose `target`
2979
+ * is inside that chat reaches its presenter; a request with no `target`, or from a chat
2980
+ * with no presenter of its own, reaches the most recently registered one. Registering a
2981
+ * presenter again makes it the most recent.
2879
2982
  */
2880
2983
  setElicitationPresenter(presenter, owner) {
2881
2984
  if (presenter === null) {
@@ -2910,7 +3013,7 @@ class AparteConfig {
2910
3013
  * the same chat is the right one — found by comparing chat boundaries rather than by
2911
3014
  * `contains`, because the presenter is a sibling of the transcript, not an ancestor
2912
3015
  * of it. With no target, no match, or no boundary to compare, the top of the stack
2913
- * is the answer, which is exactly the old single-slot behaviour.
3016
+ * is the answer.
2914
3017
  */
2915
3018
  _presenterFor(target) {
2916
3019
  const stack = this._elicitationPresenters;
@@ -2927,35 +3030,21 @@ class AparteConfig {
2927
3030
  return stack[stack.length - 1]?.fn;
2928
3031
  }
2929
3032
  /**
2930
- * Ask the user for typed input mid-run and await their response. This is the
2931
- * generic primitive behind `ask_user` and tool approval — the KIND of
2932
- * question is the schema, not a bespoke tool.
3033
+ * Ask the person for typed input in the middle of a run, and wait for the answer. The
3034
+ * primitive behind `ask_user` and tool approval: the kind of question is its schema.
2933
3035
  *
2934
- * **Resolves** `accept` with the value, or `decline` when the user declines.
3036
+ * **Resolves** `accept` with the value, or `decline` when the person declines.
2935
3037
  *
2936
- * **Rejects** — it does not resolve — when the request ends without an answer, with
2937
- * an {@link AparteElicitationAbortError} whose `name` is `'AbortError'` and whose
2938
- * `reason` is `'aborted'` (the turn was stopped, the signal fired, or another request
2939
- * took the question away) or `'no-presenter'` (nothing was mounted to ask it). So
2940
- * `await` it inside a `try`, or attach a `.catch()`.
3038
+ * **Rejects**, with no answer, with an {@link AparteElicitationAbortError} (its `name` is
3039
+ * `'AbortError'`) whose `reason` is `'aborted'` — the turn was stopped, the signal fired,
3040
+ * or another request took the question away — or `'no-presenter'`, when nothing is mounted
3041
+ * to ask it (with a console warning, once). `await` it in a `try`, or attach a `.catch()`.
2941
3042
  *
2942
- * That is a change, and this block described the old behaviour for a whole release:
2943
- * "resolves `cancel` when the turn is cancelled", and "with NO presenter registered it
2944
- * resolves `cancel` rather than hanging". Both were false the moment `cancel` was
2945
- * removed — and the second is the one that bites, because a developer who reads
2946
- * "rather than hanging" writes no `.catch()` and gets an unhandled rejection. It ships
2947
- * in the `.d.ts` and in the custom-elements manifest, which is the API surface the
2948
- * docs site renders, so it was the most-read wrong sentence in the library.
2949
- *
2950
- * A value was removed rather than renamed on purpose: `cancel` was easy to handle as
2951
- * though it were an answer, and the approval gate did exactly that — it read a stopped
2952
- * turn as the user refusing a tool they were never shown.
2953
- *
2954
- * With no presenter it rejects `'no-presenter'` and warns once, because a request
2955
- * nobody could see is a setup mistake only the developer can fix. The default
2956
- * presenter is `<aparte-elicitation>`, which registers itself from its
2957
- * `connectedCallback` — so it has to be IN THE DOM. A docs page of ours claimed it
2958
- * "installs itself — nothing to register", which is how that was found.
3043
+ * The question goes to the presenter of the chat that holds `request.target` (see
3044
+ * {@link AparteConfig.setElicitationPresenter | setElicitationPresenter()});
3045
+ * `<aparte-elicitation>` is the default one, and registers itself once it is in the DOM.
3046
+ * A chat shows one question at a time: a second request in the same chat waits until the
3047
+ * first is answered or ends, and requests with no `target` share one queue.
2959
3048
  */
2960
3049
  requestUserInput(request) {
2961
3050
  const queue = this._elicitationQueueKey(request);
@@ -2975,11 +3064,10 @@ class AparteConfig {
2975
3064
  *
2976
3065
  * The wait exists so a second question cannot take the panel away from the one on
2977
3066
  * screen — a property of ONE panel, and `_present` routes by target, so a chat is
2978
- * the right unit. It used to be one queue for the whole config: two chats on the
2979
- * global config shared it, and an approval waiting for an answer in the first (which
2980
- * is all an approval does) held the second's question behind it for ever, with
2981
- * nothing on screen to answer. Requests that name no chat keep sharing the `null`
2982
- * queue, because they all reach the same presenter — the top of the stack.
3067
+ * the right unit. One queue for the whole config would let an approval waiting in
3068
+ * the first chat hold the second chat's question behind it for ever. Requests that
3069
+ * name no chat share the `null` queue, because they all reach the same presenter —
3070
+ * the top of the stack.
2983
3071
  */
2984
3072
  _elicitationQueueKey(request) {
2985
3073
  return chatBoundaryOf(request.target);
@@ -2997,13 +3085,23 @@ class AparteConfig {
2997
3085
  return Promise.reject(new AparteElicitationAbortError("no-presenter"));
2998
3086
  }
2999
3087
  if (request.signal?.aborted) return Promise.reject(new AparteElicitationAbortError("aborted"));
3000
- return presenter(request);
3088
+ const answer = presenter(request);
3089
+ const signal = request.signal;
3090
+ if (!signal) return answer;
3091
+ return new Promise((resolve, reject) => {
3092
+ const onAbort = () => reject(new AparteElicitationAbortError("aborted"));
3093
+ signal.addEventListener("abort", onAbort, { once: true });
3094
+ answer.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
3095
+ });
3001
3096
  }
3002
3097
  /**
3003
- * Reset ALL configuration back to defaults — providers, registries, model
3004
- * selection and bubble actions. Previously left `_aiProviders` / `_tools` /
3005
- * `_toolRenderers` / `_modelConfig` behind, which leaked across SPA
3006
- * navigations (registries only ever grew). Now a full reset.
3098
+ * Put the configuration back to its defaults: the providers and the selected model,
3099
+ * the tools with their renderers and summaries, the renderers and render hooks, the
3100
+ * Markdown and highlight providers, the sanitizer, the system prompt, the locale, icons,
3101
+ * avatars, actions and host handlers, the conversation manager, the question presenters
3102
+ * and their queue. Three things stay: the transport, the key sources added with
3103
+ * {@link AparteConfig.registerKeyProvider | registerKeyProvider()} (an `AparteClient`'s
3104
+ * `keyResolver` among them), and the {@link AparteConfig.subscribe | subscribe()} listeners.
3007
3105
  */
3008
3106
  reset() {
3009
3107
  this._markdownProvider = void 0;
@@ -3031,6 +3129,7 @@ class AparteConfig {
3031
3129
  this._tools.clear();
3032
3130
  this._toolRenderers.clear();
3033
3131
  this._toolSummaries.clear();
3132
+ this._toolInputFormatters.clear();
3034
3133
  this._streamBlocks.clear();
3035
3134
  this._modelConfig = {};
3036
3135
  this._requireModelSelection = false;
@@ -3066,46 +3165,6 @@ function getGlobalConfig() {
3066
3165
  return new AparteConfig();
3067
3166
  }
3068
3167
  const aparteGlobalConfig = getGlobalConfig();
3069
- const APARTE_HOST_ATTR = "data-aparte-host";
3070
- const CONFIG_SLOT = Symbol.for("aparte.instanceConfig");
3071
- function notifyConfigChanged(root, next, previous) {
3072
- if (next === previous) return;
3073
- const call = (el2) => {
3074
- const hook = el2.aparteConfigChanged;
3075
- if (typeof hook === "function") hook.call(el2, next, previous);
3076
- };
3077
- call(root);
3078
- for (const el2 of root.querySelectorAll("*")) call(el2);
3079
- }
3080
- function attachConfig(el2, config) {
3081
- const previous = el2[CONFIG_SLOT] ?? resolveConfig(el2.parentElement);
3082
- el2[CONFIG_SLOT] = config;
3083
- el2.setAttribute(APARTE_HOST_ATTR, "");
3084
- notifyConfigChanged(el2, config, previous);
3085
- }
3086
- function detachConfig(el2) {
3087
- const previous = el2[CONFIG_SLOT];
3088
- delete el2[CONFIG_SLOT];
3089
- el2.removeAttribute(APARTE_HOST_ATTR);
3090
- if (previous) notifyConfigChanged(el2, resolveConfig(el2.parentElement), previous);
3091
- }
3092
- function resolveConfig(el2) {
3093
- const host = el2?.closest?.(`[${APARTE_HOST_ATTR}]`);
3094
- return host?.[CONFIG_SLOT] ?? aparteGlobalConfig;
3095
- }
3096
- let _renderConfig = null;
3097
- function runWithConfig(config, fn) {
3098
- const prev = _renderConfig;
3099
- _renderConfig = config;
3100
- try {
3101
- return fn();
3102
- } finally {
3103
- _renderConfig = prev;
3104
- }
3105
- }
3106
- function contextConfig(el2) {
3107
- return _renderConfig ?? resolveConfig(el2);
3108
- }
3109
3168
  const APARTE_CONFIG_CHANGE = "aparte-config-change";
3110
3169
  function subscribeConfigChange(el2, handler) {
3111
3170
  const listener = (e) => {
@@ -3163,6 +3222,7 @@ function writeStreamedMarkdown(host, contentEl, content, streaming) {
3163
3222
  contentEl.innerHTML = contextConfig().renderMarkdown(content);
3164
3223
  }
3165
3224
  function highlightMarkdownFences(contentEl) {
3225
+ if (!contextConfig().hasHighlightProvider()) return;
3166
3226
  const blocks = contentEl.querySelectorAll("pre > code:not([data-aparte-highlighted])");
3167
3227
  for (const block of blocks) {
3168
3228
  const pre = block.parentElement;
@@ -3179,6 +3239,16 @@ function highlightMarkdownFences(contentEl) {
3179
3239
  const textRenderer = {
3180
3240
  type: "text",
3181
3241
  render: (segment) => `<div class="aparte-segment aparte-segment-text" data-segment-id="${escapeHtml(segment.id)}"><div class="aparte-segment-content">${contextConfig().renderMarkdown(segment.content)}</div></div>`,
3242
+ /*
3243
+ * A fence in a settled text segment is highlighted, as the thinking renderer does for
3244
+ * its own. The parser turns a streamed fence into a `code` segment, so this only ever
3245
+ * mattered for text built by hand (`setSegments`, a restored message, a server's) — and
3246
+ * the bubble's own pass skips any message that has segments, so that fence stayed bare.
3247
+ */
3248
+ setup: (el2, segment) => {
3249
+ const contentEl = el2.querySelector(".aparte-segment-content");
3250
+ if (contentEl && segment.isStreaming !== true) highlightMarkdownFences(contentEl);
3251
+ },
3182
3252
  update: (el2, segment) => {
3183
3253
  const contentEl = el2.querySelector(".aparte-segment-content");
3184
3254
  if (!contentEl) return;
@@ -3188,10 +3258,14 @@ const textRenderer = {
3188
3258
  segment.content,
3189
3259
  segment.isStreaming !== false
3190
3260
  );
3261
+ if (segment.isStreaming === false) highlightMarkdownFences(contentEl);
3191
3262
  },
3192
3263
  getStyles: () => ``
3193
3264
  };
3194
3265
  const THINKING_ANCHOR_SLACK = 24;
3266
+ function labelOf(segment) {
3267
+ return segment.label && segment.label !== "Thinking" ? segment.label : contextConfig().t("thinking");
3268
+ }
3195
3269
  const thinkingRenderer = {
3196
3270
  type: "thinking",
3197
3271
  /*
@@ -3222,13 +3296,13 @@ const thinkingRenderer = {
3222
3296
  * only part that is genuinely about reasoning rather than about disclosure.
3223
3297
  * `.aparte-thinking-label` stays too: `relabel` below queries it.
3224
3298
  */
3225
- render: (segment) => `<details class="aparte-segment aparte-segment-thinking aparte-accordion__item" data-segment-id="${escapeHtml(segment.id)}" ${segment.collapsed === false ? "open" : ""}><summary class="aparte-accordion__header aparte-thinking-header"><span class="aparte-thinking-label">${escapeHtml(segment.label || contextConfig().t("thinking"))}</span><span class="aparte-accordion__icon">${contextConfig().getIcon("expand")}</span></summary><div class="aparte-accordion__panel aparte-thinking-content" role="region" tabindex="0" aria-label="${escapeAttr(segment.label || contextConfig().t("thinking"))}">${contextConfig().renderMarkdown(segment.content)}</div></details>`,
3299
+ render: (segment) => `<details class="aparte-segment aparte-segment-thinking aparte-accordion__item" data-segment-id="${escapeHtml(segment.id)}" ${segment.collapsed === false ? "open" : ""}><summary class="aparte-accordion__header aparte-thinking-header"><span class="aparte-thinking-label">${escapeHtml(labelOf(segment))}</span><span class="aparte-accordion__icon">${contextConfig().getIcon("expand")}</span></summary><div class="aparte-accordion__panel aparte-thinking-content" role="region" tabindex="0" aria-label="${escapeAttr(labelOf(segment))}">${contextConfig().renderMarkdown(segment.content)}</div></details>`,
3226
3300
  /**
3227
3301
  * The default label is the only config-derived text here — and `segment.label`
3228
3302
  * still wins, exactly as in `render`, because that string is the app's.
3229
3303
  */
3230
3304
  relabel: (el2, segment) => {
3231
- const text = segment.label || contextConfig().t("thinking");
3305
+ const text = labelOf(segment);
3232
3306
  const label = el2.querySelector(".aparte-thinking-label");
3233
3307
  if (label) label.textContent = text;
3234
3308
  el2.querySelector(".aparte-thinking-content")?.setAttribute("aria-label", text);
@@ -3416,10 +3490,7 @@ const errorRenderer = {
3416
3490
  `;
3417
3491
  },
3418
3492
  /**
3419
- * The icon and the heading. The heading used to be the hardcoded literal `Error`
3420
- * while `locale.error` — a REQUIRED key, documented, and already translated to
3421
- * "Erreur" — was read by nothing at all. A translated string with no consumer and
3422
- * a literal with no translation, in the same card.
3493
+ * The icon and the heading (`locale.error`).
3423
3494
  *
3424
3495
  * Not the message: that is the model's or the transport's text, in whatever
3425
3496
  * language it arrived in. Relabelling it would be inventing content.
@@ -3432,8 +3503,15 @@ const errorRenderer = {
3432
3503
  },
3433
3504
  getStyles: () => ``
3434
3505
  };
3435
- function describeToolInput(input) {
3506
+ function describeToolInput(input, format) {
3436
3507
  if (!input || typeof input !== "object" || Object.keys(input).length === 0) return "";
3508
+ if (format) {
3509
+ try {
3510
+ const text = format(input);
3511
+ if (typeof text === "string") return text;
3512
+ } catch {
3513
+ }
3514
+ }
3437
3515
  try {
3438
3516
  return JSON.stringify(input, null, 2);
3439
3517
  } catch {
@@ -3441,7 +3519,7 @@ function describeToolInput(input) {
3441
3519
  }
3442
3520
  }
3443
3521
  function describeInput(segment) {
3444
- return describeToolInput(segment.toolCall?.input);
3522
+ return describeToolInput(segment.toolCall?.input, contextConfig().getToolInputFormatter(segment.toolCall?.name ?? ""));
3445
3523
  }
3446
3524
  function stateBadge(segment) {
3447
3525
  const cfg = contextConfig();
@@ -3805,6 +3883,19 @@ function withoutDerivedTree(tree) {
3805
3883
  messages: tree.messages.map((entry) => ({ ...entry, message: withoutDerived(entry.message) }))
3806
3884
  };
3807
3885
  }
3886
+ function stableStringify(value) {
3887
+ return JSON.stringify(value, (_key, v) => v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) : v);
3888
+ }
3889
+ function asContent(message) {
3890
+ return adoptMessageSegments(withoutDerived(message));
3891
+ }
3892
+ function contentOf(messages) {
3893
+ return stableStringify(messages.map(asContent));
3894
+ }
3895
+ function treeContentOf(tree) {
3896
+ const byId = [...tree.messages].sort((a, b) => a.message.id < b.message.id ? -1 : a.message.id > b.message.id ? 1 : 0);
3897
+ return stableStringify(byId.map((entry) => ({ parentId: entry.parentId, message: asContent(entry.message) })));
3898
+ }
3808
3899
  class AparteConversationManager {
3809
3900
  _adapter;
3810
3901
  _conversations = [];
@@ -3995,9 +4086,9 @@ class AparteConversationManager {
3995
4086
  *
3996
4087
  * One append at a time per conversation: two sends that overlap (a suggestion and a
3997
4088
  * quick type, coalesced into one new conversation) both suspend before reading the
3998
- * record — for the messages, then for the title provider — so both used to read a
3999
- * conversation with no user message in it yet, and the second one's text became the
4000
- * title.
4089
+ * record — for the messages, then for the title provider — so the queue keeps the
4090
+ * second one from reading a conversation with no user message in it yet and
4091
+ * taking its text as the title.
4001
4092
  */
4002
4093
  async addMessage(convId, msg) {
4003
4094
  const previous = this._appending.get(convId);
@@ -4050,8 +4141,14 @@ class AparteConversationManager {
4050
4141
  let contentChanged = true;
4051
4142
  let treeChanged = tree !== void 0;
4052
4143
  try {
4053
- contentChanged = JSON.stringify(storedMessages) !== JSON.stringify(withoutDerivedSegments(conv.messages));
4054
- if (storedTree !== void 0) treeChanged = JSON.stringify(storedTree) !== JSON.stringify(conv.tree);
4144
+ if (storedTree !== void 0 && conv.tree !== void 0) {
4145
+ const treesDiffer = treeContentOf(storedTree) !== treeContentOf(conv.tree);
4146
+ contentChanged = treesDiffer || storedTree.headId === conv.tree.headId && contentOf(storedMessages) !== contentOf(conv.messages);
4147
+ treeChanged = treesDiffer || storedTree.headId !== conv.tree.headId;
4148
+ } else {
4149
+ contentChanged = contentOf(storedMessages) !== contentOf(conv.messages);
4150
+ if (storedTree !== void 0) treeChanged = true;
4151
+ }
4055
4152
  } catch {
4056
4153
  }
4057
4154
  let title;
@@ -4212,6 +4309,7 @@ function revokeAttachmentUrls(attachments) {
4212
4309
  if (attachment.url?.startsWith("blob:")) URL.revokeObjectURL(attachment.url);
4213
4310
  }
4214
4311
  }
4312
+ const MAX_COMPACTION_SAVE_WAITS = 6;
4215
4313
  class AparteConversationController {
4216
4314
  _binding;
4217
4315
  _options;
@@ -4227,6 +4325,7 @@ class AparteConversationController {
4227
4325
  _onMessageAborted = null;
4228
4326
  _onMessagePaused = null;
4229
4327
  _onSelectConversation = null;
4328
+ _onCompactDone = null;
4230
4329
  _unsubscribeManager = null;
4231
4330
  /** The manager the subscription above is on, so a replaced manager is re-subscribed. */
4232
4331
  _subscribedTo = null;
@@ -4289,9 +4388,9 @@ class AparteConversationController {
4289
4388
  /**
4290
4389
  * Degraded mode is legitimate — a host that never opted into the conversation
4291
4390
  * lifecycle has no manager and should not be nagged. Registering one on the
4292
- * WRONG config is not: the four wrapper hooks used to write it to the global
4293
- * singleton unconditionally, so `config` + persistence looked like it worked
4294
- * (the optimistic UI still appends) and saved nothing at all.
4391
+ * WRONG config is not: a manager registered on the global singleton while the chat
4392
+ * uses its own `config` looks like it works (the optimistic UI still appends) and
4393
+ * saves nothing at all.
4295
4394
  *
4296
4395
  * Warned once per controller, and only in the case that is unambiguously a
4297
4396
  * mistake: no manager here, but one on the global.
@@ -4364,8 +4463,14 @@ class AparteConversationController {
4364
4463
  if (targetId && targetId !== this._binding.hostId) return;
4365
4464
  void this.setConversationId(evt.detail?.id ?? null);
4366
4465
  };
4466
+ this._onCompactDone = (e) => {
4467
+ const detail = e.detail;
4468
+ if (detail?.targetId && detail.targetId !== this._binding.hostId) return;
4469
+ queueMicrotask(() => this._persistOnceSummarised(detail?.summary, 0));
4470
+ };
4367
4471
  if (typeof window !== "undefined") {
4368
4472
  window.addEventListener("aparte-conversation-select", this._onSelectConversation);
4473
+ window.addEventListener("aparte-compact-done", this._onCompactDone);
4369
4474
  }
4370
4475
  this._bound = true;
4371
4476
  this._manager();
@@ -4409,6 +4514,10 @@ class AparteConversationController {
4409
4514
  host.removeEventListener("aparte-message-paused", this._onMessagePaused);
4410
4515
  this._onMessagePaused = null;
4411
4516
  }
4517
+ if (this._onCompactDone && typeof window !== "undefined") {
4518
+ window.removeEventListener("aparte-compact-done", this._onCompactDone);
4519
+ this._onCompactDone = null;
4520
+ }
4412
4521
  if (this._onSelectConversation && typeof window !== "undefined") {
4413
4522
  window.removeEventListener("aparte-conversation-select", this._onSelectConversation);
4414
4523
  this._onSelectConversation = null;
@@ -4552,6 +4661,22 @@ class AparteConversationController {
4552
4661
  * re-enter streaming UI (caret, spinner, no action buttons) for content
4553
4662
  * that was partially written before a switch or component destruction.
4554
4663
  */
4664
+ /**
4665
+ * Save once the binding holds the summary a compaction announced. A transcript kept in
4666
+ * a framework store reaches the binding only when that framework renders, so a save on
4667
+ * the microtask wrote the conversation as it stood before the compaction. Bounded: a
4668
+ * run with no summary (skipped), or a binding that never shows it, is saved as it is.
4669
+ */
4670
+ _persistOnceSummarised(summary, waits) {
4671
+ const holds = !summary || this._binding.getMessages().some(
4672
+ (m) => m.compaction === true && typeof m.content === "string" && m.content.endsWith(summary)
4673
+ );
4674
+ if (holds || !this._binding.afterRender || waits >= MAX_COMPACTION_SAVE_WAITS) {
4675
+ this._persistActive();
4676
+ return;
4677
+ }
4678
+ this._binding.afterRender(() => this._persistOnceSummarised(summary, waits + 1));
4679
+ }
4555
4680
  _persistActive() {
4556
4681
  if (!this._activeId) return;
4557
4682
  const manager = this._manager();
@@ -4609,7 +4734,27 @@ class AparteConversationController {
4609
4734
  function cssEscape(value) {
4610
4735
  return value.replace(/["\\]/g, "\\$&").replace(/[\n\r\f]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `);
4611
4736
  }
4737
+ function gateGlide(el2) {
4738
+ if (el2.hasAttribute("data-glide")) return () => {
4739
+ };
4740
+ const doc = el2.ownerDocument;
4741
+ const off = () => {
4742
+ doc.removeEventListener("pointerdown", lift, true);
4743
+ doc.removeEventListener("keydown", lift, true);
4744
+ el2.removeEventListener("aparte-send", lift);
4745
+ };
4746
+ const lift = () => {
4747
+ el2.setAttribute("data-glide", "");
4748
+ off();
4749
+ };
4750
+ doc.addEventListener("pointerdown", lift, true);
4751
+ doc.addEventListener("keydown", lift, true);
4752
+ el2.addEventListener("aparte-send", lift);
4753
+ return off;
4754
+ }
4612
4755
  const MAX_SIBLING_RETRIES = 6;
4756
+ const TREE_OWNED_FIELDS = /* @__PURE__ */ new Set(["id", "branches", "activeBranchIndex", "isStreaming"]);
4757
+ const ALWAYS_PRESENT_FIELDS = /* @__PURE__ */ new Set(["content", "segments", "status"]);
4613
4758
  class AparteChatHost {
4614
4759
  binding;
4615
4760
  options;
@@ -4668,6 +4813,7 @@ class AparteChatHost {
4668
4813
  }
4669
4814
  this._installHostMethods(host);
4670
4815
  this._installLifecycleListeners(host);
4816
+ this._teardown.push(gateGlide(host));
4671
4817
  this._installViewportListeners();
4672
4818
  this._createController(host);
4673
4819
  return () => this.unbind();
@@ -4747,6 +4893,9 @@ class AparteChatHost {
4747
4893
  importTree: (tree) => {
4748
4894
  this._vp()?.importTree?.(tree);
4749
4895
  },
4896
+ // The framework's list arrives at its render, so a save that must see a write
4897
+ // the app made to its own store (a compaction through `resolveTarget`) waits for it.
4898
+ afterRender: (cb) => this.binding.afterRender(cb),
4750
4899
  // One writer for the wait. A wrapper that hears `onLoadingChange` draws the
4751
4900
  // wait itself and writes the viewport's attribute from its own
4752
4901
  // `loading || hostLoading` — so the host writing it too meant that, with the
@@ -4909,11 +5058,8 @@ class AparteChatHost {
4909
5058
  * the same direct path `appendToken` uses;
4910
5059
  * 2. into the framework's message list, **coalesced to once per frame**.
4911
5060
  *
4912
- * It used to do only (2), synchronously: a full list rebuild plus
4913
- * `setMessages` + `onMessagesChange` for every chunk, i.e. one complete
4914
- * framework render per token. At 60 tok/s from a local model that is unusable,
4915
- * and nothing in the imperative API hinted that `appendToSegment` cost so much
4916
- * more than `injectTokenStream`. Consumers had to write their own rAF batcher.
5061
+ * Coalescing (2) avoids a full list rebuild plus `setMessages` +
5062
+ * `onMessagesChange`, i.e. one complete framework render, for every chunk.
4917
5063
  *
4918
5064
  * Any structural change flushes the buffer first (see
4919
5065
  * {@link _flushStreamState}), so ordering is never observable.
@@ -4999,9 +5145,7 @@ class AparteChatHost {
4999
5145
  /**
5000
5146
  * Clear all messages + reset viewport state. The option travels to the viewport
5001
5147
  * untouched: a compaction passes `{ revokeAttachments: false }` because it re-appends
5002
- * the turns it keeps and revokes the dropped ones' object URLs itself — under a
5003
- * framework wrapper this bridge used to drop the argument, and every surviving
5004
- * attachment came back broken.
5148
+ * the turns it keeps and revokes the dropped ones' object URLs itself.
5005
5149
  */
5006
5150
  clearMessages(options) {
5007
5151
  this._beginConversationSwap();
@@ -5045,10 +5189,9 @@ class AparteChatHost {
5045
5189
  *
5046
5190
  * Each token reaches the bubble immediately and the framework's message list
5047
5191
  * is synced **once per frame** (same discipline as {@link appendToSegment}),
5048
- * with a guaranteed flush before completion and on abort. Before that sync
5049
- * existed the two disagreed: the DOM had the reply, the framework list still
5050
- * held `content: ''` — so any re-render from state wiped it, and a custom
5051
- * bubble (which renders from that state) showed nothing at all.
5192
+ * with a guaranteed flush before completion and on abort, so the framework list
5193
+ * and the DOM agree and a custom bubble (which renders from that state) shows the
5194
+ * reply.
5052
5195
  */
5053
5196
  async streamTokens(messageId, tokens) {
5054
5197
  this.stopTokenStream();
@@ -5063,6 +5206,14 @@ class AparteChatHost {
5063
5206
  }
5064
5207
  ac.signal.addEventListener("abort", () => resolve("aborted"), { once: true });
5065
5208
  });
5209
+ const { host, hostId } = this.binding;
5210
+ const onAbort = (e) => {
5211
+ const target = e.detail?.targetId;
5212
+ if ((!target || target === hostId) && this._streamAbort === ac) this.stopTokenStream();
5213
+ };
5214
+ window.addEventListener("aparte-abort", onAbort);
5215
+ const replaced = () => this._streamAbort !== void 0 && this._streamAbort !== ac;
5216
+ dispatchLifecycleEvent(host, "aparte-message-start", { messageId, role: "assistant" }, hostId);
5066
5217
  try {
5067
5218
  for (; ; ) {
5068
5219
  const res = await Promise.race([iterator.next(), aborted]);
@@ -5072,12 +5223,24 @@ class AparteChatHost {
5072
5223
  }
5073
5224
  this._flushStreamState();
5074
5225
  this._vp()?.completeMessage?.(messageId);
5075
- this._setStreamingId(null);
5226
+ if (!replaced()) {
5227
+ this._setStreamingId(null);
5228
+ dispatchLifecycleEvent(
5229
+ host,
5230
+ ac.signal.aborted ? "aparte-message-aborted" : "aparte-message-done",
5231
+ ac.signal.aborted ? { messageId } : { messageId, role: "assistant" },
5232
+ hostId
5233
+ );
5234
+ }
5076
5235
  } catch (err) {
5077
5236
  this._flushStreamState();
5078
- this._setStreamingId(null);
5237
+ if (!replaced()) {
5238
+ this._setStreamingId(null);
5239
+ dispatchLifecycleEvent(host, "aparte-message-error", { messageId, error: err }, hostId);
5240
+ }
5079
5241
  throw err;
5080
5242
  } finally {
5243
+ window.removeEventListener("aparte-abort", onAbort);
5081
5244
  if (ac.signal.aborted) {
5082
5245
  try {
5083
5246
  await iterator.return?.();
@@ -5139,7 +5302,18 @@ class AparteChatHost {
5139
5302
  if (msg.segments !== void 0) updates.segments = msg.segments;
5140
5303
  if (msg.status !== void 0) updates.status = msg.status;
5141
5304
  if (msg.usage !== void 0) updates.usage = msg.usage;
5305
+ if (msg.model !== void 0) updates.model = msg.model;
5142
5306
  if (Object.keys(updates).length > 0) vp.updateMessage?.(msg.id, updates);
5307
+ const stored = vp.getMessage?.(msg.id);
5308
+ if (stored) {
5309
+ for (const [key, value] of Object.entries(msg)) {
5310
+ if (value !== void 0 && !(key in updates) && !TREE_OWNED_FIELDS.has(key)) stored[key] = value;
5311
+ }
5312
+ const own = msg;
5313
+ for (const key of Object.keys(stored)) {
5314
+ if (!TREE_OWNED_FIELDS.has(key) && !ALWAYS_PRESENT_FIELDS.has(key) && own[key] === void 0) delete stored[key];
5315
+ }
5316
+ }
5143
5317
  }
5144
5318
  }
5145
5319
  }
@@ -5147,17 +5321,10 @@ class AparteChatHost {
5147
5321
  * Write the branch counts onto their bubbles — and RESCHEDULE for the ones that are
5148
5322
  * not on the page yet, instead of dropping them.
5149
5323
  *
5150
- * This used to `continue` past a missing bubble and then clear `_pendingSiblings`
5151
- * unconditionally, which lost the fork for good. That is the "1 / 1" defect: the
5152
- * bubble kept its default of one sibling, the picker hid itself, and the other version
5153
- * became unreachable for the life of the page.
5154
- *
5155
- * It needed a framework that renders LATE, which is every framework here. React's
5156
- * `afterRender` is `requestAnimationFrame(() => cb())` — a bet that the next paint
5157
- * lands after React's commit, and it does not always; the repo has lost that same bet
5158
- * before (`25f356b`, "a bet on rAF phase"). The fix is here rather than in the
5159
- * wrapper's rAF call, because ANY binding whose `afterRender` can precede its commit
5160
- * hits this, and one fix at the host covers all four.
5324
+ * Frameworks render LATE: a binding's `afterRender` can run before its commit, so a
5325
+ * bubble may not exist yet when its count arrives. A count that was dropped would
5326
+ * leave the bubble at one sibling, the picker hidden and the other version
5327
+ * unreachable. The retry lives here rather than in a wrapper, so it covers all four.
5161
5328
  *
5162
5329
  * Bounded, because a sibling can legitimately never arrive — a message that left the
5163
5330
  * path has no bubble and never will, and an unbounded retry would hold a callback for
@@ -5205,12 +5372,11 @@ class AparteChatHost {
5205
5372
  * The bubble the streaming writes below belong to: the one carrying that message's
5206
5373
  * id, and NOTHING when it is not on the page yet.
5207
5374
  *
5208
- * It used to be "the last bubble in the viewport", which is a different message
5375
+ * It is not "the last bubble in the viewport", which is a different message
5209
5376
  * whenever the DOM is a render behind the list — which is every framework here.
5210
- * `appendMessage` puts the new reply in React's state and React paints it on its own
5211
- * schedule, so a first chunk arriving in between went into the PREVIOUS reply's
5212
- * bubble; `syncBubbles` then wrote the same segment onto the real one, and the same
5213
- * `data-segment-id` was on two bubbles.
5377
+ * `appendMessage` puts the new reply in the framework's state and the framework
5378
+ * paints it on its own schedule, so a first chunk arriving in between would go into
5379
+ * the PREVIOUS reply's bubble.
5214
5380
  *
5215
5381
  * Skipping the paint is safe because it is only ever a paint: the message list is
5216
5382
  * written either way, and `syncBubbles` — called by the framework after each render,
@@ -5814,12 +5980,15 @@ function el(tag, className, text) {
5814
5980
  if (text != null) node.textContent = text;
5815
5981
  return node;
5816
5982
  }
5983
+ let askSeq = 0;
5817
5984
  function buildApprovalPanel(message, options, onChange, details) {
5818
5985
  const cfg = contextConfig();
5819
5986
  const panel = el("div", "aparte-elic-panel aparte-approval-panel");
5987
+ const body = el("div", "aparte-elic-body");
5988
+ panel.appendChild(body);
5820
5989
  const askText = () => typeof message === "function" ? message() : message;
5821
5990
  const ask = askText() ? el("p", "aparte-elic-message", askText()) : null;
5822
- if (ask) panel.appendChild(ask);
5991
+ if (ask) body.appendChild(ask);
5823
5992
  let argsLabel = null;
5824
5993
  if (details) {
5825
5994
  argsLabel = el("div", "aparte-approval-args-label", cfg.t("approvalArgsLabel"));
@@ -5829,7 +5998,7 @@ function buildApprovalPanel(message, options, onChange, details) {
5829
5998
  pre.setAttribute("role", "group");
5830
5999
  pre.setAttribute("aria-labelledby", argsLabel.id);
5831
6000
  pre.tabIndex = 0;
5832
- panel.append(argsLabel, pre);
6001
+ body.append(argsLabel, pre);
5833
6002
  }
5834
6003
  const dismiss = el("div", "aparte-elic-dismiss");
5835
6004
  panel.appendChild(dismiss);
@@ -5843,6 +6012,10 @@ function buildApprovalPanel(message, options, onChange, details) {
5843
6012
  const row = el("div", "aparte-approval-options");
5844
6013
  row.setAttribute("role", "group");
5845
6014
  row.setAttribute("aria-label", cfg.t("approvalOptionsLabel"));
6015
+ if (ask) {
6016
+ ask.id ||= `aparte-approval-ask-${++askSeq}`;
6017
+ row.setAttribute("aria-describedby", ask.id);
6018
+ }
5846
6019
  const labelled = [];
5847
6020
  const textOf = (option) => typeof option.label === "function" ? option.label() : option.label;
5848
6021
  const descriptionOf = (option) => typeof option.description === "function" ? option.description() : option.description;
@@ -5856,13 +6029,13 @@ function buildApprovalPanel(message, options, onChange, details) {
5856
6029
  row.appendChild(button);
5857
6030
  labelled.push({ button, option });
5858
6031
  }
5859
- panel.appendChild(row);
6032
+ body.appendChild(row);
5860
6033
  const instruction = el("textarea", "aparte-approval-instruction");
5861
6034
  instruction.rows = 1;
5862
6035
  instruction.placeholder = cfg.t("approvalInstructionPlaceholder");
5863
6036
  instruction.setAttribute("aria-label", cfg.t("approvalInstructionPlaceholder"));
5864
6037
  instruction.addEventListener("input", onChange);
5865
- panel.appendChild(instruction);
6038
+ body.appendChild(instruction);
5866
6039
  const typed = () => instruction.value.trim();
5867
6040
  return {
5868
6041
  el: panel,
@@ -5891,6 +6064,8 @@ function requestUserInput(request) {
5891
6064
  return resolveConfig(request.target ?? null).requestUserInput(request);
5892
6065
  }
5893
6066
  const COMPACTION_PREAMBLE = "The earlier part of this conversation was compacted. This is a summary of what came before; continue from it.";
6067
+ const NOT_RUN_AFTER_A_REFUSAL = "Not run: an earlier call of the same step was refused.";
6068
+ const approvalKey = (call) => JSON.stringify([call.id, call.name, call.input]);
5894
6069
  const warnedUninlinedFiles = /* @__PURE__ */ new Set();
5895
6070
  const CHAT_SELECTOR = "aparte-chat, aparte-chat-viewport, [data-aparte-chat]";
5896
6071
  function remoteProvider(id) {
@@ -5917,15 +6092,13 @@ class AparteClient {
5917
6092
  /**
5918
6093
  * Does this window event belong to this client?
5919
6094
  *
5920
- * One rule for all four handlers, because it used to be near-copies and
5921
- * one omission: `aparte-compact` (answered here until 0.16.0) had no guard at all, so in the two-client
5922
- * layout the JSDoc documents, a single compact event made BOTH clients run —
5923
- * two paid summarisation calls against whichever chat the DOM scan found
5924
- * first, and a global reset that wiped the other conversation.
6095
+ * One rule for all the window-event handlers, so none of them can lack the guard:
6096
+ * in a two-client layout an unguarded event would make BOTH clients run — two paid
6097
+ * calls against whichever chat the DOM scan found first.
5925
6098
  *
5926
- * A scoped client also answers only events ADDRESSED to it. The old guard let
5927
- * an untargeted event through to every scoped client, which turned one
5928
- * broadcast into an action on every chat on the page.
6099
+ * A scoped client also answers only events ADDRESSED to it; an untargeted event is
6100
+ * not let through to every scoped client, or one broadcast would act on every chat
6101
+ * on the page.
5929
6102
  */
5930
6103
  _isForThisInstance(e) {
5931
6104
  if (!e) return true;
@@ -5943,6 +6116,11 @@ class AparteClient {
5943
6116
  options;
5944
6117
  /** Config read by this client — an instance config, or the global default. */
5945
6118
  _config;
6119
+ /**
6120
+ * A `keyResolver` becomes a key source on the client's config at once, before `start()`,
6121
+ * so everything that needs a key can use it — the model selector's list included.
6122
+ * `autoRegister` (on by default) installs core's built-in renderers on that config.
6123
+ */
5946
6124
  constructor(options = {}) {
5947
6125
  this.options = {
5948
6126
  autoRegister: true,
@@ -5960,17 +6138,15 @@ class AparteClient {
5960
6138
  /**
5961
6139
  * What every fresh user action does before it starts a turn.
5962
6140
  *
5963
- * Clearing the abort flag has to happen at the START of the action rather than inside
6141
+ * Clearing the abort flag happens at the START of the action rather than inside
5964
6142
  * `_streamTurn`, which runs after the auth and file-reading awaits — a reset there
5965
- * erased an abort that arrived while the user was still waiting for a large attachment
5966
- * to be read.
6143
+ * would erase an abort that arrived while the user was still waiting for a large
6144
+ * attachment to be read.
5967
6145
  *
5968
6146
  * The superseded turn's TOOL handlers are not cancelled here, and do not need a
5969
6147
  * registry of their own: `_streamTurn` aborts the previous `_streamController`, and
5970
6148
  * the engine links every tool handler's controller to that run signal
5971
- * (`invokeToolHandler`), so cutting the stream cuts its tools. A `Set<AbortController>`
5972
- * used to sit here for that job and was never added to — three lines of loop over an
5973
- * empty set, plus a paragraph describing a cancellation the method did not perform.
6149
+ * (`invokeToolHandler`), so cutting the stream cuts its tools.
5974
6150
  */
5975
6151
  _beginUserTurn() {
5976
6152
  this._isAborted = false;
@@ -5979,10 +6155,9 @@ class AparteClient {
5979
6155
  * Put this client's `keyResolver` on its config, and hold the teardown so
5980
6156
  * `stop()` can take it back off.
5981
6157
  *
5982
- * The registration used to be fire-and-forget, so it outlived the client that
5983
- * made it: a stopped client kept signing model-list requests, and a remount —
5984
- * the way the wrappers document swapping options — added a source behind the
5985
- * one it meant to replace instead of replacing it. Idempotent, so the usual
6158
+ * The teardown means a stopped client stops signing model-list requests, and a
6159
+ * remount — the way the wrappers document swapping options — replaces the source
6160
+ * instead of adding one behind it. Idempotent, so the usual
5986
6161
  * `new AparteClient({…}).start()` registers once.
5987
6162
  */
5988
6163
  _registerKeyResolver() {
@@ -6004,7 +6179,9 @@ class AparteClient {
6004
6179
  };
6005
6180
  }
6006
6181
  /**
6007
- * Start listening for aparte-send events on the window.
6182
+ * Answer the chats: listen on `window` for `aparte-send`, `aparte-retry`, `aparte-edit`,
6183
+ * `aparte-abort` and `aparte-conversation-loaded`. Nothing streams before this is
6184
+ * called. Starting a stopped client puts its `keyResolver` back on the config.
6008
6185
  */
6009
6186
  start() {
6010
6187
  this._registerKeyResolver();
@@ -6047,7 +6224,8 @@ class AparteClient {
6047
6224
  window.addEventListener("aparte-conversation-loaded", this._boundLoadedHandler);
6048
6225
  }
6049
6226
  /**
6050
- * Stop listening.
6227
+ * Stop answering: abort the reply in flight, take this client's `keyResolver` off the
6228
+ * config, and remove the listeners.
6051
6229
  */
6052
6230
  stop() {
6053
6231
  this.abort();
@@ -6081,41 +6259,24 @@ class AparteClient {
6081
6259
  this._isAborted = true;
6082
6260
  this._streamController?.abort();
6083
6261
  }
6084
- /*
6085
- * Two docblocks stood here, both describing `_awaitToolDecision` — the method
6086
- * this replaced. Deleting a method and leaving its documentation is worse than
6087
- * leaving the method: only the LAST comment before a declaration is its JSDoc,
6088
- * so those two were floating prose, and one of them explained a DOM-containment
6089
- * check as a live security property. It was the fix for a cross-chat hazard that
6090
- * only existed because the decision travelled as a bubbling `document` event.
6091
- * Routing through `requestUserInput` removed the hazard rather than guarding it,
6092
- * so the guard is gone too — and a reader who found that comment would have
6093
- * believed a containment check was still protecting them.
6094
- */
6095
6262
  /**
6096
6263
  * Ask the human at the COMPOSER, and report what they decided.
6097
6264
  *
6098
- * The default channel, replacing a `document` listener for `aparte-tool-decision`
6099
- * that existed only because the buttons lived in a segment renderer with no
6100
- * reference to this client. Routing through `requestUserInput` puts the decision
6265
+ * The default channel. Routing through `requestUserInput` puts the decision
6101
6266
  * where every other request for the user already goes, which is the whole point:
6102
6267
  * one queue, one panel slot, one teardown, and the same behaviour whether the
6103
6268
  * request came from a tool handler or from this gate.
6104
6269
  *
6105
6270
  * The tool NAME is the question and its ARGUMENTS are the body, because the
6106
6271
  * arguments are what is being approved — a decision surface that names the act and
6107
- * hides its subject asks for a signature on a blank page. This paragraph used to
6108
- * say the opposite ("the arguments stay in the transcript"), and the guide beside
6109
- * it had always promised what the panel did not do. The transcript row stays the
6110
- * anchor; it is no longer the only place the call can be read. The panel is capped
6272
+ * hides its subject asks for a signature on a blank page. The transcript row stays the
6273
+ * anchor, but it is not the only place the call can be read. The panel is capped
6111
6274
  * at 50vh and the block inside it scrolls, so a long input cannot push the options
6112
6275
  * off the surface they are asked on.
6113
6276
  *
6114
- * `aparte-tool-decision` is GONE with the buttons that dispatched it, and so is the
6115
- * `document` listener that answered it. It existed only because a segment renderer
6116
- * has no reference to this client; two seams that can disagree about who answers a
6117
- * decision are one seam too many, and the ratified elicitation rule already said so
6118
- * — "a typed presenter registered per config instance, never window events". A host
6277
+ * There is no `aparte-tool-decision` event and no `document` listener that answers
6278
+ * one: a decision is answered by a typed presenter registered per config instance,
6279
+ * never by a window event. A host
6119
6280
  * that wants to answer programmatically registers an `approvalResolver` (headless,
6120
6281
  * no DOM) or its own presenter, both strictly more capable than an id on an event.
6121
6282
  */
@@ -6130,13 +6291,14 @@ class AparteClient {
6130
6291
  */
6131
6292
  message: () => {
6132
6293
  const loc = this._config.getLocale();
6133
- return (loc.approvalAsk ?? "Run {tool}?").replace("{tool}", event.name);
6294
+ return (loc.approvalAsk ?? "Run {tool}?").replace("{tool}", () => event.name);
6134
6295
  },
6135
- // A plain string, not a function: the arguments are wire format — the JSON
6136
- // the model sent — and re-reading them on a language switch would produce
6137
- // exactly the same bytes. Same text the transcript row shows, from the same
6138
- // function, so the two surfaces cannot disagree about what is being run.
6139
- details: describeToolInput(event.input),
6296
+ // A plain string, not a function: the arguments are the model's — its JSON, or
6297
+ // the tool's own formatter's reading of it — and do not change with the
6298
+ // language. Same text the transcript row shows, from the same function and the
6299
+ // same config — the chat's, which the row renders with — so the two surfaces
6300
+ // cannot disagree about what is being run.
6301
+ details: describeToolInput(event.input, resolveConfig(target).getToolInputFormatter(event.name)),
6140
6302
  // Functions, not strings, same as the question: these follow a language
6141
6303
  // switch while the request is open, which a resolved string cannot.
6142
6304
  options: [
@@ -6232,6 +6394,8 @@ class AparteClient {
6232
6394
  let result = opts?.resume ? { usage: void 0, suspended: opts.resume } : await this._streamLoop(targetElement, messageId, provider, baseRequest, authConfig, streamController);
6233
6395
  const maxResumes = this.options.maxTurns ?? 10;
6234
6396
  let resumes = 0;
6397
+ let approvedAtLastPause = /* @__PURE__ */ new Set();
6398
+ const rowEvents = createStreamAdapter({ target: targetElement, config: this._config, messageId, ...chatId ? { targetId: chatId } : {} });
6235
6399
  while (result.suspended) {
6236
6400
  const stopped = this._isAborted || streamController.signal.aborted;
6237
6401
  if (!stopped && ++resumes > maxResumes) {
@@ -6241,7 +6405,9 @@ class AparteClient {
6241
6405
  );
6242
6406
  break;
6243
6407
  }
6244
- const outcomes = stopped ? void 0 : await this._answerDeferred(targetElement, result.suspended.pending, streamController.signal);
6408
+ const approvedNow = /* @__PURE__ */ new Set();
6409
+ const outcomes = stopped ? void 0 : await this._answerDeferred(targetElement, result.suspended.pending, streamController.signal, rowEvents, approvedAtLastPause, approvedNow);
6410
+ approvedAtLastPause = approvedNow;
6245
6411
  if (!outcomes) {
6246
6412
  this._closeOpenCalls(targetElement, messageId);
6247
6413
  if (this._isAborted || streamController.signal.aborted) {
@@ -6290,29 +6456,21 @@ class AparteClient {
6290
6456
  /**
6291
6457
  * The registered tools, unless the current model says it cannot call one.
6292
6458
  *
6293
- * Single source for the gate, so send / retry / edit cannot drift — that drift
6294
- * is what once shipped `tools` on the initial send while retry and edit omitted
6295
- * them.
6296
- *
6297
- * The gate used to be "the model DECLARES function_calling", defaulting to
6298
- * stripping — and that made the whole tool surface unreachable on the primary
6299
- * documented path. Three things had to line up for a tool to be sent, and one
6300
- * never did: the app sets a model id by hand (`setModelConfig`) or a selector
6301
- * sets it from a fetched list, and in both cases the model's declared
6302
- * capabilities are commonly unknown. `undefined` capabilities then read as "no
6303
- * function calling", so `tools: []` went on the wire while `getTools()` held
6304
- * the tool the app had explicitly registered. The model answered, correctly,
6305
- * that it had no such tool, and nothing anywhere said why.
6459
+ * Single source for the gate, so send / retry / edit cannot drift.
6306
6460
  *
6307
- * So the question is now "did this model say it cannot", not "did it say it
6308
- * can". Registering a tool is an explicit act by the app; silently dropping it
6461
+ * The question is "did this model say it cannot", not "did it say it can": the
6462
+ * app sets a model id by hand (`setModelConfig`) or a selector sets it from a
6463
+ * fetched list, and in both cases the model's declared capabilities are commonly
6464
+ * unknown. `undefined` capabilities read as "can", or `tools: []` would go on
6465
+ * the wire while `getTools()` held the tool the app had explicitly registered,
6466
+ * and the model would answer that it had no such tool. Registering a tool is an explicit act by the app; silently dropping it
6309
6467
  * because a `/models` listing is terse is the library second-guessing the
6310
6468
  * developer. A model that declares its capabilities and omits function calling
6311
6469
  * is still honoured — that is a statement, and it is respected.
6312
6470
  *
6313
6471
  * Failure modes, both ways round: over-sending means a model that cannot use
6314
6472
  * tools does not call one, or its own endpoint rejects the array with an error
6315
- * the developer can read. Under-sending was silent, total, and looked like a
6473
+ * the developer can read. Under-sending would be silent, total, and look like a
6316
6474
  * lying model.
6317
6475
  */
6318
6476
  _toolsForCurrentModel() {
@@ -6363,10 +6521,9 @@ class AparteClient {
6363
6521
  * The system messages a turn opens with: the app's own template, then the registered
6364
6522
  * tools' `systemPrompt`s.
6365
6523
  *
6366
- * A helper rather than three copies. The two lines it replaces were written out at each
6367
- * of the three turn entry points (send, retry, edit), which is exactly how the tool half
6368
- * would have been added to two of them and forgotten in the third — the same shape as
6369
- * the abort-flag asymmetry `_beginUserTurn` exists to prevent.
6524
+ * One helper for the three turn entry points (send, retry, edit), so the tool half
6525
+ * cannot be added to two of them and forgotten in the third — the same shape as the
6526
+ * abort-flag asymmetry `_beginUserTurn` exists to prevent.
6370
6527
  */
6371
6528
  _systemMessages() {
6372
6529
  const messages = [];
@@ -6449,11 +6606,11 @@ class AparteClient {
6449
6606
  * The trees a lookup must search, the one the gesture came from FIRST.
6450
6607
  *
6451
6608
  * `document.getElementById` and `document.querySelectorAll` cannot see into a shadow
6452
- * root, so a chat mounted in one — the arrangement the theming guide endorses — was
6453
- * invisible to both: alone, the send was dropped with a warning; on a page that also
6454
- * holds a chat in the light DOM, the scan answered with the WRONG chat, so the
6455
- * person's message AND the model's reply landed in a transcript they never typed
6456
- * into. Nearest tree first is what keeps it from swapping the other way round.
6609
+ * root, so a chat mounted in one — the arrangement the theming guide endorses — is
6610
+ * invisible to both: alone, the send would be dropped with a warning; on a page that
6611
+ * also holds a chat in the light DOM, the scan would answer with the WRONG chat, and
6612
+ * the person's message AND the model's reply would land in a transcript they never
6613
+ * typed into. Nearest tree first keeps it from swapping the other way round.
6457
6614
  */
6458
6615
  _rootsFor(event) {
6459
6616
  const roots = [];
@@ -6551,8 +6708,8 @@ class AparteClient {
6551
6708
  * The SLICE is each caller's own business — retry cuts before the reply it is
6552
6709
  * regenerating, edit cuts after the message being reworded, send cuts at the last
6553
6710
  * answered turn. What a message CONTRIBUTES to the wire is not: send, retry and
6554
- * edit must not disagree about it, and they did. This one had no filter at all,
6555
- * so a failed turn and a turn stopped before its first token both went out as
6711
+ * edit must not disagree about it. A failed turn and a turn stopped before its
6712
+ * first token are filtered out rather than sent as
6556
6713
  * `{ role: 'assistant', content: '' }` — a claim the assistant said nothing, which
6557
6714
  * some providers reject outright and the rest read as an empty reply worth
6558
6715
  * imitating.
@@ -6643,7 +6800,7 @@ class AparteClient {
6643
6800
  if (!this._warnedNoModel) {
6644
6801
  this._warnedNoModel = true;
6645
6802
  console.warn(
6646
- "[AparteClient] Send dropped: no model is selected. Call aparteGlobalConfig.setModelConfig({ defaultProvider, defaultModel }), mount <aparte-model-selector auto-select>, or register one provider with a single model — that one is selected on its own."
6803
+ "[AparteClient] Send dropped: no model is selected. Call aparteGlobalConfig.setModelConfig({ defaultProvider, defaultModel }), mount <aparte-model-selector auto-select persist>, or register one provider with a single model — that one is selected on its own."
6647
6804
  );
6648
6805
  }
6649
6806
  this._handleLifecycleError(targetElement, messageId, new AparteError(
@@ -6710,12 +6867,12 @@ class AparteClient {
6710
6867
  * the question the history gates can ask is "is this still in flight?", never
6711
6868
  * "is this completed?".
6712
6869
  *
6713
- * The gates below used to ask the second one, and a host that SEEDS a transcript
6870
+ * The gates below ask the first, because a host that SEEDS a transcript
6714
6871
  * — restoring a saved conversation, hydrating a server-rendered one — has no
6715
- * reason to invent a status for turns that are already over. With none, the
6716
- * cutoff never advanced past 0 and the whole transcript was sliced away: the
6717
- * model got the new question with no context, and nothing in the UI said so,
6718
- * because the viewport still showed every message.
6872
+ * reason to invent a status for turns that are already over. Asking "is this
6873
+ * completed?" would leave the cutoff at 0 and slice away the whole transcript: the
6874
+ * model would get the new question with no context, and nothing in the UI would say
6875
+ * so, because the viewport still shows every message.
6719
6876
  */
6720
6877
  static _inFlight(m) {
6721
6878
  return m.status === "streaming" || m.status === "pending";
@@ -6746,23 +6903,9 @@ ${text}` : text;
6746
6903
  * What the model is told it said last turn — reconstructed from what was
6747
6904
  * RENDERED, not from the raw-text field.
6748
6905
  *
6749
- * The order used to be the other way round, and it silently corrupted the
6750
- * context on the most common opening in a coding chat: a reply starting with a
6751
- * code fence was sent back as three backticks and nothing else. Three
6752
- * correct-in-isolation decisions composed into it:
6753
- *
6754
- * 1. the parser withholds an ambiguous prefix (``` ``` ```, `` ` ``, `<`)
6755
- * waiting for the next delta, and creates no active segment while it waits;
6756
- * 2. so `_streamLoop` sees zero segments, concludes the parser produced
6757
- * nothing, and appends the raw delta straight to `message.content`;
6758
- * 3. and this method preferred `content`.
6759
- *
6760
- * The bubble hides its content element as soon as segments exist, so the UI was
6761
- * perfect and only the NEXT request showed it. No unit test, no example and
6762
- * no browser test could see it.
6763
- *
6764
- * `content` stays as the fallback, and that is not vestigial: a non-streaming
6765
- * transport writes the whole reply there and creates no segments at all.
6906
+ * The segments are what the reader saw, so they come first. `content` is the
6907
+ * fallback, and that is not vestigial: a non-streaming transport writes the whole
6908
+ * reply there and creates no segments at all.
6766
6909
  */
6767
6910
  _extractText(message) {
6768
6911
  const rendered = this._segmentsToText(message.segments);
@@ -6780,10 +6923,9 @@ ${text}` : text;
6780
6923
  * A type core does not know — a registered block grammar's (an artifact, a
6781
6924
  * citation, a file), a consumer's custom segment — follows one rule: its
6782
6925
  * `content` if it has one, else its `fallback`, else nothing. Dropping such a
6783
- * segment made a document the model had just produced invisible on the very next
6784
- * turn, so it could not be asked to change the thing it had built; there used to
6785
- * be an `artifact` branch here for exactly that, and the rule is what replaces it
6786
- * now that the artifact is a plugin's type like any other.
6926
+ * segment would make a document the model had just produced invisible on the very
6927
+ * next turn, so it could not be asked to change the thing it had built. An artifact
6928
+ * is a plugin's type like any other and follows this rule.
6787
6929
  *
6788
6930
  * `thinking` and `tool_call` stay out on purpose — the first is the model's own
6789
6931
  * scratchpad, and most APIs neither want it back nor bill for it kindly; the
@@ -7003,10 +7145,15 @@ ${content}
7003
7145
  * nobody answered — the turn was stopped, or the channel gave up — and there is no
7004
7146
  * resume. A channel that CRASHES fails the turn, as it does for a run in the page.
7005
7147
  */
7006
- async _answerDeferred(targetElement, pending, signal) {
7148
+ async _answerDeferred(targetElement, pending, signal, rowEvents, approvedBefore = /* @__PURE__ */ new Set(), approvedNow = /* @__PURE__ */ new Set()) {
7007
7149
  const outcomes = {};
7008
7150
  const resolver = this._approvalResolverFor(targetElement, true);
7009
- for (const p of pending) {
7151
+ const refuseTheRest = (after) => {
7152
+ for (const rest of pending.slice(after + 1)) {
7153
+ outcomes[rest.toolCallId] = { kind: "decision", approved: false, reason: NOT_RUN_AFTER_A_REFUSAL };
7154
+ }
7155
+ };
7156
+ for (const [index, p] of pending.entries()) {
7010
7157
  if (signal.aborted) break;
7011
7158
  const call = { id: p.toolCallId, name: p.name, input: p.input ?? {} };
7012
7159
  if (p.reason === "approval") {
@@ -7018,11 +7165,34 @@ ${content}
7018
7165
  }
7019
7166
  if (!decision || signal.aborted) return void 0;
7020
7167
  outcomes[p.toolCallId] = { kind: "decision", ...decision };
7168
+ if (decision.approved) approvedNow.add(approvalKey(call));
7169
+ else {
7170
+ refuseTheRest(index);
7171
+ break;
7172
+ }
7021
7173
  } else if (p.reason === "external") {
7174
+ let gate = null;
7175
+ const key = approvalKey(call);
7176
+ if (!approvedBefore.has(key) && !approvedNow.has(key)) {
7177
+ try {
7178
+ gate = await this._gateBrowserTool(call, signal, targetElement, rowEvents);
7179
+ } catch (error) {
7180
+ if (!signal.aborted && error?.name !== "AbortError") throw error;
7181
+ gate = void 0;
7182
+ }
7183
+ }
7184
+ if (gate === void 0 || signal.aborted) return void 0;
7185
+ if (gate && !gate.approved) {
7186
+ outcomes[p.toolCallId] = { kind: "decision", ...gate };
7187
+ refuseTheRest(index);
7188
+ break;
7189
+ }
7190
+ const payload = gate?.payload;
7191
+ const ran = payload && typeof payload === "object" && !Array.isArray(payload) ? { ...call, input: { ...call.input, ...payload } } : call;
7022
7192
  const handler = this._config.getToolHandler(p.name);
7023
7193
  try {
7024
7194
  if (!handler) throw new Error(`No browser handler is registered for "${p.name}".`);
7025
- const result = await handler(call, signal, { target: targetElement, config: this._config });
7195
+ const result = await handler(ran, signal, { target: targetElement, config: this._config });
7026
7196
  outcomes[p.toolCallId] = {
7027
7197
  kind: "result",
7028
7198
  content: result.content,
@@ -7036,6 +7206,21 @@ ${content}
7036
7206
  }
7037
7207
  return signal.aborted ? void 0 : outcomes;
7038
7208
  }
7209
+ /**
7210
+ * Whether a browser tool a paused run asked for may run, decided as a run in the page
7211
+ * decides it. With a host `approvalResolver`, the tool's own `needsApproval` says whether
7212
+ * to ask and the resolver answers; without one, the approval policy and `needsApproval`
7213
+ * rule (`ruleOnToolCall`) and the default channel asks. `null` when nothing gates the
7214
+ * call, the decision when something did, `undefined` when nobody answered.
7215
+ */
7216
+ async _gateBrowserTool(call, signal, targetElement, rowEvents) {
7217
+ const ruling = this.options.approvalResolver ? this._config.getTools().find((t) => t.name === call.name)?.needsApproval ? "ask" : "allow" : this._config.ruleOnToolCall(call).verdict;
7218
+ if (ruling === "allow") return null;
7219
+ if (ruling === "ask") rowEvents?.({ type: "tool-awaiting-approval", toolCallId: call.id, name: call.name, input: call.input });
7220
+ const decision = await this._approvalResolverFor(targetElement)(call, signal) ?? void 0;
7221
+ if (decision?.approved && ruling === "ask") targetElement.updateSegment?.(`tool-${call.id}`, { status: "pending" });
7222
+ return decision;
7223
+ }
7039
7224
  /**
7040
7225
  * The ticket is spent the moment the resume leaves: the server answers these calls
7041
7226
  * now, so a pause saved from here on — the conversation manager saves before it cuts a
@@ -7058,8 +7243,8 @@ ${content}
7058
7243
  * Close the reply's calls that can no longer finish — paused, queued, or running on a
7059
7244
  * server the page stopped listening to: each says it stopped, and forgets the pause.
7060
7245
  * A cut connection carries none of their last events, so nothing else would: the rows
7061
- * spun, and a queued call kept its ticket, which a reload then resumed against a
7062
- * server that had moved on. By the reply's id, not the chat's head — a retry during
7246
+ * would spin, and a queued call would keep its ticket, which a reload would then
7247
+ * resume against a server that had moved on. By the reply's id, not the chat's head — a retry during
7063
7248
  * the wait moves the head. A run in the page ends every call itself: nothing to do.
7064
7249
  */
7065
7250
  _closeOpenCalls(targetElement, messageId) {
@@ -7304,10 +7489,9 @@ class AparteMessageRepository {
7304
7489
  /**
7305
7490
  * Every message the tree holds — every branch, not just the active path.
7306
7491
  *
7307
- * `getMessages()` walks head → root, so a question about the whole conversation
7308
- * ("is anything still streaming?") answered from it could not see a turn that a
7309
- * retry had just taken off the path — and the viewport wrote that turn's remaining
7310
- * segments into the bubble which replaced it. No order is promised beyond
7492
+ * Unlike `getMessages()`, which walks head → root, this also sees a turn that a
7493
+ * retry has taken off the active path, so it answers questions about the whole
7494
+ * conversation ("is anything still streaming?"). No order is promised beyond
7311
7495
  * insertion: a caller that needs the topology reads `export()`.
7312
7496
  */
7313
7497
  getAllMessages() {
@@ -7376,9 +7560,7 @@ class AparteMessageRepository {
7376
7560
  * Remove all descendants of `messageId` (inclusive) and, when the head was among
7377
7561
  * them, set `head` to the parent of `messageId`. Used by edit/truncate flows.
7378
7562
  *
7379
- * A head in a branch this call did not touch stays where it is: it used to be
7380
- * relocated regardless, which took the active path out of the version the reader was
7381
- * on for a truncate somewhere else entirely.
7563
+ * A head in a branch this call did not touch stays where it is.
7382
7564
  *
7383
7565
  * @param messageId The first message to remove
7384
7566
  */
@@ -7432,9 +7614,8 @@ class AparteMessageRepository {
7432
7614
  /**
7433
7615
  * Import a previously-exported snapshot, rebuilding the full tree and
7434
7616
  * restoring the active head. Replaces any existing content (calls `clear()`
7435
- * first). Messages must be in topological order (parent before child) —
7436
- * this is guaranteed by `export()` since `_nodes` is an insertion-order
7437
- * Map and parents are always inserted before their children.
7617
+ * first). `export()` writes messages in topological order (parent before
7618
+ * child), which is the order this expects.
7438
7619
  */
7439
7620
  import(exported) {
7440
7621
  this.clear();
@@ -7462,11 +7643,8 @@ class AparteMessageRepository {
7462
7643
  * Compatible with the assistant-ui ExportedMessageRepository format.
7463
7644
  *
7464
7645
  * Walked from the root through each node's `children`, so a parent is always
7465
- * written before its children. It used to iterate `_nodes` in insertion order and
7466
- * call that topological — which holds until `_relink` re-parents an EXISTING node
7467
- * under one inserted after it (`addOrUpdateMessage` with a known id and a newer
7468
- * parent). The snapshot then listed the child first, and `import()` threw "parent
7469
- * not found" on the very tree this method had just produced.
7646
+ * written before its children, even after `addOrUpdateMessage` re-parented an
7647
+ * existing node under a newer one; `import()` relies on that order.
7470
7648
  */
7471
7649
  export() {
7472
7650
  const messages = [];
@@ -7690,18 +7868,20 @@ export {
7690
7868
  uuid as a8,
7691
7869
  eventOrigin as a9,
7692
7870
  activeElementIn as aa,
7693
- mergeSegmentUpdate as ab,
7694
- writeStreamedMarkdown as ac,
7695
- stampSegmentActivity as ad,
7696
- stampSegmentOnInsert as ae,
7697
- stampSegmentOnUpdate as af,
7698
- renumberSegments as ag,
7699
- openSegmentIds as ah,
7700
- isTerminalStatus as ai,
7701
- adoptMessageSegments as aj,
7702
- APARTE_DERIVED_SEGMENTS as ak,
7703
- chatBoundaryOf as al,
7704
- rootOf as am,
7871
+ gateGlide as ab,
7872
+ renderingVersionOf as ac,
7873
+ mergeSegmentUpdate as ad,
7874
+ writeStreamedMarkdown as ae,
7875
+ stampSegmentActivity as af,
7876
+ stampSegmentOnInsert as ag,
7877
+ stampSegmentOnUpdate as ah,
7878
+ renumberSegments as ai,
7879
+ openSegmentIds as aj,
7880
+ isTerminalStatus as ak,
7881
+ adoptMessageSegments as al,
7882
+ APARTE_DERIVED_SEGMENTS as am,
7883
+ chatBoundaryOf as an,
7884
+ rootOf as ao,
7705
7885
  APARTE_DEFAULT_BUBBLE_ACTIONS as b,
7706
7886
  APARTE_DEFAULT_HOST_HANDLERS as c,
7707
7887
  APARTE_DEFAULT_ICON_FALLBACKS as d,
@@ -7728,4 +7908,4 @@ export {
7728
7908
  collectRendererStyles as y,
7729
7909
  contentToText as z
7730
7910
  };
7731
- //# sourceMappingURL=dialog-triggers-CEazY76J.js.map
7911
+ //# sourceMappingURL=dialog-triggers-3Rk7PuZa.js.map