@aparte/core 0.19.3 → 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 (139) hide show
  1. package/dist/client/aparte-client.d.ts +66 -95
  2. package/dist/client/aparte-client.d.ts.map +1 -1
  3. package/dist/client/stream-adapter.d.ts +7 -13
  4. package/dist/client/stream-adapter.d.ts.map +1 -1
  5. package/dist/client/wire-history.d.ts +4 -5
  6. package/dist/client/wire-history.d.ts.map +1 -1
  7. package/dist/components/bubble/aparte-chat-bubble.d.ts +88 -57
  8. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  9. package/dist/components/chat/aparte-chat.d.ts +15 -13
  10. package/dist/components/chat/aparte-chat.d.ts.map +1 -1
  11. package/dist/components/chat/glide-gate.d.ts +13 -0
  12. package/dist/components/chat/glide-gate.d.ts.map +1 -0
  13. package/dist/components/composer/aparte-composer-action.d.ts +2 -2
  14. package/dist/components/composer/aparte-composer-add-attachment.d.ts +1 -1
  15. package/dist/components/composer/aparte-composer-input.d.ts +1 -5
  16. package/dist/components/composer/aparte-composer-input.d.ts.map +1 -1
  17. package/dist/components/composer/aparte-composer-send.d.ts +8 -10
  18. package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
  19. package/dist/components/composer/aparte-composer.d.ts +48 -80
  20. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  21. package/dist/components/context/aparte-context.d.ts +9 -4
  22. package/dist/components/context/aparte-context.d.ts.map +1 -1
  23. package/dist/components/conversation-list/aparte-conversation-list.d.ts +23 -13
  24. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  25. package/dist/components/elicitation/aparte-elicitation.d.ts +15 -22
  26. package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
  27. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts +10 -11
  28. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -1
  29. package/dist/components/sidebar/aparte-sidebar.d.ts +16 -3
  30. package/dist/components/sidebar/aparte-sidebar.d.ts.map +1 -1
  31. package/dist/components/split/aparte-split.d.ts +12 -3
  32. package/dist/components/split/aparte-split.d.ts.map +1 -1
  33. package/dist/components/status/aparte-chat-status.d.ts.map +1 -1
  34. package/dist/components/suggestions/aparte-suggestions.d.ts +6 -9
  35. package/dist/components/suggestions/aparte-suggestions.d.ts.map +1 -1
  36. package/dist/components/viewport/aparte-chat-viewport.d.ts +70 -70
  37. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  38. package/dist/config/aparte-config.d.ts +87 -66
  39. package/dist/config/aparte-config.d.ts.map +1 -1
  40. package/dist/config/icon-provider.d.ts +19 -2
  41. package/dist/config/icon-provider.d.ts.map +1 -1
  42. package/dist/config/index.d.ts +1 -1
  43. package/dist/config/index.d.ts.map +1 -1
  44. package/dist/config/locale.d.ts +57 -89
  45. package/dist/config/locale.d.ts.map +1 -1
  46. package/dist/config/sanitize.d.ts.map +1 -1
  47. package/dist/conversations/conversation-controller.d.ts +18 -3
  48. package/dist/conversations/conversation-controller.d.ts.map +1 -1
  49. package/dist/conversations/conversation-manager.d.ts +3 -3
  50. package/dist/conversations/conversation-manager.d.ts.map +1 -1
  51. package/dist/custom-elements.json +13054 -12334
  52. package/dist/{dialog-triggers-ChnPFG9M.js → dialog-triggers-3Rk7PuZa.js} +444 -287
  53. package/dist/dialog-triggers-3Rk7PuZa.js.map +1 -0
  54. package/dist/elicitation/approval-panel.d.ts.map +1 -1
  55. package/dist/elicitation/index.d.ts +6 -1
  56. package/dist/elicitation/index.d.ts.map +1 -1
  57. package/dist/elicitation/panel.d.ts +11 -21
  58. package/dist/elicitation/panel.d.ts.map +1 -1
  59. package/dist/elicitation/types.d.ts +19 -27
  60. package/dist/elicitation/types.d.ts.map +1 -1
  61. package/dist/generated/element-attributes.d.ts +11 -5
  62. package/dist/generated/element-attributes.d.ts.map +1 -1
  63. package/dist/host/aparte-chat-host.d.ts +17 -32
  64. package/dist/host/aparte-chat-host.d.ts.map +1 -1
  65. package/dist/icons/extended.d.ts +1 -2
  66. package/dist/icons/extended.d.ts.map +1 -1
  67. package/dist/icons.d.ts +6 -8
  68. package/dist/icons.d.ts.map +1 -1
  69. package/dist/icons.js.map +1 -1
  70. package/dist/index.css +1 -1
  71. package/dist/index.d.ts +12 -9
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +682 -327
  74. package/dist/index.js.map +1 -1
  75. package/dist/index.node.d.ts +6 -3
  76. package/dist/index.node.d.ts.map +1 -1
  77. package/dist/index.node.js +2 -2
  78. package/dist/index.node.js.map +1 -1
  79. package/dist/interop/dialog-triggers.d.ts +2 -2
  80. package/dist/interop/element-props.d.ts +16 -27
  81. package/dist/interop/element-props.d.ts.map +1 -1
  82. package/dist/primitives/icon/aparte-icon.d.ts.map +1 -1
  83. package/dist/primitives/select/aparte-option.d.ts +3 -2
  84. package/dist/primitives/select/aparte-option.d.ts.map +1 -1
  85. package/dist/primitives/select/aparte-select.d.ts +24 -21
  86. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  87. package/dist/renderers/markdown-stream.d.ts.map +1 -1
  88. package/dist/renderers/segment-renderers.d.ts +11 -17
  89. package/dist/renderers/segment-renderers.d.ts.map +1 -1
  90. package/dist/renderers/segments/code.d.ts.map +1 -1
  91. package/dist/renderers/segments/error.d.ts.map +1 -1
  92. package/dist/renderers/segments/text.d.ts.map +1 -1
  93. package/dist/renderers/segments/tool-call.d.ts.map +1 -1
  94. package/dist/runtime/message-repository.d.ts +8 -15
  95. package/dist/runtime/message-repository.d.ts.map +1 -1
  96. package/dist/transport/backend-handler.d.ts +4 -6
  97. package/dist/transport/backend-handler.d.ts.map +1 -1
  98. package/dist/transport/backend-transport.d.ts +14 -0
  99. package/dist/transport/backend-transport.d.ts.map +1 -1
  100. package/dist/transport/types.d.ts +7 -10
  101. package/dist/transport/types.d.ts.map +1 -1
  102. package/dist/types/chat.d.ts +10 -18
  103. package/dist/types/chat.d.ts.map +1 -1
  104. package/dist/types/element-attributes.d.ts +6 -12
  105. package/dist/types/element-attributes.d.ts.map +1 -1
  106. package/dist/types/element-map.d.ts +6 -19
  107. package/dist/types/element-map.d.ts.map +1 -1
  108. package/dist/types/event-map.d.ts +11 -17
  109. package/dist/types/event-map.d.ts.map +1 -1
  110. package/dist/types/events.d.ts +5 -14
  111. package/dist/types/events.d.ts.map +1 -1
  112. package/dist/types/index.d.ts +2 -2
  113. package/dist/types/index.d.ts.map +1 -1
  114. package/dist/types/model-provider.d.ts +7 -9
  115. package/dist/types/model-provider.d.ts.map +1 -1
  116. package/dist/types/segments.d.ts +21 -13
  117. package/dist/types/segments.d.ts.map +1 -1
  118. package/dist/types/tools.d.ts +20 -22
  119. package/dist/types/tools.d.ts.map +1 -1
  120. package/dist/utils/copy-text.d.ts +1 -2
  121. package/dist/utils/copy-text.d.ts.map +1 -1
  122. package/dist/utils/css-escape.d.ts +1 -1
  123. package/dist/utils/escape.d.ts +4 -19
  124. package/dist/utils/escape.d.ts.map +1 -1
  125. package/dist/utils/files-to-attachments.d.ts +2 -2
  126. package/dist/utils/fold-text.d.ts +13 -0
  127. package/dist/utils/fold-text.d.ts.map +1 -0
  128. package/dist/utils/is-awaiting-reply.d.ts +2 -5
  129. package/dist/utils/is-awaiting-reply.d.ts.map +1 -1
  130. package/dist/utils/presence.d.ts +5 -5
  131. package/dist/utils/segments.d.ts +54 -78
  132. package/dist/utils/segments.d.ts.map +1 -1
  133. package/dist/utils/tool-input.d.ts +1 -1
  134. package/dist/utils/tool-input.d.ts.map +1 -1
  135. package/dist/utils/transcript.d.ts +2 -2
  136. package/dist/utils/uuid.d.ts +0 -11
  137. package/dist/utils/uuid.d.ts.map +1 -1
  138. package/package.json +29 -3
  139. package/dist/dialog-triggers-ChnPFG9M.js.map +0 -1
@@ -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.",
@@ -148,6 +153,7 @@ const APARTE_DEFAULT_LOCALE = {
148
153
  messageInfo: "Details",
149
154
  newChat: "New Chat",
150
155
  loadingConversations: "Loading conversations",
156
+ conversationSearchEmpty: "No conversation matches",
151
157
  conversationList: "Conversations",
152
158
  loadingConversation: "Loading the conversation",
153
159
  deleteConversation: "Delete",
@@ -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,8 +1107,7 @@ function createStreamAdapter(opts) {
1061
1107
  const seg = {
1062
1108
  id: `think-${uuid()}`,
1063
1109
  type: "thinking",
1064
- content: thinkingContent,
1065
- collapsed: true
1110
+ content: thinkingContent
1066
1111
  };
1067
1112
  thinkingId = seg.id;
1068
1113
  streaming.add(seg.id);
@@ -1074,7 +1119,9 @@ function createStreamAdapter(opts) {
1074
1119
  }
1075
1120
  case "text-delta": {
1076
1121
  if (thinkingId && !thinkingCollapsed) {
1077
- 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 });
1078
1125
  thinkingCollapsed = true;
1079
1126
  }
1080
1127
  const result = parser.parse(e.delta);
@@ -1813,6 +1860,26 @@ const APARTE_DEFAULT_HOST_HANDLERS = {
1813
1860
  };
1814
1861
  const NOOP = () => {
1815
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
+ }
1816
1883
  class AparteConfig {
1817
1884
  _markdownProvider;
1818
1885
  _streamingMarkdownProvider;
@@ -1844,15 +1911,13 @@ class AparteConfig {
1844
1911
  /**
1845
1912
  * What `refreshProviderModels()` last brought back, per provider.
1846
1913
  *
1847
- * Without it, `getCurrentModel()` read `provider.getModels()` only — the
1848
- * SYNCHRONOUS, hand-declared list, which every preset of
1849
- * `@aparte/provider-openai-compat` leaves empty because a compat endpoint's
1850
- * list is fetched at runtime. So the current model resolved to `undefined`
1851
- * for the documented primary path, and everything that reads a capability off
1852
- * it silently got nothing: `AparteClient._toolsForCurrentModel()` gates tools
1853
- * on `capabilities.includes('function_calling')`, so NO tool was ever sent to
1854
- * the model. A registered tool, an approval gate, `<aparte-elicitation>` — all
1855
- * 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.
1856
1921
  */
1857
1922
  _fetchedModels = /* @__PURE__ */ new Map();
1858
1923
  _modelConfig = {};
@@ -1887,9 +1952,8 @@ class AparteConfig {
1887
1952
  * silently, because the warning fires at most once per config.
1888
1953
  *
1889
1954
  * Each entry carries the element that registered it, so a request naming a `target`
1890
- * is routed to the presenter in the SAME chat. `AparteElicitationRequest.target` was
1891
- * already documented as "used to resolve WHICH instance presents"; the single slot
1892
- * 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").
1893
1957
  */
1894
1958
  _elicitationPresenters = [];
1895
1959
  /** Warn once per config, not once per request — a tool loop can ask repeatedly. */
@@ -1899,22 +1963,21 @@ class AparteConfig {
1899
1963
  *
1900
1964
  * One request reaches the presenter at a time — not because a presenter could not
1901
1965
  * cope, but because the COMPOSER cannot: it has one panel slot, and a second
1902
- * request used to clobber the first's DOM. The previous protection lived in the
1903
- * default presenter, which answered the second request `cancel` on the spot; that
1904
- * is a refusal invented for a question nobody was ever shown, the same class of
1905
- * lie as telling the model a stopped turn was refused. WAITING is the honest
1906
- * 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.
1907
1969
  *
1908
1970
  * Here rather than in the presenter because this is the only place that sees
1909
- * EVERY request whatever raised it — a tool handler, an app's own button, and
1910
- * (soon) the tool-approval gate. A custom presenter registered by a consumer gets
1911
- * 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.
1912
1974
  */
1913
1975
  _elicitationQueue = /* @__PURE__ */ new Map();
1914
1976
  // Tool Registry
1915
1977
  _tools = /* @__PURE__ */ new Map();
1916
1978
  _toolRenderers = /* @__PURE__ */ new Map();
1917
1979
  _toolSummaries = /* @__PURE__ */ new Map();
1980
+ _toolInputFormatters = /* @__PURE__ */ new Map();
1918
1981
  /** Tagged blocks the stream parser recognises in the prose — see {@link registerStreamBlock}. */
1919
1982
  _streamBlocks = /* @__PURE__ */ new Map();
1920
1983
  _segmentDefaults = /* @__PURE__ */ new Map();
@@ -2007,6 +2070,8 @@ class AparteConfig {
2007
2070
  */
2008
2071
  setMarkdownProvider(fn) {
2009
2072
  this._markdownProvider = fn;
2073
+ bumpRenderingVersion(this);
2074
+ this._notify();
2010
2075
  }
2011
2076
  /**
2012
2077
  * Set an incremental (streaming) Markdown renderer provider. Optional —
@@ -2021,6 +2086,8 @@ class AparteConfig {
2021
2086
  */
2022
2087
  setStreamingMarkdownProvider(fn) {
2023
2088
  this._streamingMarkdownProvider = fn;
2089
+ bumpRenderingVersion(this);
2090
+ this._notify();
2024
2091
  }
2025
2092
  /**
2026
2093
  * Create an incremental Markdown renderer bound to `target`, or `null` when
@@ -2036,6 +2103,8 @@ class AparteConfig {
2036
2103
  */
2037
2104
  setHighlightProvider(fn) {
2038
2105
  this._highlightProvider = fn;
2106
+ bumpRenderingVersion(this);
2107
+ this._notify();
2039
2108
  }
2040
2109
  /**
2041
2110
  * Whether a syntax-highlight provider is registered. Lets consumers (e.g.
@@ -2103,30 +2172,54 @@ class AparteConfig {
2103
2172
  return resolved.trim() || null;
2104
2173
  }
2105
2174
  /**
2106
- * 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.
2107
2178
  * @param provider Object implementing AparteIconProvider interface
2108
2179
  */
2109
2180
  setIconProvider(provider) {
2110
2181
  this._iconProvider = provider;
2111
2182
  this._notify();
2112
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
+ }
2113
2205
  /**
2114
2206
  * The icon set as a **complete** provider: every name resolves, falling back
2115
2207
  * to `APARTE_DEFAULT_ICON_FALLBACKS` for anything the registered provider doesn't
2116
2208
  * implement. Callers (bubble action bar, composer controls) can therefore
2117
2209
  * invoke `icons.copy()` unconditionally.
2118
2210
  *
2119
- * It used to hand back the registered provider verbatim, so a provider that
2120
- * implemented a subset — which `getIcon()` has always supported, and which
2121
- * the type now states — crashed every other icon with
2122
- * "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".
2123
2213
  */
2124
2214
  getIconProvider() {
2125
2215
  const registered = this._iconProvider;
2126
2216
  const complete = {};
2127
2217
  for (const name of Object.keys(APARTE_DEFAULT_ICON_FALLBACKS)) {
2128
- const fn = registered?.[name];
2129
- 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;
2130
2223
  }
2131
2224
  return complete;
2132
2225
  }
@@ -2258,14 +2351,10 @@ class AparteConfig {
2258
2351
  this._locale = { ...this._locale, ...translations };
2259
2352
  this._notify();
2260
2353
  }
2261
- /**
2262
- * Get icon HTML string by name
2263
- * Falls back to textual representation if no provider is set
2264
- */
2265
2354
  getIcon(name) {
2266
- const icon = this._iconProvider?.[name];
2355
+ const icon = drawingOf(this._iconProvider, name);
2267
2356
  if (icon) return icon();
2268
- return APARTE_DEFAULT_ICON_FALLBACKS[name];
2357
+ return Object.prototype.hasOwnProperty.call(APARTE_DEFAULT_ICON_FALLBACKS, name) ? APARTE_DEFAULT_ICON_FALLBACKS[name] : void 0;
2269
2358
  }
2270
2359
  /**
2271
2360
  * Set a custom Key provider (e.g., AparteVault override)
@@ -2412,11 +2501,14 @@ class AparteConfig {
2412
2501
  /**
2413
2502
  * Register a model preference provider for agnostic persistence.
2414
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.
2415
2506
  * @example
2416
2507
  * aparteGlobalConfig.setModelPreferenceProvider({
2417
- * save: (p, m) => localStorage.setItem('model', JSON.stringify({p, m})),
2418
- * 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'),
2419
2510
  * });
2511
+ * aparteGlobalConfig.restoreModelPreference();
2420
2512
  */
2421
2513
  setModelPreferenceProvider(provider) {
2422
2514
  this._modelPreferenceProvider = provider;
@@ -2463,7 +2555,7 @@ class AparteConfig {
2463
2555
  return { verdict: tool?.needsApproval ? "ask" : "allow" };
2464
2556
  }
2465
2557
  /**
2466
- * Restore previously saved model preference via the registered provider.
2558
+ * Restore the saved model preference via the registered provider.
2467
2559
  * Should be called once at app startup, before any component mounts.
2468
2560
  * No-op if no provider is registered or nothing was saved. The saved selection is
2469
2561
  * applied without being saved again.
@@ -2527,25 +2619,17 @@ class AparteConfig {
2527
2619
  /**
2528
2620
  * Elicitation policy: whether a choice offers a free-text "Other…" escape.
2529
2621
  *
2530
- * This is the HOST's decision, not the model's. `ask_user` used to carry an
2531
- * `allow_other` field in the schema it hands the model, which meant the model
2532
- * decided your UX — and a small model fills a field it does not understand: one
2533
- * sent two questions with `allow_other: true` and no options at all, so the
2534
- * panel rendered a radio list whose only entry was "Other…", twice.
2535
- *
2536
- * Every serious implementation of this pattern makes the escape hatch a property
2537
- * of the surface rather than of the request. Default `true`, which is what the
2538
- * panel did before, so nothing a user sees changes — only who gets to say so.
2539
- * A direct `requestUserInput` caller can still set `allowOther` per field; that
2540
- * 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.
2541
2627
  *
2542
2628
  * `layout` decides how a form of SEVERAL questions is presented.
2543
2629
  * `'stepped'` (default) asks them one at a time with a chip per question;
2544
- * `'stacked'` puts them all in the panel at once. Stacked was the only shape,
2545
- * inherited unexamined from MCP elicitation — which describes a FORM for
2546
- * collecting structured data, not two different questions asked mid-conversation.
2547
- * No product asks a person two questions by stacking them in one box; it is kept
2548
- * 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.
2549
2633
  *
2550
2634
  * `answerOnClick` decides how a question asked ON ITS OWN with a single choice — an
2551
2635
  * `enum` without `multiple` or a `default`, a `boolean` without a `default` — is
@@ -2745,8 +2829,8 @@ class AparteConfig {
2745
2829
  * is a string, so a consumer's own segment type is covered by the same call.
2746
2830
  *
2747
2831
  * The problem it solves: a consumer streaming a reply does not CONSTRUCT its
2748
- * segments — the parser does — so there was no hook at all for "reasoning blocks
2749
- * 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.
2750
2834
  *
2751
2835
  * Applied where identity is stamped (`utils/segments.ts`), so it covers every
2752
2836
  * path a segment can arrive by — the parser, `addSegment`, and the segments
@@ -2787,8 +2871,8 @@ class AparteConfig {
2787
2871
  * Say what a call targets, in the default row, after the tool's name.
2788
2872
  *
2789
2873
  * The default row names the tool and keeps its arguments behind a disclosure, so a
2790
- * reader saw that a file was read, not which one. Replacing the row with
2791
- * `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,
2792
2876
  * the spinner, the disclosure and the relabel contract. This keeps all of them and
2793
2877
  * adds the line. A tool with its own renderer draws its own row, so its summary is
2794
2878
  * never read.
@@ -2814,6 +2898,27 @@ class AparteConfig {
2814
2898
  getToolSummary(toolName) {
2815
2899
  return this._toolSummaries.get(toolName);
2816
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
+ }
2817
2922
  /**
2818
2923
  * Teach the stream parser a tagged block: `<tag attr="…">…</tag>` in the model's
2819
2924
  * prose becomes the segment `toSegment` builds, streamed delta by delta.
@@ -2908,7 +3013,7 @@ class AparteConfig {
2908
3013
  * the same chat is the right one — found by comparing chat boundaries rather than by
2909
3014
  * `contains`, because the presenter is a sibling of the transcript, not an ancestor
2910
3015
  * of it. With no target, no match, or no boundary to compare, the top of the stack
2911
- * is the answer, which is exactly the old single-slot behaviour.
3016
+ * is the answer.
2912
3017
  */
2913
3018
  _presenterFor(target) {
2914
3019
  const stack = this._elicitationPresenters;
@@ -2959,11 +3064,10 @@ class AparteConfig {
2959
3064
  *
2960
3065
  * The wait exists so a second question cannot take the panel away from the one on
2961
3066
  * screen — a property of ONE panel, and `_present` routes by target, so a chat is
2962
- * the right unit. It used to be one queue for the whole config: two chats on the
2963
- * global config shared it, and an approval waiting for an answer in the first (which
2964
- * is all an approval does) held the second's question behind it for ever, with
2965
- * nothing on screen to answer. Requests that name no chat keep sharing the `null`
2966
- * 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.
2967
3071
  */
2968
3072
  _elicitationQueueKey(request) {
2969
3073
  return chatBoundaryOf(request.target);
@@ -2981,7 +3085,14 @@ class AparteConfig {
2981
3085
  return Promise.reject(new AparteElicitationAbortError("no-presenter"));
2982
3086
  }
2983
3087
  if (request.signal?.aborted) return Promise.reject(new AparteElicitationAbortError("aborted"));
2984
- 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
+ });
2985
3096
  }
2986
3097
  /**
2987
3098
  * Put the configuration back to its defaults: the providers and the selected model,
@@ -3018,6 +3129,7 @@ class AparteConfig {
3018
3129
  this._tools.clear();
3019
3130
  this._toolRenderers.clear();
3020
3131
  this._toolSummaries.clear();
3132
+ this._toolInputFormatters.clear();
3021
3133
  this._streamBlocks.clear();
3022
3134
  this._modelConfig = {};
3023
3135
  this._requireModelSelection = false;
@@ -3053,46 +3165,6 @@ function getGlobalConfig() {
3053
3165
  return new AparteConfig();
3054
3166
  }
3055
3167
  const aparteGlobalConfig = getGlobalConfig();
3056
- const APARTE_HOST_ATTR = "data-aparte-host";
3057
- const CONFIG_SLOT = Symbol.for("aparte.instanceConfig");
3058
- function notifyConfigChanged(root, next, previous) {
3059
- if (next === previous) return;
3060
- const call = (el2) => {
3061
- const hook = el2.aparteConfigChanged;
3062
- if (typeof hook === "function") hook.call(el2, next, previous);
3063
- };
3064
- call(root);
3065
- for (const el2 of root.querySelectorAll("*")) call(el2);
3066
- }
3067
- function attachConfig(el2, config) {
3068
- const previous = el2[CONFIG_SLOT] ?? resolveConfig(el2.parentElement);
3069
- el2[CONFIG_SLOT] = config;
3070
- el2.setAttribute(APARTE_HOST_ATTR, "");
3071
- notifyConfigChanged(el2, config, previous);
3072
- }
3073
- function detachConfig(el2) {
3074
- const previous = el2[CONFIG_SLOT];
3075
- delete el2[CONFIG_SLOT];
3076
- el2.removeAttribute(APARTE_HOST_ATTR);
3077
- if (previous) notifyConfigChanged(el2, resolveConfig(el2.parentElement), previous);
3078
- }
3079
- function resolveConfig(el2) {
3080
- const host = el2?.closest?.(`[${APARTE_HOST_ATTR}]`);
3081
- return host?.[CONFIG_SLOT] ?? aparteGlobalConfig;
3082
- }
3083
- let _renderConfig = null;
3084
- function runWithConfig(config, fn) {
3085
- const prev = _renderConfig;
3086
- _renderConfig = config;
3087
- try {
3088
- return fn();
3089
- } finally {
3090
- _renderConfig = prev;
3091
- }
3092
- }
3093
- function contextConfig(el2) {
3094
- return _renderConfig ?? resolveConfig(el2);
3095
- }
3096
3168
  const APARTE_CONFIG_CHANGE = "aparte-config-change";
3097
3169
  function subscribeConfigChange(el2, handler) {
3098
3170
  const listener = (e) => {
@@ -3150,6 +3222,7 @@ function writeStreamedMarkdown(host, contentEl, content, streaming) {
3150
3222
  contentEl.innerHTML = contextConfig().renderMarkdown(content);
3151
3223
  }
3152
3224
  function highlightMarkdownFences(contentEl) {
3225
+ if (!contextConfig().hasHighlightProvider()) return;
3153
3226
  const blocks = contentEl.querySelectorAll("pre > code:not([data-aparte-highlighted])");
3154
3227
  for (const block of blocks) {
3155
3228
  const pre = block.parentElement;
@@ -3166,6 +3239,16 @@ function highlightMarkdownFences(contentEl) {
3166
3239
  const textRenderer = {
3167
3240
  type: "text",
3168
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
+ },
3169
3252
  update: (el2, segment) => {
3170
3253
  const contentEl = el2.querySelector(".aparte-segment-content");
3171
3254
  if (!contentEl) return;
@@ -3175,6 +3258,7 @@ const textRenderer = {
3175
3258
  segment.content,
3176
3259
  segment.isStreaming !== false
3177
3260
  );
3261
+ if (segment.isStreaming === false) highlightMarkdownFences(contentEl);
3178
3262
  },
3179
3263
  getStyles: () => ``
3180
3264
  };
@@ -3406,10 +3490,7 @@ const errorRenderer = {
3406
3490
  `;
3407
3491
  },
3408
3492
  /**
3409
- * The icon and the heading. The heading used to be the hardcoded literal `Error`
3410
- * while `locale.error` — a REQUIRED key, documented, and already translated to
3411
- * "Erreur" — was read by nothing at all. A translated string with no consumer and
3412
- * a literal with no translation, in the same card.
3493
+ * The icon and the heading (`locale.error`).
3413
3494
  *
3414
3495
  * Not the message: that is the model's or the transport's text, in whatever
3415
3496
  * language it arrived in. Relabelling it would be inventing content.
@@ -3422,8 +3503,15 @@ const errorRenderer = {
3422
3503
  },
3423
3504
  getStyles: () => ``
3424
3505
  };
3425
- function describeToolInput(input) {
3506
+ function describeToolInput(input, format) {
3426
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
+ }
3427
3515
  try {
3428
3516
  return JSON.stringify(input, null, 2);
3429
3517
  } catch {
@@ -3431,7 +3519,7 @@ function describeToolInput(input) {
3431
3519
  }
3432
3520
  }
3433
3521
  function describeInput(segment) {
3434
- return describeToolInput(segment.toolCall?.input);
3522
+ return describeToolInput(segment.toolCall?.input, contextConfig().getToolInputFormatter(segment.toolCall?.name ?? ""));
3435
3523
  }
3436
3524
  function stateBadge(segment) {
3437
3525
  const cfg = contextConfig();
@@ -3795,6 +3883,19 @@ function withoutDerivedTree(tree) {
3795
3883
  messages: tree.messages.map((entry) => ({ ...entry, message: withoutDerived(entry.message) }))
3796
3884
  };
3797
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
+ }
3798
3899
  class AparteConversationManager {
3799
3900
  _adapter;
3800
3901
  _conversations = [];
@@ -3985,9 +4086,9 @@ class AparteConversationManager {
3985
4086
  *
3986
4087
  * One append at a time per conversation: two sends that overlap (a suggestion and a
3987
4088
  * quick type, coalesced into one new conversation) both suspend before reading the
3988
- * record — for the messages, then for the title provider — so both used to read a
3989
- * conversation with no user message in it yet, and the second one's text became the
3990
- * 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.
3991
4092
  */
3992
4093
  async addMessage(convId, msg) {
3993
4094
  const previous = this._appending.get(convId);
@@ -4040,8 +4141,14 @@ class AparteConversationManager {
4040
4141
  let contentChanged = true;
4041
4142
  let treeChanged = tree !== void 0;
4042
4143
  try {
4043
- contentChanged = JSON.stringify(storedMessages) !== JSON.stringify(withoutDerivedSegments(conv.messages));
4044
- 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
+ }
4045
4152
  } catch {
4046
4153
  }
4047
4154
  let title;
@@ -4202,6 +4309,7 @@ function revokeAttachmentUrls(attachments) {
4202
4309
  if (attachment.url?.startsWith("blob:")) URL.revokeObjectURL(attachment.url);
4203
4310
  }
4204
4311
  }
4312
+ const MAX_COMPACTION_SAVE_WAITS = 6;
4205
4313
  class AparteConversationController {
4206
4314
  _binding;
4207
4315
  _options;
@@ -4217,6 +4325,7 @@ class AparteConversationController {
4217
4325
  _onMessageAborted = null;
4218
4326
  _onMessagePaused = null;
4219
4327
  _onSelectConversation = null;
4328
+ _onCompactDone = null;
4220
4329
  _unsubscribeManager = null;
4221
4330
  /** The manager the subscription above is on, so a replaced manager is re-subscribed. */
4222
4331
  _subscribedTo = null;
@@ -4279,9 +4388,9 @@ class AparteConversationController {
4279
4388
  /**
4280
4389
  * Degraded mode is legitimate — a host that never opted into the conversation
4281
4390
  * lifecycle has no manager and should not be nagged. Registering one on the
4282
- * WRONG config is not: the four wrapper hooks used to write it to the global
4283
- * singleton unconditionally, so `config` + persistence looked like it worked
4284
- * (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.
4285
4394
  *
4286
4395
  * Warned once per controller, and only in the case that is unambiguously a
4287
4396
  * mistake: no manager here, but one on the global.
@@ -4354,8 +4463,14 @@ class AparteConversationController {
4354
4463
  if (targetId && targetId !== this._binding.hostId) return;
4355
4464
  void this.setConversationId(evt.detail?.id ?? null);
4356
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
+ };
4357
4471
  if (typeof window !== "undefined") {
4358
4472
  window.addEventListener("aparte-conversation-select", this._onSelectConversation);
4473
+ window.addEventListener("aparte-compact-done", this._onCompactDone);
4359
4474
  }
4360
4475
  this._bound = true;
4361
4476
  this._manager();
@@ -4399,6 +4514,10 @@ class AparteConversationController {
4399
4514
  host.removeEventListener("aparte-message-paused", this._onMessagePaused);
4400
4515
  this._onMessagePaused = null;
4401
4516
  }
4517
+ if (this._onCompactDone && typeof window !== "undefined") {
4518
+ window.removeEventListener("aparte-compact-done", this._onCompactDone);
4519
+ this._onCompactDone = null;
4520
+ }
4402
4521
  if (this._onSelectConversation && typeof window !== "undefined") {
4403
4522
  window.removeEventListener("aparte-conversation-select", this._onSelectConversation);
4404
4523
  this._onSelectConversation = null;
@@ -4542,6 +4661,22 @@ class AparteConversationController {
4542
4661
  * re-enter streaming UI (caret, spinner, no action buttons) for content
4543
4662
  * that was partially written before a switch or component destruction.
4544
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
+ }
4545
4680
  _persistActive() {
4546
4681
  if (!this._activeId) return;
4547
4682
  const manager = this._manager();
@@ -4599,7 +4734,27 @@ class AparteConversationController {
4599
4734
  function cssEscape(value) {
4600
4735
  return value.replace(/["\\]/g, "\\$&").replace(/[\n\r\f]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `);
4601
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
+ }
4602
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"]);
4603
4758
  class AparteChatHost {
4604
4759
  binding;
4605
4760
  options;
@@ -4658,6 +4813,7 @@ class AparteChatHost {
4658
4813
  }
4659
4814
  this._installHostMethods(host);
4660
4815
  this._installLifecycleListeners(host);
4816
+ this._teardown.push(gateGlide(host));
4661
4817
  this._installViewportListeners();
4662
4818
  this._createController(host);
4663
4819
  return () => this.unbind();
@@ -4737,6 +4893,9 @@ class AparteChatHost {
4737
4893
  importTree: (tree) => {
4738
4894
  this._vp()?.importTree?.(tree);
4739
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),
4740
4899
  // One writer for the wait. A wrapper that hears `onLoadingChange` draws the
4741
4900
  // wait itself and writes the viewport's attribute from its own
4742
4901
  // `loading || hostLoading` — so the host writing it too meant that, with the
@@ -4899,11 +5058,8 @@ class AparteChatHost {
4899
5058
  * the same direct path `appendToken` uses;
4900
5059
  * 2. into the framework's message list, **coalesced to once per frame**.
4901
5060
  *
4902
- * It used to do only (2), synchronously: a full list rebuild plus
4903
- * `setMessages` + `onMessagesChange` for every chunk, i.e. one complete
4904
- * framework render per token. At 60 tok/s from a local model that is unusable,
4905
- * and nothing in the imperative API hinted that `appendToSegment` cost so much
4906
- * 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.
4907
5063
  *
4908
5064
  * Any structural change flushes the buffer first (see
4909
5065
  * {@link _flushStreamState}), so ordering is never observable.
@@ -4989,9 +5145,7 @@ class AparteChatHost {
4989
5145
  /**
4990
5146
  * Clear all messages + reset viewport state. The option travels to the viewport
4991
5147
  * untouched: a compaction passes `{ revokeAttachments: false }` because it re-appends
4992
- * the turns it keeps and revokes the dropped ones' object URLs itself — under a
4993
- * framework wrapper this bridge used to drop the argument, and every surviving
4994
- * attachment came back broken.
5148
+ * the turns it keeps and revokes the dropped ones' object URLs itself.
4995
5149
  */
4996
5150
  clearMessages(options) {
4997
5151
  this._beginConversationSwap();
@@ -5035,10 +5189,9 @@ class AparteChatHost {
5035
5189
  *
5036
5190
  * Each token reaches the bubble immediately and the framework's message list
5037
5191
  * is synced **once per frame** (same discipline as {@link appendToSegment}),
5038
- * with a guaranteed flush before completion and on abort. Before that sync
5039
- * existed the two disagreed: the DOM had the reply, the framework list still
5040
- * held `content: ''` — so any re-render from state wiped it, and a custom
5041
- * 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.
5042
5195
  */
5043
5196
  async streamTokens(messageId, tokens) {
5044
5197
  this.stopTokenStream();
@@ -5149,7 +5302,18 @@ class AparteChatHost {
5149
5302
  if (msg.segments !== void 0) updates.segments = msg.segments;
5150
5303
  if (msg.status !== void 0) updates.status = msg.status;
5151
5304
  if (msg.usage !== void 0) updates.usage = msg.usage;
5305
+ if (msg.model !== void 0) updates.model = msg.model;
5152
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
+ }
5153
5317
  }
5154
5318
  }
5155
5319
  }
@@ -5157,17 +5321,10 @@ class AparteChatHost {
5157
5321
  * Write the branch counts onto their bubbles — and RESCHEDULE for the ones that are
5158
5322
  * not on the page yet, instead of dropping them.
5159
5323
  *
5160
- * This used to `continue` past a missing bubble and then clear `_pendingSiblings`
5161
- * unconditionally, which lost the fork for good. That is the "1 / 1" defect: the
5162
- * bubble kept its default of one sibling, the picker hid itself, and the other version
5163
- * became unreachable for the life of the page.
5164
- *
5165
- * It needed a framework that renders LATE, which is every framework here. React's
5166
- * `afterRender` is `requestAnimationFrame(() => cb())` — a bet that the next paint
5167
- * lands after React's commit, and it does not always; the repo has lost that same bet
5168
- * before (`25f356b`, "a bet on rAF phase"). The fix is here rather than in the
5169
- * wrapper's rAF call, because ANY binding whose `afterRender` can precede its commit
5170
- * 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.
5171
5328
  *
5172
5329
  * Bounded, because a sibling can legitimately never arrive — a message that left the
5173
5330
  * path has no bubble and never will, and an unbounded retry would hold a callback for
@@ -5215,12 +5372,11 @@ class AparteChatHost {
5215
5372
  * The bubble the streaming writes below belong to: the one carrying that message's
5216
5373
  * id, and NOTHING when it is not on the page yet.
5217
5374
  *
5218
- * 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
5219
5376
  * whenever the DOM is a render behind the list — which is every framework here.
5220
- * `appendMessage` puts the new reply in React's state and React paints it on its own
5221
- * schedule, so a first chunk arriving in between went into the PREVIOUS reply's
5222
- * bubble; `syncBubbles` then wrote the same segment onto the real one, and the same
5223
- * `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.
5224
5380
  *
5225
5381
  * Skipping the paint is safe because it is only ever a paint: the message list is
5226
5382
  * written either way, and `syncBubbles` — called by the framework after each render,
@@ -5828,9 +5984,11 @@ let askSeq = 0;
5828
5984
  function buildApprovalPanel(message, options, onChange, details) {
5829
5985
  const cfg = contextConfig();
5830
5986
  const panel = el("div", "aparte-elic-panel aparte-approval-panel");
5987
+ const body = el("div", "aparte-elic-body");
5988
+ panel.appendChild(body);
5831
5989
  const askText = () => typeof message === "function" ? message() : message;
5832
5990
  const ask = askText() ? el("p", "aparte-elic-message", askText()) : null;
5833
- if (ask) panel.appendChild(ask);
5991
+ if (ask) body.appendChild(ask);
5834
5992
  let argsLabel = null;
5835
5993
  if (details) {
5836
5994
  argsLabel = el("div", "aparte-approval-args-label", cfg.t("approvalArgsLabel"));
@@ -5840,7 +5998,7 @@ function buildApprovalPanel(message, options, onChange, details) {
5840
5998
  pre.setAttribute("role", "group");
5841
5999
  pre.setAttribute("aria-labelledby", argsLabel.id);
5842
6000
  pre.tabIndex = 0;
5843
- panel.append(argsLabel, pre);
6001
+ body.append(argsLabel, pre);
5844
6002
  }
5845
6003
  const dismiss = el("div", "aparte-elic-dismiss");
5846
6004
  panel.appendChild(dismiss);
@@ -5871,13 +6029,13 @@ function buildApprovalPanel(message, options, onChange, details) {
5871
6029
  row.appendChild(button);
5872
6030
  labelled.push({ button, option });
5873
6031
  }
5874
- panel.appendChild(row);
6032
+ body.appendChild(row);
5875
6033
  const instruction = el("textarea", "aparte-approval-instruction");
5876
6034
  instruction.rows = 1;
5877
6035
  instruction.placeholder = cfg.t("approvalInstructionPlaceholder");
5878
6036
  instruction.setAttribute("aria-label", cfg.t("approvalInstructionPlaceholder"));
5879
6037
  instruction.addEventListener("input", onChange);
5880
- panel.appendChild(instruction);
6038
+ body.appendChild(instruction);
5881
6039
  const typed = () => instruction.value.trim();
5882
6040
  return {
5883
6041
  el: panel,
@@ -5906,6 +6064,8 @@ function requestUserInput(request) {
5906
6064
  return resolveConfig(request.target ?? null).requestUserInput(request);
5907
6065
  }
5908
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]);
5909
6069
  const warnedUninlinedFiles = /* @__PURE__ */ new Set();
5910
6070
  const CHAT_SELECTOR = "aparte-chat, aparte-chat-viewport, [data-aparte-chat]";
5911
6071
  function remoteProvider(id) {
@@ -5932,15 +6092,13 @@ class AparteClient {
5932
6092
  /**
5933
6093
  * Does this window event belong to this client?
5934
6094
  *
5935
- * One rule for all four handlers, because it used to be near-copies and
5936
- * one omission: `aparte-compact` (answered here until 0.16.0) had no guard at all, so in the two-client
5937
- * layout the JSDoc documents, a single compact event made BOTH clients run —
5938
- * two paid summarisation calls against whichever chat the DOM scan found
5939
- * 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.
5940
6098
  *
5941
- * A scoped client also answers only events ADDRESSED to it. The old guard let
5942
- * an untargeted event through to every scoped client, which turned one
5943
- * 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.
5944
6102
  */
5945
6103
  _isForThisInstance(e) {
5946
6104
  if (!e) return true;
@@ -5980,17 +6138,15 @@ class AparteClient {
5980
6138
  /**
5981
6139
  * What every fresh user action does before it starts a turn.
5982
6140
  *
5983
- * 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
5984
6142
  * `_streamTurn`, which runs after the auth and file-reading awaits — a reset there
5985
- * erased an abort that arrived while the user was still waiting for a large attachment
5986
- * to be read.
6143
+ * would erase an abort that arrived while the user was still waiting for a large
6144
+ * attachment to be read.
5987
6145
  *
5988
6146
  * The superseded turn's TOOL handlers are not cancelled here, and do not need a
5989
6147
  * registry of their own: `_streamTurn` aborts the previous `_streamController`, and
5990
6148
  * the engine links every tool handler's controller to that run signal
5991
- * (`invokeToolHandler`), so cutting the stream cuts its tools. A `Set<AbortController>`
5992
- * used to sit here for that job and was never added to — three lines of loop over an
5993
- * empty set, plus a paragraph describing a cancellation the method did not perform.
6149
+ * (`invokeToolHandler`), so cutting the stream cuts its tools.
5994
6150
  */
5995
6151
  _beginUserTurn() {
5996
6152
  this._isAborted = false;
@@ -5999,10 +6155,9 @@ class AparteClient {
5999
6155
  * Put this client's `keyResolver` on its config, and hold the teardown so
6000
6156
  * `stop()` can take it back off.
6001
6157
  *
6002
- * The registration used to be fire-and-forget, so it outlived the client that
6003
- * made it: a stopped client kept signing model-list requests, and a remount —
6004
- * the way the wrappers document swapping options — added a source behind the
6005
- * 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
6006
6161
  * `new AparteClient({…}).start()` registers once.
6007
6162
  */
6008
6163
  _registerKeyResolver() {
@@ -6104,41 +6259,24 @@ class AparteClient {
6104
6259
  this._isAborted = true;
6105
6260
  this._streamController?.abort();
6106
6261
  }
6107
- /*
6108
- * Two docblocks stood here, both describing `_awaitToolDecision` — the method
6109
- * this replaced. Deleting a method and leaving its documentation is worse than
6110
- * leaving the method: only the LAST comment before a declaration is its JSDoc,
6111
- * so those two were floating prose, and one of them explained a DOM-containment
6112
- * check as a live security property. It was the fix for a cross-chat hazard that
6113
- * only existed because the decision travelled as a bubbling `document` event.
6114
- * Routing through `requestUserInput` removed the hazard rather than guarding it,
6115
- * so the guard is gone too — and a reader who found that comment would have
6116
- * believed a containment check was still protecting them.
6117
- */
6118
6262
  /**
6119
6263
  * Ask the human at the COMPOSER, and report what they decided.
6120
6264
  *
6121
- * The default channel, replacing a `document` listener for `aparte-tool-decision`
6122
- * that existed only because the buttons lived in a segment renderer with no
6123
- * reference to this client. Routing through `requestUserInput` puts the decision
6265
+ * The default channel. Routing through `requestUserInput` puts the decision
6124
6266
  * where every other request for the user already goes, which is the whole point:
6125
6267
  * one queue, one panel slot, one teardown, and the same behaviour whether the
6126
6268
  * request came from a tool handler or from this gate.
6127
6269
  *
6128
6270
  * The tool NAME is the question and its ARGUMENTS are the body, because the
6129
6271
  * arguments are what is being approved — a decision surface that names the act and
6130
- * hides its subject asks for a signature on a blank page. This paragraph used to
6131
- * say the opposite ("the arguments stay in the transcript"), and the guide beside
6132
- * it had always promised what the panel did not do. The transcript row stays the
6133
- * 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
6134
6274
  * at 50vh and the block inside it scrolls, so a long input cannot push the options
6135
6275
  * off the surface they are asked on.
6136
6276
  *
6137
- * `aparte-tool-decision` is GONE with the buttons that dispatched it, and so is the
6138
- * `document` listener that answered it. It existed only because a segment renderer
6139
- * has no reference to this client; two seams that can disagree about who answers a
6140
- * decision are one seam too many, and the ratified elicitation rule already said so
6141
- * — "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
6142
6280
  * that wants to answer programmatically registers an `approvalResolver` (headless,
6143
6281
  * no DOM) or its own presenter, both strictly more capable than an id on an event.
6144
6282
  */
@@ -6153,13 +6291,14 @@ class AparteClient {
6153
6291
  */
6154
6292
  message: () => {
6155
6293
  const loc = this._config.getLocale();
6156
- return (loc.approvalAsk ?? "Run {tool}?").replace("{tool}", event.name);
6294
+ return (loc.approvalAsk ?? "Run {tool}?").replace("{tool}", () => event.name);
6157
6295
  },
6158
- // A plain string, not a function: the arguments are wire format — the JSON
6159
- // the model sent — and re-reading them on a language switch would produce
6160
- // exactly the same bytes. Same text the transcript row shows, from the same
6161
- // function, so the two surfaces cannot disagree about what is being run.
6162
- 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)),
6163
6302
  // Functions, not strings, same as the question: these follow a language
6164
6303
  // switch while the request is open, which a resolved string cannot.
6165
6304
  options: [
@@ -6255,6 +6394,8 @@ class AparteClient {
6255
6394
  let result = opts?.resume ? { usage: void 0, suspended: opts.resume } : await this._streamLoop(targetElement, messageId, provider, baseRequest, authConfig, streamController);
6256
6395
  const maxResumes = this.options.maxTurns ?? 10;
6257
6396
  let resumes = 0;
6397
+ let approvedAtLastPause = /* @__PURE__ */ new Set();
6398
+ const rowEvents = createStreamAdapter({ target: targetElement, config: this._config, messageId, ...chatId ? { targetId: chatId } : {} });
6258
6399
  while (result.suspended) {
6259
6400
  const stopped = this._isAborted || streamController.signal.aborted;
6260
6401
  if (!stopped && ++resumes > maxResumes) {
@@ -6264,7 +6405,9 @@ class AparteClient {
6264
6405
  );
6265
6406
  break;
6266
6407
  }
6267
- 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;
6268
6411
  if (!outcomes) {
6269
6412
  this._closeOpenCalls(targetElement, messageId);
6270
6413
  if (this._isAborted || streamController.signal.aborted) {
@@ -6313,29 +6456,21 @@ class AparteClient {
6313
6456
  /**
6314
6457
  * The registered tools, unless the current model says it cannot call one.
6315
6458
  *
6316
- * Single source for the gate, so send / retry / edit cannot drift — that drift
6317
- * is what once shipped `tools` on the initial send while retry and edit omitted
6318
- * them.
6459
+ * Single source for the gate, so send / retry / edit cannot drift.
6319
6460
  *
6320
- * The gate used to be "the model DECLARES function_calling", defaulting to
6321
- * stripping — and that made the whole tool surface unreachable on the primary
6322
- * documented path. Three things had to line up for a tool to be sent, and one
6323
- * never did: the app sets a model id by hand (`setModelConfig`) or a selector
6324
- * sets it from a fetched list, and in both cases the model's declared
6325
- * capabilities are commonly unknown. `undefined` capabilities then read as "no
6326
- * function calling", so `tools: []` went on the wire while `getTools()` held
6327
- * the tool the app had explicitly registered. The model answered, correctly,
6328
- * that it had no such tool, and nothing anywhere said why.
6329
- *
6330
- * So the question is now "did this model say it cannot", not "did it say it
6331
- * 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
6332
6467
  * because a `/models` listing is terse is the library second-guessing the
6333
6468
  * developer. A model that declares its capabilities and omits function calling
6334
6469
  * is still honoured — that is a statement, and it is respected.
6335
6470
  *
6336
6471
  * Failure modes, both ways round: over-sending means a model that cannot use
6337
6472
  * tools does not call one, or its own endpoint rejects the array with an error
6338
- * 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
6339
6474
  * lying model.
6340
6475
  */
6341
6476
  _toolsForCurrentModel() {
@@ -6386,10 +6521,9 @@ class AparteClient {
6386
6521
  * The system messages a turn opens with: the app's own template, then the registered
6387
6522
  * tools' `systemPrompt`s.
6388
6523
  *
6389
- * A helper rather than three copies. The two lines it replaces were written out at each
6390
- * of the three turn entry points (send, retry, edit), which is exactly how the tool half
6391
- * would have been added to two of them and forgotten in the third — the same shape as
6392
- * 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.
6393
6527
  */
6394
6528
  _systemMessages() {
6395
6529
  const messages = [];
@@ -6472,11 +6606,11 @@ class AparteClient {
6472
6606
  * The trees a lookup must search, the one the gesture came from FIRST.
6473
6607
  *
6474
6608
  * `document.getElementById` and `document.querySelectorAll` cannot see into a shadow
6475
- * root, so a chat mounted in one — the arrangement the theming guide endorses — was
6476
- * invisible to both: alone, the send was dropped with a warning; on a page that also
6477
- * holds a chat in the light DOM, the scan answered with the WRONG chat, so the
6478
- * person's message AND the model's reply landed in a transcript they never typed
6479
- * 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.
6480
6614
  */
6481
6615
  _rootsFor(event) {
6482
6616
  const roots = [];
@@ -6574,8 +6708,8 @@ class AparteClient {
6574
6708
  * The SLICE is each caller's own business — retry cuts before the reply it is
6575
6709
  * regenerating, edit cuts after the message being reworded, send cuts at the last
6576
6710
  * answered turn. What a message CONTRIBUTES to the wire is not: send, retry and
6577
- * edit must not disagree about it, and they did. This one had no filter at all,
6578
- * 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
6579
6713
  * `{ role: 'assistant', content: '' }` — a claim the assistant said nothing, which
6580
6714
  * some providers reject outright and the rest read as an empty reply worth
6581
6715
  * imitating.
@@ -6666,7 +6800,7 @@ class AparteClient {
6666
6800
  if (!this._warnedNoModel) {
6667
6801
  this._warnedNoModel = true;
6668
6802
  console.warn(
6669
- "[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."
6670
6804
  );
6671
6805
  }
6672
6806
  this._handleLifecycleError(targetElement, messageId, new AparteError(
@@ -6733,12 +6867,12 @@ class AparteClient {
6733
6867
  * the question the history gates can ask is "is this still in flight?", never
6734
6868
  * "is this completed?".
6735
6869
  *
6736
- * 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
6737
6871
  * — restoring a saved conversation, hydrating a server-rendered one — has no
6738
- * reason to invent a status for turns that are already over. With none, the
6739
- * cutoff never advanced past 0 and the whole transcript was sliced away: the
6740
- * model got the new question with no context, and nothing in the UI said so,
6741
- * 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.
6742
6876
  */
6743
6877
  static _inFlight(m) {
6744
6878
  return m.status === "streaming" || m.status === "pending";
@@ -6769,23 +6903,9 @@ ${text}` : text;
6769
6903
  * What the model is told it said last turn — reconstructed from what was
6770
6904
  * RENDERED, not from the raw-text field.
6771
6905
  *
6772
- * The order used to be the other way round, and it silently corrupted the
6773
- * context on the most common opening in a coding chat: a reply starting with a
6774
- * code fence was sent back as three backticks and nothing else. Three
6775
- * correct-in-isolation decisions composed into it:
6776
- *
6777
- * 1. the parser withholds an ambiguous prefix (``` ``` ```, `` ` ``, `<`)
6778
- * waiting for the next delta, and creates no active segment while it waits;
6779
- * 2. so `_streamLoop` sees zero segments, concludes the parser produced
6780
- * nothing, and appends the raw delta straight to `message.content`;
6781
- * 3. and this method preferred `content`.
6782
- *
6783
- * The bubble hides its content element as soon as segments exist, so the UI was
6784
- * perfect and only the NEXT request showed it. No unit test, no example and
6785
- * no browser test could see it.
6786
- *
6787
- * `content` stays as the fallback, and that is not vestigial: a non-streaming
6788
- * 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.
6789
6909
  */
6790
6910
  _extractText(message) {
6791
6911
  const rendered = this._segmentsToText(message.segments);
@@ -6803,10 +6923,9 @@ ${text}` : text;
6803
6923
  * A type core does not know — a registered block grammar's (an artifact, a
6804
6924
  * citation, a file), a consumer's custom segment — follows one rule: its
6805
6925
  * `content` if it has one, else its `fallback`, else nothing. Dropping such a
6806
- * segment made a document the model had just produced invisible on the very next
6807
- * turn, so it could not be asked to change the thing it had built; there used to
6808
- * be an `artifact` branch here for exactly that, and the rule is what replaces it
6809
- * 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.
6810
6929
  *
6811
6930
  * `thinking` and `tool_call` stay out on purpose — the first is the model's own
6812
6931
  * scratchpad, and most APIs neither want it back nor bill for it kindly; the
@@ -7026,10 +7145,15 @@ ${content}
7026
7145
  * nobody answered — the turn was stopped, or the channel gave up — and there is no
7027
7146
  * resume. A channel that CRASHES fails the turn, as it does for a run in the page.
7028
7147
  */
7029
- async _answerDeferred(targetElement, pending, signal) {
7148
+ async _answerDeferred(targetElement, pending, signal, rowEvents, approvedBefore = /* @__PURE__ */ new Set(), approvedNow = /* @__PURE__ */ new Set()) {
7030
7149
  const outcomes = {};
7031
7150
  const resolver = this._approvalResolverFor(targetElement, true);
7032
- 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()) {
7033
7157
  if (signal.aborted) break;
7034
7158
  const call = { id: p.toolCallId, name: p.name, input: p.input ?? {} };
7035
7159
  if (p.reason === "approval") {
@@ -7041,11 +7165,34 @@ ${content}
7041
7165
  }
7042
7166
  if (!decision || signal.aborted) return void 0;
7043
7167
  outcomes[p.toolCallId] = { kind: "decision", ...decision };
7168
+ if (decision.approved) approvedNow.add(approvalKey(call));
7169
+ else {
7170
+ refuseTheRest(index);
7171
+ break;
7172
+ }
7044
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;
7045
7192
  const handler = this._config.getToolHandler(p.name);
7046
7193
  try {
7047
7194
  if (!handler) throw new Error(`No browser handler is registered for "${p.name}".`);
7048
- const result = await handler(call, signal, { target: targetElement, config: this._config });
7195
+ const result = await handler(ran, signal, { target: targetElement, config: this._config });
7049
7196
  outcomes[p.toolCallId] = {
7050
7197
  kind: "result",
7051
7198
  content: result.content,
@@ -7059,6 +7206,21 @@ ${content}
7059
7206
  }
7060
7207
  return signal.aborted ? void 0 : outcomes;
7061
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
+ }
7062
7224
  /**
7063
7225
  * The ticket is spent the moment the resume leaves: the server answers these calls
7064
7226
  * now, so a pause saved from here on — the conversation manager saves before it cuts a
@@ -7081,8 +7243,8 @@ ${content}
7081
7243
  * Close the reply's calls that can no longer finish — paused, queued, or running on a
7082
7244
  * server the page stopped listening to: each says it stopped, and forgets the pause.
7083
7245
  * A cut connection carries none of their last events, so nothing else would: the rows
7084
- * spun, and a queued call kept its ticket, which a reload then resumed against a
7085
- * 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
7086
7248
  * the wait moves the head. A run in the page ends every call itself: nothing to do.
7087
7249
  */
7088
7250
  _closeOpenCalls(targetElement, messageId) {
@@ -7327,10 +7489,9 @@ class AparteMessageRepository {
7327
7489
  /**
7328
7490
  * Every message the tree holds — every branch, not just the active path.
7329
7491
  *
7330
- * `getMessages()` walks head → root, so a question about the whole conversation
7331
- * ("is anything still streaming?") answered from it could not see a turn that a
7332
- * retry had just taken off the path — and the viewport wrote that turn's remaining
7333
- * 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
7334
7495
  * insertion: a caller that needs the topology reads `export()`.
7335
7496
  */
7336
7497
  getAllMessages() {
@@ -7399,9 +7560,7 @@ class AparteMessageRepository {
7399
7560
  * Remove all descendants of `messageId` (inclusive) and, when the head was among
7400
7561
  * them, set `head` to the parent of `messageId`. Used by edit/truncate flows.
7401
7562
  *
7402
- * A head in a branch this call did not touch stays where it is: it used to be
7403
- * relocated regardless, which took the active path out of the version the reader was
7404
- * on for a truncate somewhere else entirely.
7563
+ * A head in a branch this call did not touch stays where it is.
7405
7564
  *
7406
7565
  * @param messageId The first message to remove
7407
7566
  */
@@ -7455,9 +7614,8 @@ class AparteMessageRepository {
7455
7614
  /**
7456
7615
  * Import a previously-exported snapshot, rebuilding the full tree and
7457
7616
  * restoring the active head. Replaces any existing content (calls `clear()`
7458
- * first). Messages must be in topological order (parent before child) —
7459
- * this is guaranteed by `export()` since `_nodes` is an insertion-order
7460
- * 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.
7461
7619
  */
7462
7620
  import(exported) {
7463
7621
  this.clear();
@@ -7485,11 +7643,8 @@ class AparteMessageRepository {
7485
7643
  * Compatible with the assistant-ui ExportedMessageRepository format.
7486
7644
  *
7487
7645
  * Walked from the root through each node's `children`, so a parent is always
7488
- * written before its children. It used to iterate `_nodes` in insertion order and
7489
- * call that topological — which holds until `_relink` re-parents an EXISTING node
7490
- * under one inserted after it (`addOrUpdateMessage` with a known id and a newer
7491
- * parent). The snapshot then listed the child first, and `import()` threw "parent
7492
- * 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.
7493
7648
  */
7494
7649
  export() {
7495
7650
  const messages = [];
@@ -7713,18 +7868,20 @@ export {
7713
7868
  uuid as a8,
7714
7869
  eventOrigin as a9,
7715
7870
  activeElementIn as aa,
7716
- mergeSegmentUpdate as ab,
7717
- writeStreamedMarkdown as ac,
7718
- stampSegmentActivity as ad,
7719
- stampSegmentOnInsert as ae,
7720
- stampSegmentOnUpdate as af,
7721
- renumberSegments as ag,
7722
- openSegmentIds as ah,
7723
- isTerminalStatus as ai,
7724
- adoptMessageSegments as aj,
7725
- APARTE_DERIVED_SEGMENTS as ak,
7726
- chatBoundaryOf as al,
7727
- 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,
7728
7885
  APARTE_DEFAULT_BUBBLE_ACTIONS as b,
7729
7886
  APARTE_DEFAULT_HOST_HANDLERS as c,
7730
7887
  APARTE_DEFAULT_ICON_FALLBACKS as d,
@@ -7751,4 +7908,4 @@ export {
7751
7908
  collectRendererStyles as y,
7752
7909
  contentToText as z
7753
7910
  };
7754
- //# sourceMappingURL=dialog-triggers-ChnPFG9M.js.map
7911
+ //# sourceMappingURL=dialog-triggers-3Rk7PuZa.js.map