@aparte/core 0.7.1 → 0.9.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 (175) hide show
  1. package/README.md +20 -6
  2. package/dist/client/aparte-client.d.ts +113 -47
  3. package/dist/client/aparte-client.d.ts.map +1 -1
  4. package/dist/client/lifecycle-events.d.ts +55 -0
  5. package/dist/client/lifecycle-events.d.ts.map +1 -0
  6. package/dist/client/stream-adapter.d.ts +11 -5
  7. package/dist/client/stream-adapter.d.ts.map +1 -1
  8. package/dist/client/xml-artifact-feed.d.ts +76 -0
  9. package/dist/client/xml-artifact-feed.d.ts.map +1 -0
  10. package/dist/components/bubble/aparte-chat-bubble.d.ts +35 -8
  11. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  12. package/dist/components/bubble/bubble-sync.d.ts.map +1 -1
  13. package/dist/components/chat/aparte-chat.d.ts +0 -6
  14. package/dist/components/chat/aparte-chat.d.ts.map +1 -1
  15. package/dist/components/composer/aparte-composer-action.d.ts +18 -3
  16. package/dist/components/composer/aparte-composer-action.d.ts.map +1 -1
  17. package/dist/components/composer/aparte-composer-add-attachment.d.ts.map +1 -1
  18. package/dist/components/composer/aparte-composer-attachments.d.ts +1 -1
  19. package/dist/components/composer/aparte-composer-attachments.d.ts.map +1 -1
  20. package/dist/components/composer/aparte-composer-cancel.d.ts.map +1 -1
  21. package/dist/components/composer/aparte-composer-input.d.ts +0 -2
  22. package/dist/components/composer/aparte-composer-input.d.ts.map +1 -1
  23. package/dist/components/composer/aparte-composer-send.d.ts +5 -0
  24. package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
  25. package/dist/components/composer/aparte-composer-toolbar.d.ts +4 -0
  26. package/dist/components/composer/aparte-composer-toolbar.d.ts.map +1 -1
  27. package/dist/components/composer/aparte-composer.d.ts +64 -6
  28. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  29. package/dist/components/composer/index.d.ts +1 -1
  30. package/dist/components/composer/index.d.ts.map +1 -1
  31. package/dist/components/conversation-list/aparte-conversation-list.d.ts +2 -5
  32. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  33. package/dist/components/conversation-list/index.d.ts +1 -1
  34. package/dist/components/conversation-list/index.d.ts.map +1 -1
  35. package/dist/components/elicitation/aparte-elicitation.d.ts +63 -3
  36. package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
  37. package/dist/components/index.d.ts +2 -2
  38. package/dist/components/index.d.ts.map +1 -1
  39. package/dist/components/status/aparte-chat-status.d.ts +8 -6
  40. package/dist/components/status/aparte-chat-status.d.ts.map +1 -1
  41. package/dist/components/viewport/aparte-chat-viewport.d.ts +45 -9
  42. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  43. package/dist/config/action-provider.d.ts +4 -4
  44. package/dist/config/aparte-config.d.ts +124 -41
  45. package/dist/config/aparte-config.d.ts.map +1 -1
  46. package/dist/config/attachment-renderer.d.ts +1 -1
  47. package/dist/config/avatar-provider.d.ts +1 -1
  48. package/dist/config/config-context.d.ts +66 -11
  49. package/dist/config/config-context.d.ts.map +1 -1
  50. package/dist/config/elicitation-field-renderer.d.ts +73 -0
  51. package/dist/config/elicitation-field-renderer.d.ts.map +1 -0
  52. package/dist/config/error-renderer.d.ts +1 -1
  53. package/dist/config/icon-provider.d.ts +2 -2
  54. package/dist/config/icon-provider.d.ts.map +1 -1
  55. package/dist/config/index.d.ts +7 -5
  56. package/dist/config/index.d.ts.map +1 -1
  57. package/dist/config/locale.d.ts +43 -2
  58. package/dist/config/locale.d.ts.map +1 -1
  59. package/dist/config/sanitize.d.ts +1 -7
  60. package/dist/config/sanitize.d.ts.map +1 -1
  61. package/dist/config/sibling-nav-renderer.d.ts +1 -1
  62. package/dist/config/skeleton-provider.d.ts +2 -2
  63. package/dist/config/skeleton-provider.d.ts.map +1 -1
  64. package/dist/config/status-renderer.d.ts +1 -1
  65. package/dist/conversations/conversation-controller.d.ts +19 -5
  66. package/dist/conversations/conversation-controller.d.ts.map +1 -1
  67. package/dist/conversations/conversation-manager.d.ts +2 -2
  68. package/dist/conversations/conversation-manager.d.ts.map +1 -1
  69. package/dist/conversations/index.d.ts +1 -1
  70. package/dist/conversations/index.d.ts.map +1 -1
  71. package/dist/conversations/types.d.ts +1 -1
  72. package/dist/custom-elements.json +8418 -6295
  73. package/dist/elicitation/index.d.ts +1 -1
  74. package/dist/elicitation/panel.d.ts +32 -0
  75. package/dist/elicitation/panel.d.ts.map +1 -1
  76. package/dist/elicitation/types.d.ts +29 -2
  77. package/dist/elicitation/types.d.ts.map +1 -1
  78. package/dist/host/aparte-chat-host.d.ts +3 -3
  79. package/dist/host/aparte-chat-host.d.ts.map +1 -1
  80. package/dist/{index-Dn7TH14G.js → index-hQQljwfX.js} +1890 -1222
  81. package/dist/index-hQQljwfX.js.map +1 -0
  82. package/dist/index.css +299 -21
  83. package/dist/index.d.ts +22 -13
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +485 -210
  86. package/dist/index.js.map +1 -1
  87. package/dist/index.node.d.ts +23 -13
  88. package/dist/index.node.d.ts.map +1 -1
  89. package/dist/index.node.js +56 -46
  90. package/dist/index.node.js.map +1 -1
  91. package/dist/interop/element-props.d.ts +1 -1
  92. package/dist/interop/element-props.d.ts.map +1 -1
  93. package/dist/parsers/aparte-stream-parser.d.ts +25 -0
  94. package/dist/parsers/aparte-stream-parser.d.ts.map +1 -1
  95. package/dist/primitives/index.d.ts +1 -1
  96. package/dist/primitives/index.d.ts.map +1 -1
  97. package/dist/primitives/select/aparte-optgroup.d.ts +15 -4
  98. package/dist/primitives/select/aparte-optgroup.d.ts.map +1 -1
  99. package/dist/primitives/select/aparte-option.d.ts +0 -5
  100. package/dist/primitives/select/aparte-option.d.ts.map +1 -1
  101. package/dist/primitives/select/aparte-select.d.ts +17 -5
  102. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  103. package/dist/primitives/select/index.d.ts +1 -1
  104. package/dist/primitives/select/index.d.ts.map +1 -1
  105. package/dist/renderers/markdown-stream.d.ts +42 -0
  106. package/dist/renderers/markdown-stream.d.ts.map +1 -0
  107. package/dist/renderers/segment-renderers.d.ts +20 -14
  108. package/dist/renderers/segment-renderers.d.ts.map +1 -1
  109. package/dist/renderers/segments/artifact/binary-file.d.ts +45 -0
  110. package/dist/renderers/segments/artifact/binary-file.d.ts.map +1 -0
  111. package/dist/renderers/segments/artifact/card.d.ts +3 -0
  112. package/dist/renderers/segments/artifact/card.d.ts.map +1 -0
  113. package/dist/renderers/segments/artifact/preview-document.d.ts +27 -0
  114. package/dist/renderers/segments/artifact/preview-document.d.ts.map +1 -0
  115. package/dist/renderers/segments/artifact/shared.d.ts +12 -0
  116. package/dist/renderers/segments/artifact/shared.d.ts.map +1 -0
  117. package/dist/renderers/segments/code.d.ts +3 -0
  118. package/dist/renderers/segments/code.d.ts.map +1 -0
  119. package/dist/renderers/segments/error.d.ts +3 -0
  120. package/dist/renderers/segments/error.d.ts.map +1 -0
  121. package/dist/renderers/segments/file-tree.d.ts +3 -0
  122. package/dist/renderers/segments/file-tree.d.ts.map +1 -0
  123. package/dist/renderers/segments/pipeline-waiting.d.ts +3 -0
  124. package/dist/renderers/segments/pipeline-waiting.d.ts.map +1 -0
  125. package/dist/renderers/segments/progress.d.ts +3 -0
  126. package/dist/renderers/segments/progress.d.ts.map +1 -0
  127. package/dist/renderers/segments/terminal.d.ts +3 -0
  128. package/dist/renderers/segments/terminal.d.ts.map +1 -0
  129. package/dist/renderers/segments/text.d.ts +10 -0
  130. package/dist/renderers/segments/text.d.ts.map +1 -0
  131. package/dist/renderers/segments/thinking.d.ts +3 -0
  132. package/dist/renderers/segments/thinking.d.ts.map +1 -0
  133. package/dist/renderers/segments/tool-call.d.ts +3 -0
  134. package/dist/renderers/segments/tool-call.d.ts.map +1 -0
  135. package/dist/runtime/message-repository.d.ts +3 -3
  136. package/dist/runtime/message-repository.d.ts.map +1 -1
  137. package/dist/transport/backend-handler.d.ts +19 -7
  138. package/dist/transport/backend-handler.d.ts.map +1 -1
  139. package/dist/transport/backend-transport.d.ts +1 -1
  140. package/dist/transport/backend-transport.d.ts.map +1 -1
  141. package/dist/transport/direct-transport.d.ts +4 -4
  142. package/dist/transport/direct-transport.d.ts.map +1 -1
  143. package/dist/transport/index.d.ts +2 -2
  144. package/dist/transport/index.d.ts.map +1 -1
  145. package/dist/transport/types.d.ts +3 -3
  146. package/dist/types/element-map.d.ts +69 -0
  147. package/dist/types/element-map.d.ts.map +1 -0
  148. package/dist/types/event-map.d.ts +84 -23
  149. package/dist/types/event-map.d.ts.map +1 -1
  150. package/dist/types/events.d.ts +212 -64
  151. package/dist/types/events.d.ts.map +1 -1
  152. package/dist/types/index.d.ts +4 -5
  153. package/dist/types/index.d.ts.map +1 -1
  154. package/dist/types/model-provider.d.ts +100 -54
  155. package/dist/types/model-provider.d.ts.map +1 -1
  156. package/dist/types/models.d.ts +29 -7
  157. package/dist/types/models.d.ts.map +1 -1
  158. package/dist/types/segments.d.ts +33 -8
  159. package/dist/types/segments.d.ts.map +1 -1
  160. package/dist/types/theming.d.ts +25 -37
  161. package/dist/types/theming.d.ts.map +1 -1
  162. package/dist/types/tools.d.ts +60 -15
  163. package/dist/types/tools.d.ts.map +1 -1
  164. package/dist/utils/escape.d.ts +46 -0
  165. package/dist/utils/escape.d.ts.map +1 -0
  166. package/dist/utils/files-to-attachments.d.ts +18 -1
  167. package/dist/utils/files-to-attachments.d.ts.map +1 -1
  168. package/dist/utils/segments.d.ts +174 -0
  169. package/dist/utils/segments.d.ts.map +1 -0
  170. package/dist/utils/uuid.d.ts +26 -0
  171. package/dist/utils/uuid.d.ts.map +1 -0
  172. package/package.json +3 -5
  173. package/dist/index-Dn7TH14G.js.map +0 -1
  174. package/dist/types/providers.d.ts +0 -93
  175. package/dist/types/providers.d.ts.map +0 -1
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { R as resolveConfig, S as runWithConfig, F as getSegmentRenderer, U as installDefaultRenderersOnce, M as MessageRepository, G as isAwaitingReply, L as populateBubbleFromMessage, d as AparteConfig, r as buildElicitationPanel } from "./index-Dn7TH14G.js";
2
- import { A, a, b, c, e, f, g, h, i, B, C, D, j, k, l, m, n, o, p, q, s, t, u, v, w, x, y, z, E, H, I, J, K, N, O, P, Q, T } from "./index-Dn7TH14G.js";
1
+ import { H as escapeAttr, Y as resolveConfig, C as cssEscape, _ as runWithConfig, I as escapeHtml, L as getSegmentRenderer, M as installDefaultRenderersOnce, q as AparteMessageRepository, a2 as stampSegmentActivity, a3 as stampSegmentOnInsert, a4 as stampSegmentOnUpdate, a5 as renumberSegments, a6 as openSegmentIds, a7 as isTerminalStatus, N as isAwaitingReply, T as populateBubbleFromMessage, a1 as uuid, Z as revokeAttachmentUrls, v as buildElicitationPanel } from "./index-hQQljwfX.js";
2
+ import { A, a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, r, s, t, u, w, x, y, z, B, D, E, F, G, J, K, O, P, Q, R, S, U, V, W, X, $, a0 } from "./index-hQQljwfX.js";
3
3
  class AparteOption extends HTMLElement {
4
4
  static get observedAttributes() {
5
5
  return ["value", "disabled", "selected", "data-status"];
@@ -192,6 +192,22 @@ class AparteSelect extends HTMLElement {
192
192
  _searchInput = null;
193
193
  _observer = null;
194
194
  // Bound handlers for cleanup
195
+ /**
196
+ * Bound, like its two neighbours below — because an inline arrow on `this` can
197
+ * never be removed.
198
+ *
199
+ * `_setupEventListeners()` runs on EVERY `connectedCallback`, and `_render()`'s
200
+ * idempotency guard means the host element survives a re-connect. So each move
201
+ * of the element (a portal, a Vue teleport, any framework re-parent) added
202
+ * another option-click listener: measured, one click fired the change handler
203
+ * FIVE times after five re-connects. `disconnectedCallback` removed the two
204
+ * document-level handlers and could not touch this one.
205
+ *
206
+ * This is verbatim the bug class `aparte-chat-viewport` documents having fixed
207
+ * for its own listeners; a repo sweep found this as the last inline-arrow
208
+ * listener on a persistent node.
209
+ */
210
+ _boundHandleOptionClick = this._handleOptionClick.bind(this);
195
211
  _boundHandleDocumentClick = this._handleDocumentClick.bind(this);
196
212
  _boundHandleKeydown = this._handleKeydown.bind(this);
197
213
  static get observedAttributes() {
@@ -205,6 +221,7 @@ class AparteSelect extends HTMLElement {
205
221
  this._setupMutationObserver();
206
222
  }
207
223
  disconnectedCallback() {
224
+ this.removeEventListener("click", this._boundHandleOptionClick);
208
225
  document.removeEventListener("click", this._boundHandleDocumentClick);
209
226
  document.removeEventListener("keydown", this._boundHandleKeydown);
210
227
  this._observer?.disconnect();
@@ -368,12 +385,8 @@ class AparteSelect extends HTMLElement {
368
385
  this._openDropdown();
369
386
  }
370
387
  });
371
- this.addEventListener("click", (e2) => {
372
- const option = e2.target.closest("aparte-option");
373
- if (option && !option.hasAttribute("disabled")) {
374
- this._selectOption(option);
375
- }
376
- });
388
+ this.removeEventListener("click", this._boundHandleOptionClick);
389
+ this.addEventListener("click", this._boundHandleOptionClick);
377
390
  this._searchInput?.addEventListener("input", (e2) => {
378
391
  const query = e2.target.value.toLowerCase();
379
392
  this._filterOptions(query);
@@ -381,6 +394,12 @@ class AparteSelect extends HTMLElement {
381
394
  document.addEventListener("click", this._boundHandleDocumentClick);
382
395
  document.addEventListener("keydown", this._boundHandleKeydown);
383
396
  }
397
+ _handleOptionClick(e2) {
398
+ const option = e2.target.closest("aparte-option");
399
+ if (option && !option.hasAttribute("disabled")) {
400
+ this._selectOption(option);
401
+ }
402
+ }
384
403
  _handleDocumentClick(e2) {
385
404
  if (!this.contains(e2.target)) {
386
405
  this._closeDropdown();
@@ -660,7 +679,7 @@ class AparteChat extends HTMLElement {
660
679
  if (this.hasAttribute("framework-managed")) return;
661
680
  if (this.querySelector("aparte-chat-viewport")) return;
662
681
  const placeholder = this.getAttribute("placeholder");
663
- const composerAttrs = (placeholder !== null ? ` placeholder="${this._escapeAttr(placeholder)}"` : "") + (this.hasAttribute("disabled") ? " disabled" : "");
682
+ const composerAttrs = (placeholder !== null ? ` placeholder="${escapeAttr(placeholder)}"` : "") + (this.hasAttribute("disabled") ? " disabled" : "");
664
683
  const attachments = this.hasAttribute("attachments");
665
684
  this.innerHTML = `
666
685
  <aparte-chat-viewport></aparte-chat-viewport>
@@ -723,27 +742,21 @@ class AparteChat extends HTMLElement {
723
742
  const empty = !viewport || !viewport.querySelector("aparte-chat-bubble");
724
743
  this.toggleAttribute("data-empty", empty);
725
744
  }
726
- _escapeAttr(value) {
727
- return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
728
- }
729
745
  }
730
746
  if (!customElements.get("aparte-chat")) {
731
747
  customElements.define("aparte-chat", AparteChat);
732
748
  }
733
- function cssEscape(value) {
734
- return value.replace(/["\\]/g, "\\$&");
735
- }
736
749
  let _warnedNoRenderer = false;
737
750
  function warnMissingRenderer(type) {
738
751
  if (_warnedNoRenderer) return;
739
752
  _warnedNoRenderer = true;
740
753
  console.warn(`[aparte] No renderer for segment "${type}". Register one with registerSegmentRenderer({ type: '${type}', render }) from @aparte/core — see https://apartejs.dev/guides/customization/#custom-segment-types`);
741
754
  }
742
- function resolveSegmentRenderer(type) {
743
- const renderer = getSegmentRenderer(type);
755
+ function resolveSegmentRenderer(type, config) {
756
+ const renderer = getSegmentRenderer(type, config);
744
757
  if (renderer) return renderer;
745
- installDefaultRenderersOnce();
746
- return getSegmentRenderer(type);
758
+ installDefaultRenderersOnce(config);
759
+ return getSegmentRenderer(type, config);
747
760
  }
748
761
  function segmentRenderResultToElement(result) {
749
762
  if (result instanceof HTMLElement) return result;
@@ -820,9 +833,11 @@ class AparteChatBubble extends HTMLElement {
820
833
  this._updateContent();
821
834
  this._updateTimestamp(this.getAttribute("timestamp"));
822
835
  window.addEventListener("aparte-config-change", this._onConfigChange);
836
+ this.addEventListener("click", this._onBranchPickerClick);
823
837
  }
824
838
  disconnectedCallback() {
825
839
  window.removeEventListener("aparte-config-change", this._onConfigChange);
840
+ this.removeEventListener("click", this._onBranchPickerClick);
826
841
  if (this._avatarCleanup) {
827
842
  try {
828
843
  this._avatarCleanup();
@@ -909,12 +924,26 @@ class AparteChatBubble extends HTMLElement {
909
924
  return [...this._segments];
910
925
  }
911
926
  /** Remove a segment by id (e.g. to discard a transient waiting indicator) */
927
+ /**
928
+ * Scoped to DIRECT children on purpose.
929
+ *
930
+ * Segments are appended as direct children of the container, but a descendant
931
+ * query returns the first match in document order — and sanitized model
932
+ * markdown renders inside that same container, with `data-*` attributes
933
+ * deliberately preserved (they are inert). So a decoy `data-segment-id` planted
934
+ * in an earlier segment's prose used to win over the real segment element.
935
+ *
936
+ * Parser ids are unguessable UUIDs, but a tool segment is `tool-${toolCallId}`
937
+ * and the MODEL chooses that id — so this was reachable, and pointing an update
938
+ * at a decoy left a rejected tool rendering as still-running: a spoof against
939
+ * the human-in-the-loop control.
940
+ */
912
941
  removeSegment(segmentId) {
913
942
  const index = this._segments.findIndex((s2) => s2.id === segmentId);
914
943
  if (index !== -1) {
915
944
  this._segments.splice(index, 1);
916
945
  }
917
- const el = this._segmentsEl?.querySelector(`[data-segment-id="${cssEscape(segmentId)}"]`);
946
+ const el = this._segmentsEl?.querySelector(`:scope > [data-segment-id="${cssEscape(segmentId)}"]`);
918
947
  el?.remove();
919
948
  this._updateWaiting();
920
949
  }
@@ -928,7 +957,7 @@ class AparteChatBubble extends HTMLElement {
928
957
  *
929
958
  * This is the *precondition* for the info ("i") action, not the trigger: the
930
959
  * button appears only if the app also declared it wants it —
931
- * `AparteConfig.setBubbleActions({ info: true })` — because the stats popover it
960
+ * `aparteGlobalConfig.setBubbleActions({ info: true })` — because the stats popover it
932
961
  * opens (`aparte-message-info`) is the app's, and core has none. Without usage
933
962
  * there is nothing to show, so the button never renders either way.
934
963
  */
@@ -983,7 +1012,7 @@ class AparteChatBubble extends HTMLElement {
983
1012
  console.warn(`[AparteChatBubble] _appendSegmentEl ABORT: _segmentsEl is null`);
984
1013
  return;
985
1014
  }
986
- const renderer = resolveSegmentRenderer(segment.type);
1015
+ const renderer = resolveSegmentRenderer(segment.type, this._cfg);
987
1016
  if (renderer) {
988
1017
  const el = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)));
989
1018
  if (el) {
@@ -1001,12 +1030,12 @@ class AparteChatBubble extends HTMLElement {
1001
1030
  this._reflectError();
1002
1031
  }
1003
1032
  _applySegmentUpdate(segmentId, segment, updates) {
1004
- const el = this._segmentsEl?.querySelector(`[data-segment-id="${cssEscape(segmentId)}"]`);
1033
+ const el = this._segmentsEl?.querySelector(`:scope > [data-segment-id="${cssEscape(segmentId)}"]`);
1005
1034
  if (!el) {
1006
1035
  this._renderSegments();
1007
1036
  return;
1008
1037
  }
1009
- const renderer = resolveSegmentRenderer(segment.type);
1038
+ const renderer = resolveSegmentRenderer(segment.type, this._cfg);
1010
1039
  if (!renderer) return;
1011
1040
  if (renderer.update) {
1012
1041
  runWithConfig(this._cfg, () => renderer.update(el, segment));
@@ -1056,11 +1085,11 @@ class AparteChatBubble extends HTMLElement {
1056
1085
  else this.innerHTML = out;
1057
1086
  } else {
1058
1087
  this.innerHTML = `
1059
- <div class="aparte-message" data-role="${role}" role="article" aria-label="${this._getAriaLabel()}">
1060
- <div class="aparte-avatar" data-role="${role}"></div>
1088
+ <div class="aparte-message" data-role="${escapeAttr(role)}" role="article" aria-label="${escapeAttr(this._getAriaLabel())}">
1089
+ <div class="aparte-avatar" data-role="${escapeAttr(role)}"></div>
1061
1090
  <div class="aparte-body">
1062
1091
  <div class="aparte-header">
1063
- <span class="aparte-name">${this._escapeHtml(displayName)}</span>
1092
+ <span class="aparte-name">${escapeHtml(displayName)}</span>
1064
1093
  <span class="aparte-timestamp"></span>
1065
1094
  </div>
1066
1095
  <div class="aparte-attachments" hidden></div>
@@ -1074,11 +1103,11 @@ class AparteChatBubble extends HTMLElement {
1074
1103
  </div>
1075
1104
  <div class="aparte-footer">
1076
1105
  <div class="aparte-branch-picker" hidden>
1077
- <button class="aparte-branch-prev" aria-label="${this._cfg.getLocale().previousResponse ?? "Previous response"}">&#8249;</button>
1106
+ <button class="aparte-branch-prev" aria-label="${escapeAttr(this._cfg.getLocale().previousResponse ?? "Previous response")}">&#8249;</button>
1078
1107
  <span class="aparte-branch-label">1 / 1</span>
1079
- <button class="aparte-branch-next" aria-label="${this._cfg.getLocale().nextResponse ?? "Next response"}">&#8250;</button>
1108
+ <button class="aparte-branch-next" aria-label="${escapeAttr(this._cfg.getLocale().nextResponse ?? "Next response")}">&#8250;</button>
1080
1109
  </div>
1081
- <div class="aparte-action-bar" role="toolbar" aria-label="${this._cfg.getLocale().messageActions ?? "Message actions"}"></div>
1110
+ <div class="aparte-action-bar" role="toolbar" aria-label="${escapeAttr(this._cfg.getLocale().messageActions ?? "Message actions")}"></div>
1082
1111
  </div>
1083
1112
  </div>
1084
1113
  </div>
@@ -1090,7 +1119,6 @@ class AparteChatBubble extends HTMLElement {
1090
1119
  this._actionBarEl = this.querySelector(".aparte-action-bar");
1091
1120
  this._branchPickerEl = this.querySelector(".aparte-branch-picker");
1092
1121
  this._footerEl = this.querySelector(".aparte-footer");
1093
- this._setupBranchPickerListeners();
1094
1122
  this._updateActionBar();
1095
1123
  this._renderAvatar();
1096
1124
  if (this._streaming) this._updateStreaming(true);
@@ -1204,7 +1232,7 @@ class AparteChatBubble extends HTMLElement {
1204
1232
  if (!this._segmentsEl) return;
1205
1233
  this._segmentsEl.innerHTML = "";
1206
1234
  for (const segment of this._segments) {
1207
- const renderer = resolveSegmentRenderer(segment.type);
1235
+ const renderer = resolveSegmentRenderer(segment.type, this._cfg);
1208
1236
  if (renderer) {
1209
1237
  const el = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)));
1210
1238
  if (el) {
@@ -1276,11 +1304,11 @@ class AparteChatBubble extends HTMLElement {
1276
1304
  return;
1277
1305
  }
1278
1306
  this._attachmentsEl.innerHTML = this._attachments.map((a2) => {
1279
- const name = this._escapeHtml(a2.name);
1307
+ const name = escapeHtml(a2.name);
1280
1308
  if (a2.type.startsWith("image/")) {
1281
- return `<div class="aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${this._escapeHtml(a2.url)}" alt="${name}" loading="lazy" /><span class="aparte-thumb__name">${name}</span></div>`;
1309
+ return `<div class="aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${escapeHtml(a2.url)}" alt="${name}" loading="lazy" /><span class="aparte-thumb__name">${name}</span></div>`;
1282
1310
  }
1283
- return `<div class="aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${this._escapeHtml(this._fileExt(a2.name))}</span><span class="aparte-thumb__name">${name}</span></div>`;
1311
+ return `<div class="aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${escapeHtml(this._fileExt(a2.name))}</span><span class="aparte-thumb__name">${name}</span></div>`;
1284
1312
  }).join("");
1285
1313
  if (!this._cfg.getHostHandlers().attachmentPreview) return;
1286
1314
  this._attachmentsEl.querySelectorAll(".aparte-thumb--image").forEach((tile) => {
@@ -1312,22 +1340,39 @@ class AparteChatBubble extends HTMLElement {
1312
1340
  // ─────────────────────────────────────────────────────────────────────────
1313
1341
  // Branch Picker
1314
1342
  // ─────────────────────────────────────────────────────────────────────────
1315
- _setupBranchPickerListeners() {
1316
- const prevBtn = this._branchPickerEl?.querySelector(".aparte-branch-prev");
1317
- const nextBtn = this._branchPickerEl?.querySelector(".aparte-branch-next");
1318
- const dispatchNav = (direction) => {
1319
- const messageId = this.getAttribute("message-id");
1320
- if (!messageId) return;
1321
- const detail = { messageId, direction };
1322
- this.dispatchEvent(new CustomEvent("aparte-branch-navigate", {
1323
- bubbles: true,
1324
- composed: true,
1325
- detail
1326
- }));
1327
- };
1328
- prevBtn?.addEventListener("click", () => dispatchNav("prev"));
1329
- nextBtn?.addEventListener("click", () => dispatchNav("next"));
1330
- }
1343
+ /**
1344
+ * The branch arrows are handled by DELEGATION, on this element, bound once.
1345
+ *
1346
+ * They used to get a fresh listener each, attached by `_render()` to the buttons
1347
+ * `_render()` had just created. So a click that landed while a re-render was
1348
+ * swapping those nodes hit an element about to be discarded, and did nothing at
1349
+ * all not late, nothing. Invisible on a fast machine; reproducible on
1350
+ * WebKit-Linux in CI, where `‹` left the picker on "2 / 2" and a 20-second
1351
+ * assertion watched it stay there.
1352
+ *
1353
+ * Delegation makes `_render()` irrelevant to it: the listener lives on the host,
1354
+ * which is never replaced, and `closest()` finds whichever button exists at the
1355
+ * moment of the click. It is also less work — one listener per bubble instead of
1356
+ * two per bubble per render.
1357
+ *
1358
+ * Bound in `connectedCallback` and removed in `disconnectedCallback` as a stable
1359
+ * field, because an inline arrow re-added on every re-connect is how this repo has
1360
+ * stacked listeners twice before (the viewport, and `aparte-select`).
1361
+ */
1362
+ _onBranchPickerClick = (event) => {
1363
+ const target = event.target;
1364
+ const button = target?.closest?.(".aparte-branch-prev, .aparte-branch-next");
1365
+ if (!button || !this.contains(button)) return;
1366
+ const direction = button.classList.contains("aparte-branch-prev") ? "prev" : "next";
1367
+ const messageId = this.getAttribute("message-id");
1368
+ if (!messageId) return;
1369
+ const detail = { messageId, direction };
1370
+ this.dispatchEvent(new CustomEvent("aparte-branch-navigate", {
1371
+ bubbles: true,
1372
+ composed: true,
1373
+ detail
1374
+ }));
1375
+ };
1331
1376
  _updateBranchPicker() {
1332
1377
  if (!this._branchPickerEl) return;
1333
1378
  if (this._siblingCount <= 1 || this._role !== "assistant") {
@@ -1428,28 +1473,28 @@ class AparteChatBubble extends HTMLElement {
1428
1473
  switch (action) {
1429
1474
  case "copy": {
1430
1475
  const l2 = locale.copy ?? "Copy";
1431
- return `<button class="aparte-action-btn aparte-action-copy" data-action="copy" aria-label="${l2}" title="${l2}">${icons.copy()}</button>`;
1476
+ return `<button class="aparte-action-btn aparte-action-copy" data-action="copy" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.copy()}</button>`;
1432
1477
  }
1433
1478
  case "edit": {
1434
1479
  const l2 = locale.edit ?? "Edit message";
1435
- return `<button class="aparte-action-btn aparte-action-edit" data-action="edit" aria-label="${l2}" title="${l2}">${icons.edit()}</button>`;
1480
+ return `<button class="aparte-action-btn aparte-action-edit" data-action="edit" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.edit()}</button>`;
1436
1481
  }
1437
1482
  case "retry": {
1438
1483
  const l2 = locale.retry ?? "Retry";
1439
- return `<button class="aparte-action-btn aparte-action-retry" data-action="retry" aria-label="${l2}" title="${l2}">${icons.retry()}</button>`;
1484
+ return `<button class="aparte-action-btn aparte-action-retry" data-action="retry" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.retry()}</button>`;
1440
1485
  }
1441
1486
  case "thumbUp": {
1442
1487
  const l2 = locale.feedbackPositive ?? "Good response";
1443
- return `<button class="aparte-action-btn aparte-action-feedback-pos" data-action="feedback-positive" aria-label="${l2}" title="${l2}">${icons.thumbUp()}</button>`;
1488
+ return `<button class="aparte-action-btn aparte-action-feedback-pos" data-action="feedback-positive" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.thumbUp()}</button>`;
1444
1489
  }
1445
1490
  case "thumbDown": {
1446
1491
  const l2 = locale.feedbackNegative ?? "Bad response";
1447
- return `<button class="aparte-action-btn aparte-action-feedback-neg" data-action="feedback-negative" aria-label="${l2}" title="${l2}">${icons.thumbDown()}</button>`;
1492
+ return `<button class="aparte-action-btn aparte-action-feedback-neg" data-action="feedback-negative" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.thumbDown()}</button>`;
1448
1493
  }
1449
1494
  case "info": {
1450
1495
  if (!this._usage) return "";
1451
1496
  const l2 = locale.messageInfo ?? "Details";
1452
- return `<button class="aparte-action-btn aparte-action-info" data-action="info" aria-label="${l2}" title="${l2}">${INFO_ICON_SVG}</button>`;
1497
+ return `<button class="aparte-action-btn aparte-action-info" data-action="info" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${INFO_ICON_SVG}</button>`;
1453
1498
  }
1454
1499
  default:
1455
1500
  return "";
@@ -1461,7 +1506,7 @@ class AparteChatBubble extends HTMLElement {
1461
1506
  const locale = this._cfg.getLocale();
1462
1507
  const saveLabel = locale.editConfirm ?? "Save";
1463
1508
  const cancelLabel = locale.editCancel ?? "Cancel";
1464
- this._actionBarEl.innerHTML = `<button class="aparte-action-btn aparte-action-edit-save" data-action="edit-save" aria-label="${saveLabel}" title="${saveLabel}">${this._cfg.getIcon("check")}</button><button class="aparte-action-btn aparte-action-edit-cancel" data-action="edit-cancel" aria-label="${cancelLabel}" title="${cancelLabel}">${this._cfg.getIcon("close")}</button>`;
1509
+ this._actionBarEl.innerHTML = `<button class="aparte-action-btn aparte-action-edit-save" data-action="edit-save" aria-label="${escapeAttr(saveLabel)}" title="${escapeAttr(saveLabel)}">${this._cfg.getIcon("check")}</button><button class="aparte-action-btn aparte-action-edit-cancel" data-action="edit-cancel" aria-label="${escapeAttr(cancelLabel)}" title="${escapeAttr(cancelLabel)}">${this._cfg.getIcon("close")}</button>`;
1465
1510
  this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
1466
1511
  btn.addEventListener("click", (e2) => this._handleActionClick(e2));
1467
1512
  });
@@ -1636,9 +1681,6 @@ class AparteChatBubble extends HTMLElement {
1636
1681
  // ─────────────────────────────────────────────────────────────────────────
1637
1682
  // Utilities
1638
1683
  // ─────────────────────────────────────────────────────────────────────────
1639
- _escapeHtml(str) {
1640
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
1641
- }
1642
1684
  _updateStreaming(streaming) {
1643
1685
  const wasStreaming = this._streaming;
1644
1686
  this._streaming = streaming;
@@ -1664,12 +1706,20 @@ class AparteChatStatus extends HTMLElement {
1664
1706
  static get observedAttributes() {
1665
1707
  return ["visible", "text"];
1666
1708
  }
1667
- _cfg;
1709
+ /**
1710
+ * Resolved LIVE, not cached. Caching it at connect made this element
1711
+ * permanently deaf to its own instance: `_onConfigChange` filters on
1712
+ * `detail.config !== this._cfg`, so once `_cfg` had latched the global config no
1713
+ * change for the real instance ever matched, and the filter meant to isolate
1714
+ * chats became the thing that silenced one.
1715
+ */
1716
+ get _cfg() {
1717
+ return resolveConfig(this);
1718
+ }
1668
1719
  constructor() {
1669
1720
  super();
1670
1721
  }
1671
1722
  connectedCallback() {
1672
- this._cfg = resolveConfig(this);
1673
1723
  this._render();
1674
1724
  window.addEventListener("aparte-config-change", this._onConfigChange);
1675
1725
  }
@@ -1792,7 +1842,7 @@ class AparteChatViewport extends HTMLElement {
1792
1842
  _spacerRafId = null;
1793
1843
  _spacerFrozenUntil = 0;
1794
1844
  _layoutTransitionMs = 0;
1795
- _repo = new MessageRepository();
1845
+ _repo = new AparteMessageRepository();
1796
1846
  _isAutoScrollEnabled = true;
1797
1847
  _scrollThreshold = 50;
1798
1848
  /** When true, the next _autoScroll() call uses smooth instead of instant, then resets. */
@@ -1928,7 +1978,13 @@ class AparteChatViewport extends HTMLElement {
1928
1978
  if (segment && "content" in segment) {
1929
1979
  message.segments[index] = {
1930
1980
  ...segment,
1931
- content: segment.content + chunk
1981
+ content: segment.content + chunk,
1982
+ // Content arriving IS the segment's activity, so this is what
1983
+ // `endedAt` measures. Without it a thinking block's end would be
1984
+ // whenever someone happened to notice it had stopped — the end of
1985
+ // the turn, or the start of the next segment — and both of those
1986
+ // silently fold the waiting that followed into the duration.
1987
+ ...stampSegmentActivity(segment)
1932
1988
  };
1933
1989
  }
1934
1990
  this.dispatchEvent(new CustomEvent("aparte-segment-update", {
@@ -1947,7 +2003,17 @@ class AparteChatViewport extends HTMLElement {
1947
2003
  * framework wrapper's host element.
1948
2004
  */
1949
2005
  _activeMessageId() {
1950
- return this._repo.headId;
2006
+ const head = this._repo.headId;
2007
+ const streaming = this._streamingMessageId();
2008
+ if (streaming !== null && streaming !== head) return null;
2009
+ return head;
2010
+ }
2011
+ /** The id of the message currently streaming, if any. */
2012
+ _streamingMessageId() {
2013
+ for (const message of this._repo.getMessages()) {
2014
+ if (message.isStreaming) return message.id;
2015
+ }
2016
+ return null;
1951
2017
  }
1952
2018
  addSegment(messageIdOrSegment, maybeSegment) {
1953
2019
  const messageId = typeof messageIdOrSegment === "string" ? messageIdOrSegment : this._activeMessageId();
@@ -1957,8 +2023,9 @@ class AparteChatViewport extends HTMLElement {
1957
2023
  if (!message.segments) {
1958
2024
  message.segments = [];
1959
2025
  }
1960
- message.segments.push(segment);
1961
- this._notifyBubble(messageId, "addSegment", segment);
2026
+ const stamped = stampSegmentOnInsert(message.segments, segment, messageId);
2027
+ message.segments.push(stamped);
2028
+ this._notifyBubble(messageId, "addSegment", stamped);
1962
2029
  this._autoScroll();
1963
2030
  }
1964
2031
  updateSegment(a2, b2, c2) {
@@ -1971,11 +2038,13 @@ class AparteChatViewport extends HTMLElement {
1971
2038
  if (!message?.segments) return;
1972
2039
  const segmentIndex = message.segments.findIndex((s2) => s2.id === segmentId);
1973
2040
  if (segmentIndex !== -1) {
2041
+ const current = message.segments[segmentIndex];
2042
+ const stamped = stampSegmentOnUpdate(current, updates);
1974
2043
  message.segments[segmentIndex] = {
1975
- ...message.segments[segmentIndex],
1976
- ...updates
2044
+ ...current,
2045
+ ...stamped
1977
2046
  };
1978
- this._notifyBubble(messageId, "updateSegment", { segmentId, updates });
2047
+ this._notifyBubble(messageId, "updateSegment", { segmentId, updates: stamped });
1979
2048
  }
1980
2049
  }
1981
2050
  removeSegment(a2, b2) {
@@ -1986,7 +2055,10 @@ class AparteChatViewport extends HTMLElement {
1986
2055
  const message = this._repo.getMessageById(messageId);
1987
2056
  if (message?.segments) {
1988
2057
  const idx = message.segments.findIndex((s2) => s2.id === segmentId);
1989
- if (idx !== -1) message.segments.splice(idx, 1);
2058
+ if (idx !== -1) {
2059
+ message.segments.splice(idx, 1);
2060
+ renumberSegments(message.segments);
2061
+ }
1990
2062
  }
1991
2063
  this._notifyBubble(messageId, "removeSegment", segmentId);
1992
2064
  }
@@ -2007,7 +2079,7 @@ class AparteChatViewport extends HTMLElement {
2007
2079
  /**
2008
2080
  * Persist token usage on a message and propagate to the live bubble, which is
2009
2081
  * what allows the info ("i") action to render — provided the app declared it
2010
- * with `AparteConfig.setBubbleActions({ info: true })`; it is off by default,
2082
+ * with `aparteGlobalConfig.setBubbleActions({ info: true })`; it is off by default,
2011
2083
  * since the popover it opens belongs to the app.
2012
2084
  */
2013
2085
  setUsage(messageId, usage) {
@@ -2026,13 +2098,26 @@ class AparteChatViewport extends HTMLElement {
2026
2098
  if (message) {
2027
2099
  message.isStreaming = false;
2028
2100
  message.status = "completed";
2029
- message.segments?.forEach((s2) => {
2030
- s2.isStreaming = false;
2031
- });
2101
+ this._settleSegments(messageId, message);
2032
2102
  this._notifyBubble(messageId, "complete", { status: "completed" });
2033
2103
  this._recalculateSpacer();
2034
2104
  }
2035
2105
  }
2106
+ /**
2107
+ * Close a finished message's still-open segments, one `updateSegment` each.
2108
+ *
2109
+ * Deliberately NOT a loop that writes `isStreaming` onto the objects: that path
2110
+ * stamps the model and tells the bubble nothing, so a renderer never learns its
2111
+ * segment settled — no `endedAt` in the rendered label, and no final Markdown
2112
+ * flush either. Going through `updateSegment` reuses the one path that does
2113
+ * both.
2114
+ */
2115
+ _settleSegments(messageId, message) {
2116
+ if (!message.segments) return;
2117
+ for (const id of openSegmentIds(message.segments)) {
2118
+ this.updateSegment(messageId, id, { isStreaming: false });
2119
+ }
2120
+ }
2036
2121
  /**
2037
2122
  * Atomic update for a message by ID
2038
2123
  * Supports updating content, status, segments, and other metadata
@@ -2043,6 +2128,7 @@ class AparteChatViewport extends HTMLElement {
2043
2128
  Object.assign(message, updates);
2044
2129
  if (updates.status) {
2045
2130
  message.isStreaming = updates.status === "streaming" || updates.status === "pending";
2131
+ if (isTerminalStatus(updates.status)) this._settleSegments(messageId, message);
2046
2132
  }
2047
2133
  this._notifyBubble(messageId, "update", updates);
2048
2134
  this._autoScroll();
@@ -2125,7 +2211,7 @@ class AparteChatViewport extends HTMLElement {
2125
2211
  const meta = this._repo.getMessage(messageId);
2126
2212
  if (!meta) return 0;
2127
2213
  const newMsg = {
2128
- id: crypto.randomUUID(),
2214
+ id: uuid(),
2129
2215
  role: "assistant",
2130
2216
  content: "",
2131
2217
  status: "pending",
@@ -2235,7 +2321,7 @@ class AparteChatViewport extends HTMLElement {
2235
2321
  * branch arrows on already-rendered bubbles.
2236
2322
  */
2237
2323
  importTree(tree) {
2238
- this.clearAll();
2324
+ this.clearAll({ revokeAttachments: false });
2239
2325
  this._repo.import(tree);
2240
2326
  this._reRenderActivePath();
2241
2327
  }
@@ -2248,7 +2334,12 @@ class AparteChatViewport extends HTMLElement {
2248
2334
  * so desynchronises the framework's view tree from the live DOM and the
2249
2335
  * next change-detection pass throws `NotFoundError` on insertBefore.
2250
2336
  */
2251
- clearAll() {
2337
+ clearAll(options) {
2338
+ if (options?.revokeAttachments !== false) {
2339
+ for (const message of this._repo.getMessages()) {
2340
+ revokeAttachmentUrls(message.attachments);
2341
+ }
2342
+ }
2252
2343
  this._repo.clear();
2253
2344
  if (!this._frameworkManagedDOM) {
2254
2345
  const wrapper = this.querySelector(".aparte-messages-wrapper");
@@ -2277,7 +2368,7 @@ class AparteChatViewport extends HTMLElement {
2277
2368
  * build chat history).
2278
2369
  */
2279
2370
  setMessages(messages) {
2280
- this.clearAll();
2371
+ this.clearAll({ revokeAttachments: false });
2281
2372
  for (const m2 of messages) {
2282
2373
  this.appendMessage(m2);
2283
2374
  }
@@ -2535,18 +2626,27 @@ class AparteChatViewport extends HTMLElement {
2535
2626
  this._bottomSpacer.style.height = `${px}px`;
2536
2627
  }
2537
2628
  }
2629
+ // Bound fields, not inline arrows: a custom element is re-connected every
2630
+ // time it is MOVED in the DOM (a portal, a dialog, a framework re-parenting),
2631
+ // so `_setupEventListeners` runs again each time. An inline arrow can never
2632
+ // be handed to `removeEventListener`, so it just accumulates — one branch
2633
+ // click then ran N handlers, N active-path re-renders and N storage writes
2634
+ // through the conversation controller. The window listeners next to these
2635
+ // were always removed properly; these two, attached to `this`, were not.
2636
+ _onScrollBtnClick = () => {
2637
+ this._isAutoScrollEnabled = true;
2638
+ this._smoothScrollToBottom();
2639
+ this._updateScrollButton();
2640
+ };
2641
+ _onBranchNavigate = (e2) => {
2642
+ const evt = e2;
2643
+ evt.stopPropagation();
2644
+ this.navigateBranch(evt.detail.messageId, evt.detail.direction);
2645
+ };
2538
2646
  _setupEventListeners() {
2539
2647
  this._container?.addEventListener("scroll", this._handleScroll, { passive: true });
2540
- this._scrollBtn?.addEventListener("click", () => {
2541
- this._isAutoScrollEnabled = true;
2542
- this._smoothScrollToBottom();
2543
- this._updateScrollButton();
2544
- });
2545
- this.addEventListener("aparte-branch-navigate", (e2) => {
2546
- const evt = e2;
2547
- evt.stopPropagation();
2548
- this.navigateBranch(evt.detail.messageId, evt.detail.direction);
2549
- });
2648
+ this._scrollBtn?.addEventListener("click", this._onScrollBtnClick);
2649
+ this.addEventListener("aparte-branch-navigate", this._onBranchNavigate);
2550
2650
  }
2551
2651
  _setupObservers() {
2552
2652
  this._resizeObserver = new ResizeObserver(() => {
@@ -2592,6 +2692,41 @@ class AparteChatViewport extends HTMLElement {
2592
2692
  _scrollToBottom() {
2593
2693
  if (!this._container) return;
2594
2694
  this._container.scrollTop = this._container.scrollHeight;
2695
+ this._settleAtBottom(4);
2696
+ }
2697
+ /**
2698
+ * Confirm over the next few frames that we actually reached the bottom.
2699
+ *
2700
+ * One assignment is not enough, and the reason is measured rather than guessed.
2701
+ * A timeline of a streamed turn on Safari (framework mode) recorded the content
2702
+ * settling in TWO layout passes — 1118 → 1121 → 1152 px. `scrollTop =
2703
+ * scrollHeight` ran against the middle one, clamped to that layout's max (603),
2704
+ * and nothing ran afterwards: the last 31px never closed. Auto-follow stayed
2705
+ * armed the whole time, so the component was not disarmed — it was SATISFIED.
2706
+ * `_isAtBottom()` answers "yes" for any gap under `_scrollThreshold` (50), which
2707
+ * is the right rule for keeping auto-follow armed and the wrong one as a
2708
+ * definition of "anchored".
2709
+ *
2710
+ * Ruled out on the way here, so nobody pays for it twice: not a WebKit
2711
+ * padding-accounting difference (a probe writing `scrollTop = 1e7` reached
2712
+ * exactly `scrollHeight - clientHeight`), not a missing `characterData`
2713
+ * mutation, and not a child resize a ResizeObserver could see.
2714
+ *
2715
+ * A BOUNDED retry, not one corrective frame: a single frame lands on the same
2716
+ * stale layout and was measured leaving a wider gap than doing nothing. Bounded
2717
+ * so it always terminates; re-reads `_isAutoScrollEnabled` every frame so a
2718
+ * reader who scrolls away mid-settle is left alone; stops as soon as the gap is
2719
+ * closed, so the common case costs one frame that does nothing.
2720
+ */
2721
+ _settleAtBottom(framesLeft) {
2722
+ if (framesLeft <= 0) return;
2723
+ requestAnimationFrame(() => {
2724
+ if (!this._container || !this._isAutoScrollEnabled) return;
2725
+ const max = this._container.scrollHeight - this._container.clientHeight;
2726
+ if (max - this._container.scrollTop <= 1) return;
2727
+ this._container.scrollTop = max;
2728
+ this._settleAtBottom(framesLeft - 1);
2729
+ });
2595
2730
  }
2596
2731
  _smoothScrollToBottom() {
2597
2732
  if (!this._container) return;
@@ -2684,7 +2819,7 @@ class AparteChatViewport extends HTMLElement {
2684
2819
  * Cap the number of rendered bubbles in the DOM (perf ceiling only).
2685
2820
  *
2686
2821
  * Drops the oldest `<aparte-chat-bubble>` elements beyond `_maxRenderedBubbles`
2687
- * from the DOM. It **never** touches the MessageRepository — the conversation
2822
+ * from the DOM. It **never** touches the AparteMessageRepository — the conversation
2688
2823
  * model and its persistence snapshot stay complete (retention/eviction is a
2689
2824
  * consumer/persistence concern, not the viewport's). No-op when a framework
2690
2825
  * owns the DOM.
@@ -2703,11 +2838,13 @@ class AparteChatViewport extends HTMLElement {
2703
2838
  if (this._warnedMaxMessagesDeprecation) return;
2704
2839
  this._warnedMaxMessagesDeprecation = true;
2705
2840
  console.warn(
2706
- "[Aparte] `maxMessages` / `max-messages` on aparte-chat-viewport is deprecated: it used to silently evict messages from the conversation model. It now only caps rendered bubbles in the DOM — use `maxRenderedBubbles` / `max-rendered-bubbles`. For actual history retention, configure it on your ConversationManager instead."
2841
+ "[Aparte] `maxMessages` / `max-messages` on aparte-chat-viewport is deprecated: it used to silently evict messages from the conversation model. It now only caps rendered bubbles in the DOM — use `maxRenderedBubbles` / `max-rendered-bubbles`. For actual history retention, configure it on your AparteConversationManager instead."
2707
2842
  );
2708
2843
  }
2709
2844
  _cleanup() {
2710
2845
  this._container?.removeEventListener("scroll", this._handleScroll);
2846
+ this._scrollBtn?.removeEventListener("click", this._onScrollBtnClick);
2847
+ this.removeEventListener("aparte-branch-navigate", this._onBranchNavigate);
2711
2848
  this._resizeObserver?.disconnect();
2712
2849
  this._mutationObserver?.disconnect();
2713
2850
  this._resizeObserver = null;
@@ -2727,6 +2864,8 @@ class AparteComposer extends HTMLElement {
2727
2864
  _attachments = [];
2728
2865
  _listeners = /* @__PURE__ */ new Map();
2729
2866
  _panelActive = false;
2867
+ /** What the send button means while a panel is up — see `showPanel`. */
2868
+ _panelMode = "submit";
2730
2869
  _panelSubmitEnabled = false;
2731
2870
  _panelOnSubmit = null;
2732
2871
  // Internal bus events that represent an observable STATE change — these are
@@ -2743,8 +2882,25 @@ class AparteComposer extends HTMLElement {
2743
2882
  _onMessageStart = this._handleMessageStart.bind(this);
2744
2883
  _onMessageDone = this._handleMessageDone.bind(this);
2745
2884
  /** Config governing THIS composer (nearest instance boundary, else global). */
2746
- _cfg = AparteConfig;
2747
- _configUnsub = null;
2885
+ /**
2886
+ * Resolved LIVE, not cached at connect.
2887
+ *
2888
+ * A wrapper runs `AparteChatHost.bind()` — which calls `attachConfig` — from its
2889
+ * POST-mount hook, so this element connects BEFORE the boundary exists. Caching
2890
+ * here latched the global config forever: an instance `config` carrying an RTL
2891
+ * locale flipped the transcript and not the composer, and the
2892
+ * `requireModelSelection` gate was read off the wrong object. `aparte-chat-bubble`
2893
+ * has always resolved live, and its JSDoc names this exact race.
2894
+ */
2895
+ get _cfg() {
2896
+ return resolveConfig(this);
2897
+ }
2898
+ /** Only OUR config: a change on another chat's instance must not touch us. */
2899
+ _onConfigChangeEvent = (e2) => {
2900
+ const detail = e2.detail;
2901
+ if (detail?.config && detail.config !== this._cfg) return;
2902
+ this._onConfigChange();
2903
+ };
2748
2904
  /** True while `requireModelSelection` is on AND no model is selected — blocks send. */
2749
2905
  _modelGated = false;
2750
2906
  _onConfigChange = () => {
@@ -2776,8 +2932,7 @@ class AparteComposer extends HTMLElement {
2776
2932
  window.addEventListener("aparte-message-done", this._onMessageDone);
2777
2933
  window.addEventListener("aparte-message-error", this._onMessageDone);
2778
2934
  window.addEventListener("aparte-message-aborted", this._onMessageDone);
2779
- this._cfg = resolveConfig(this);
2780
- this._configUnsub = this._cfg.subscribe(this._onConfigChange);
2935
+ window.addEventListener("aparte-config-change", this._onConfigChangeEvent);
2781
2936
  this._evaluateModelGate();
2782
2937
  this._applyDirection();
2783
2938
  }
@@ -2786,8 +2941,7 @@ class AparteComposer extends HTMLElement {
2786
2941
  window.removeEventListener("aparte-message-done", this._onMessageDone);
2787
2942
  window.removeEventListener("aparte-message-error", this._onMessageDone);
2788
2943
  window.removeEventListener("aparte-message-aborted", this._onMessageDone);
2789
- this._configUnsub?.();
2790
- this._configUnsub = null;
2944
+ window.removeEventListener("aparte-config-change", this._onConfigChangeEvent);
2791
2945
  this._listeners.clear();
2792
2946
  }
2793
2947
  attributeChangedCallback(name, _old, value) {
@@ -2863,11 +3017,29 @@ class AparteComposer extends HTMLElement {
2863
3017
  this._attachments = [];
2864
3018
  this._emit("attachments-change", { attachments: [] });
2865
3019
  }
2866
- /** Inject a panel into the composer, hiding the text input. The send button calls onSubmit when clicked. */
3020
+ /**
3021
+ * Inject a panel into the composer. The send button calls `onSubmit` when clicked.
3022
+ *
3023
+ * While a panel is up, the composer is answering a QUESTION, not composing a
3024
+ * message — so the affordances that lead nowhere go away with the text input:
3025
+ * the attachment picker above all, which stayed clickable while the user was
3026
+ * being asked something ("on voyait encore l'icône de upload", reported from a
3027
+ * real session). Ratified decision #8: an affordance nothing can honour is not
3028
+ * rendered.
3029
+ *
3030
+ * What STAYS, deliberately: the attachments strip, because pending attachments
3031
+ * are the user's state and not an action to offer — hiding them would look like
3032
+ * losing them; and the toolbar, because switching model still does something.
3033
+ *
3034
+ * Declared with an attribute + CSS rather than the inline `style.display` this
3035
+ * used to set on a child: an attribute is themeable, is visible to a consumer's
3036
+ * own rules, and does not clobber a `display` the consumer had set (the restore
3037
+ * wrote `''`, not the previous value).
3038
+ */
2867
3039
  showPanel(panel, options) {
2868
3040
  this.hidePanel();
2869
3041
  const inputEl = this.querySelector("aparte-composer-input");
2870
- if (inputEl) inputEl.style.display = "none";
3042
+ this.setAttribute("data-panel-active", "");
2871
3043
  panel.dataset["apartePanel"] = "true";
2872
3044
  if (inputEl) {
2873
3045
  inputEl.insertAdjacentElement("afterend", panel);
@@ -2876,26 +3048,35 @@ class AparteComposer extends HTMLElement {
2876
3048
  }
2877
3049
  this._panelActive = true;
2878
3050
  this._panelSubmitEnabled = options?.submitEnabled ?? false;
3051
+ this._panelMode = options?.mode ?? "submit";
2879
3052
  this._panelOnSubmit = options?.onSubmit ?? null;
2880
- this._emit("panel-change", { active: true, submitEnabled: this._panelSubmitEnabled });
3053
+ this._emit("panel-change", { active: true, submitEnabled: this._panelSubmitEnabled, mode: this._panelMode });
2881
3054
  }
2882
- /** Remove the panel and restore the text input. */
3055
+ /** Remove the panel and restore the composer's own controls. */
2883
3056
  hidePanel() {
2884
3057
  const existing = this.querySelector("[data-aparte-panel]");
2885
3058
  if (existing) existing.remove();
2886
- const inputEl = this.querySelector("aparte-composer-input");
2887
- if (inputEl) inputEl.style.display = "";
3059
+ this.removeAttribute("data-panel-active");
2888
3060
  this._panelActive = false;
2889
3061
  this._panelSubmitEnabled = false;
3062
+ this._panelMode = "submit";
2890
3063
  this._panelOnSubmit = null;
2891
- this._emit("panel-change", { active: false, submitEnabled: false });
3064
+ this._emit("panel-change", { active: false, submitEnabled: false, mode: "submit" });
2892
3065
  this.focus();
2893
3066
  }
2894
- /** Update the send button enabled state while a panel is active. */
2895
- setPanelSubmitEnabled(enabled) {
3067
+ /**
3068
+ * Update the send button's state while a panel is active.
3069
+ *
3070
+ * `mode` moves with it because both change on the same event — answering the
3071
+ * question you are on can enable the button AND turn it from "advance" into
3072
+ * "submit" (when it was the last one), and two separate calls would flash a
3073
+ * wrong icon between them.
3074
+ */
3075
+ setPanelSubmitEnabled(enabled, mode) {
2896
3076
  if (!this._panelActive) return;
2897
3077
  this._panelSubmitEnabled = enabled;
2898
- this._emit("panel-change", { active: true, submitEnabled: enabled });
3078
+ if (mode) this._panelMode = mode;
3079
+ this._emit("panel-change", { active: true, submitEnabled: enabled, mode: this._panelMode });
2899
3080
  }
2900
3081
  get panelActive() {
2901
3082
  return this._panelActive;
@@ -2986,7 +3167,36 @@ class AparteComposer extends HTMLElement {
2986
3167
  * Without this filter, streaming in one chat flips every composer's state. */
2987
3168
  _isForThisComposer(e2) {
2988
3169
  const evtTargetId = e2.detail?.targetId;
2989
- return !evtTargetId || !this.targetId || evtTargetId === this.targetId;
3170
+ return !evtTargetId || !this._ownTargetId() || evtTargetId === this._ownTargetId();
3171
+ }
3172
+ /**
3173
+ * Which chat this composer belongs to: its `target` attribute, or the id of the
3174
+ * chat host above it.
3175
+ *
3176
+ * All four wrappers set `target` themselves, so the attribute alone identified a
3177
+ * composer there. In RAW core — the documented quick start, where the markup is
3178
+ * hand-written — nothing sets it, so `!this.targetId` was true and this composer
3179
+ * accepted every chat's lifecycle events: on a two-chat page, one chat's Stop
3180
+ * tore down the other's open elicitation panel while its tool call kept waiting,
3181
+ * i.e. the question vanished and the turn hung.
3182
+ *
3183
+ * Found by a two-chat test written for the elicitation presenter, which is the
3184
+ * only reason it surfaced: raw core with two chats is a shape nothing exercised.
3185
+ * The hosts matched are the ones `aparte-chat-bubble._resolveTargetId()` matches,
3186
+ * for the reason written there — Angular's wrapper root IS `<aparte-chat>`, the
3187
+ * other three render a `[data-aparte-chat]` div.
3188
+ */
3189
+ _ownTargetId() {
3190
+ const attr = this.targetId;
3191
+ if (attr) return attr;
3192
+ let el = this.parentElement;
3193
+ while (el) {
3194
+ const tag = el.tagName?.toLowerCase();
3195
+ const isHost = tag === "aparte-chat" || tag === "aparte-chat-component" || el.hasAttribute?.("data-aparte-chat");
3196
+ if (isHost && el.id) return el.id;
3197
+ el = el.parentElement;
3198
+ }
3199
+ return void 0;
2990
3200
  }
2991
3201
  _handleMessageStart(e2) {
2992
3202
  if (!this._isForThisComposer(e2)) return;
@@ -3093,7 +3303,7 @@ class AparteComposerInput extends HTMLElement {
3093
3303
  _render() {
3094
3304
  if (this.querySelector(".aparte-ci-editor")) return;
3095
3305
  const disabled = this.hasAttribute("disabled") || this._getRoot()?.disabled || false;
3096
- const placeholder = this._escapeAttr(this._getPlaceholder());
3306
+ const placeholder = escapeAttr(this._getPlaceholder());
3097
3307
  this.innerHTML = `<div
3098
3308
  class="aparte-ci-editor"
3099
3309
  contenteditable="${!disabled}"
@@ -3217,9 +3427,6 @@ class AparteComposerInput extends HTMLElement {
3217
3427
  this._editor.setAttribute("aria-disabled", String(disabled));
3218
3428
  }
3219
3429
  /** Escape a value before it lands in a double-quoted HTML attribute. */
3220
- _escapeAttr(str) {
3221
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
3222
- }
3223
3430
  }
3224
3431
  if (!customElements.get("aparte-composer-input")) {
3225
3432
  customElements.define("aparte-composer-input", AparteComposerInput);
@@ -3250,8 +3457,8 @@ class AparteComposerSend extends HTMLElement {
3250
3457
  const disabled = !root || root.disabled || root.value.trim() === "";
3251
3458
  this.innerHTML = `<button
3252
3459
  class="aparte-cs-button aparte-send-button"
3253
- aria-label="${label}"
3254
- title="${label}"
3460
+ aria-label="${escapeAttr(label)}"
3461
+ title="${escapeAttr(label)}"
3255
3462
  ${disabled ? "disabled" : ""}
3256
3463
  >${icon}</button>`;
3257
3464
  this._button = this.querySelector(".aparte-cs-button");
@@ -3276,12 +3483,14 @@ class AparteComposerSend extends HTMLElement {
3276
3483
  root._on("attachments-change", () => this._syncState())
3277
3484
  );
3278
3485
  this._unsubscribes.push(
3279
- root._on("panel-change", ({ active, submitEnabled }) => {
3486
+ root._on("panel-change", ({ active, submitEnabled, mode }) => {
3280
3487
  if (!this._button) return;
3281
3488
  if (active) {
3282
3489
  this._button.disabled = !submitEnabled;
3283
- this._button.innerHTML = this._getSendIcon();
3284
- const label = resolveConfig(this).t("submitButton") || "Submit";
3490
+ const cfg = resolveConfig(this);
3491
+ const advancing = mode === "advance";
3492
+ this._button.innerHTML = advancing ? cfg.getIcon("nextBranch") : this._getSubmitIcon();
3493
+ const label = advancing ? cfg.t("elicitationNext") || "Next" : cfg.t("submitButton") || "Submit";
3285
3494
  this._button.setAttribute("aria-label", label);
3286
3495
  this._button.setAttribute("title", label);
3287
3496
  this._button.classList.remove("is-streaming");
@@ -3324,6 +3533,13 @@ class AparteComposerSend extends HTMLElement {
3324
3533
  this._syncState();
3325
3534
  }
3326
3535
  }
3536
+ /**
3537
+ * The icon for submitting an ANSWER, which is not the same act as sending a
3538
+ * message — one button, two meanings, and it has to say which one it is.
3539
+ */
3540
+ _getSubmitIcon() {
3541
+ return resolveConfig(this).getIcon("check");
3542
+ }
3327
3543
  _getSendIcon() {
3328
3544
  return resolveConfig(this).getIcon("send") || "Send";
3329
3545
  }
@@ -3358,8 +3574,8 @@ class AparteComposerCancel extends HTMLElement {
3358
3574
  const icon = this._getStopIcon();
3359
3575
  this.innerHTML = `<button
3360
3576
  class="aparte-cc-button"
3361
- aria-label="${label}"
3362
- title="${label}"
3577
+ aria-label="${escapeAttr(label)}"
3578
+ title="${escapeAttr(label)}"
3363
3579
  hidden
3364
3580
  >${icon}</button>`;
3365
3581
  this._button = this.querySelector(".aparte-cc-button");
@@ -3426,9 +3642,9 @@ class AparteComposerAttachments extends HTMLElement {
3426
3642
  if (file.type.startsWith("image/")) {
3427
3643
  const url = URL.createObjectURL(file);
3428
3644
  this._objectUrls.push(url);
3429
- return `<div class="aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${url}" alt="${name}" /><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
3645
+ return `<div class="aparte-thumb aparte-thumb--image" title="${escapeAttr(name)}"><img class="aparte-thumb__img" src="${escapeAttr(url)}" alt="${escapeAttr(name)}" /><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
3430
3646
  }
3431
- return `<div class="aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${this._escape(this._ext(file.name))}</span><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
3647
+ return `<div class="aparte-thumb aparte-thumb--file" title="${escapeAttr(name)}"><span class="aparte-thumb__ext">${this._escape(this._ext(file.name))}</span><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
3432
3648
  }).join("");
3433
3649
  this.querySelectorAll(".aparte-thumb__remove").forEach((btn, i2) => {
3434
3650
  btn.addEventListener("click", (e2) => {
@@ -3507,8 +3723,8 @@ class AparteComposerAddAttachment extends HTMLElement {
3507
3723
  const disabled = this.hasAttribute("disabled") || this._getRoot()?.disabled || false;
3508
3724
  this.innerHTML = `<button
3509
3725
  class="aparte-caa-button aparte-action-button"
3510
- aria-label="${label}"
3511
- title="${label}"
3726
+ aria-label="${escapeAttr(label)}"
3727
+ title="${escapeAttr(label)}"
3512
3728
  type="button"
3513
3729
  ${disabled ? "disabled" : ""}
3514
3730
  >${icon}</button>`;
@@ -3618,7 +3834,7 @@ class AparteComposerAction extends HTMLElement {
3618
3834
  }
3619
3835
  _render() {
3620
3836
  if (this.querySelector(".aparte-cact-button")) return;
3621
- const label = this._escapeAttr(this.getAttribute("label") ?? "");
3837
+ const label = escapeAttr(this.getAttribute("label") ?? "");
3622
3838
  const icon = this._resolveIcon(this.getAttribute("icon") ?? "");
3623
3839
  const disabled = this.hasAttribute("disabled") || this._getRoot()?.disabled || false;
3624
3840
  this.innerHTML = `<button
@@ -3658,9 +3874,6 @@ class AparteComposerAction extends HTMLElement {
3658
3874
  return resolveConfig(this).getIcon(icon) ?? icon;
3659
3875
  }
3660
3876
  /** Escape a value before it lands in a double-quoted HTML attribute. */
3661
- _escapeAttr(str) {
3662
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
3663
- }
3664
3877
  }
3665
3878
  if (!customElements.get("aparte-composer-action")) {
3666
3879
  customElements.define("aparte-composer-action", AparteComposerAction);
@@ -3776,8 +3989,8 @@ class AparteConversationList extends HTMLElement {
3776
3989
  class="aparte-conv-item__archive"
3777
3990
  type="button"
3778
3991
  data-archive-id="${escapedId}"
3779
- data-archive-action="${archiveAction}"
3780
- aria-label="${archiveAriaLabel}"
3992
+ data-archive-action="${escapeAttr(archiveAction)}"
3993
+ aria-label="${escapeAttr(archiveAriaLabel)}"
3781
3994
  tabindex="0"
3782
3995
  >${archiveIcon}</button>
3783
3996
  <button
@@ -3856,14 +4069,49 @@ class AparteConversationList extends HTMLElement {
3856
4069
  if (!customElements.get("aparte-conversation-list")) customElements.define("aparte-conversation-list", AparteConversationList);
3857
4070
  class AparteElicitation extends HTMLElement {
3858
4071
  _pending = null;
3859
- _onTurnEnd = () => this._cancelPending();
4072
+ /**
4073
+ * A turn ended — cancel the open question only if it was OUR turn.
4074
+ *
4075
+ * These two listeners sit on `window` and had no instance filter at all, so a
4076
+ * Stop (or an error) in one chat cancelled the question a DIFFERENT chat was
4077
+ * waiting on — and that chat's model was told the user had refused a question
4078
+ * the user was still looking at. Same defect as the `compact()` handler that
4079
+ * emptied both chats, which its four sibling handlers in `AparteClient` already
4080
+ * guarded against.
4081
+ *
4082
+ * The leniency rule is the composer's, deliberately: an event with no
4083
+ * `targetId` is for everyone (a single-chat app never sets one), and a chat we
4084
+ * cannot identify accepts everything rather than becoming deaf. Only a
4085
+ * MISMATCH is ignored.
4086
+ */
4087
+ _onTurnEnd = (e2) => {
4088
+ const evtTargetId = e2.detail?.targetId;
4089
+ const own = this._pendingTargetId();
4090
+ if (evtTargetId && own && evtTargetId !== own) return;
4091
+ this._cancelPending();
4092
+ };
3860
4093
  connectedCallback() {
3861
4094
  this.style.display = "none";
3862
- this._injectStyles();
3863
4095
  resolveConfig(this).setElicitationPresenter(this._present);
3864
4096
  window.addEventListener("aparte-message-aborted", this._onTurnEnd);
3865
4097
  window.addEventListener("aparte-message-error", this._onTurnEnd);
3866
4098
  }
4099
+ /**
4100
+ * The boundary above us appeared, changed, or went away — move the
4101
+ * registration with it.
4102
+ *
4103
+ * `connectedCallback` alone is not enough and cannot be: registering is a
4104
+ * WRITE, and under all four wrappers it happens before `attachConfig` runs, so
4105
+ * it lands on the global singleton. `requestUserInput()` then resolves the
4106
+ * instance config, finds nothing, and answers the model `cancel` — the model
4107
+ * hears the user refuse a question the user never saw.
4108
+ *
4109
+ * See {@link AparteConfigAware}.
4110
+ */
4111
+ aparteConfigChanged(next, previous) {
4112
+ if (previous.getElicitationPresenter() === this._present) previous.setElicitationPresenter(null);
4113
+ next.setElicitationPresenter(this._present);
4114
+ }
3867
4115
  disconnectedCallback() {
3868
4116
  const cfg = resolveConfig(this);
3869
4117
  if (cfg.getElicitationPresenter() === this._present) cfg.setElicitationPresenter(null);
@@ -3891,22 +4139,26 @@ class AparteElicitation extends HTMLElement {
3891
4139
  }
3892
4140
  request.signal.addEventListener("abort", () => settle({ action: "cancel" }), { once: true });
3893
4141
  }
3894
- const panel = buildElicitationPanel(request.message, request.schema, () => {
3895
- composer.setPanelSubmitEnabled(panel.isComplete());
3896
- });
3897
- const footer = document.createElement("div");
3898
- footer.className = "aparte-elic-footer";
4142
+ const cfg = resolveConfig(this);
4143
+ const panel = runWithConfig(cfg, () => buildElicitationPanel(request.message, request.schema, () => {
4144
+ composer.setPanelSubmitEnabled(panel.canProceed(), panel.mode());
4145
+ }));
3899
4146
  const skip = document.createElement("button");
3900
4147
  skip.type = "button";
3901
4148
  skip.className = "aparte-elic-skip";
3902
- skip.textContent = "Skip";
4149
+ skip.textContent = cfg.t("elicitationSkip");
3903
4150
  skip.addEventListener("click", () => settle({ action: "decline" }));
3904
- footer.appendChild(skip);
3905
- panel.el.appendChild(footer);
4151
+ panel.dismiss.appendChild(skip);
3906
4152
  this._pending = { settle, composer };
3907
4153
  composer.showPanel(panel.el, {
3908
- submitEnabled: panel.isComplete(),
4154
+ submitEnabled: panel.canProceed(),
4155
+ mode: panel.mode(),
3909
4156
  onSubmit: () => {
4157
+ if (panel.mode() === "advance") {
4158
+ panel.proceed();
4159
+ composer.setPanelSubmitEnabled(panel.canProceed(), panel.mode());
4160
+ return;
4161
+ }
3910
4162
  if (panel.isComplete()) settle({ action: "accept", content: panel.getContent() });
3911
4163
  }
3912
4164
  });
@@ -3916,43 +4168,56 @@ class AparteElicitation extends HTMLElement {
3916
4168
  _cancelPending() {
3917
4169
  this._pending?.settle({ action: "cancel" });
3918
4170
  }
4171
+ /**
4172
+ * The host id of the chat whose composer holds the open panel.
4173
+ *
4174
+ * Walks up from the composer rather than from `this`, because the panel lives in
4175
+ * the composer and that is what the turn belongs to. Matches the hosts
4176
+ * `aparte-chat-bubble._resolveTargetId()` matches, for the reason written there:
4177
+ * Angular's wrapper root IS the `<aparte-chat>` element, while the plain-root
4178
+ * wrappers render a `[data-aparte-chat]` div instead, so matching only the tag
4179
+ * resolves `undefined` on three wrappers out of four.
4180
+ */
4181
+ _pendingTargetId() {
4182
+ let el = this._pending?.composer ?? null;
4183
+ while (el) {
4184
+ const tag = el.tagName?.toLowerCase();
4185
+ const isHost = tag === "aparte-chat" || tag === "aparte-chat-component" || el.hasAttribute?.("data-aparte-chat");
4186
+ if (isHost && el.id) return el.id;
4187
+ el = el.parentElement;
4188
+ }
4189
+ return void 0;
4190
+ }
4191
+ /**
4192
+ * The composer to present in: the nearest one in an ancestor subtree, and
4193
+ * nothing else.
4194
+ *
4195
+ * There used to be a `document.querySelector('aparte-composer')` fallback, which
4196
+ * is the "first chat on the page" bug this repo has now fixed in four other
4197
+ * places: on a page with two chats, an elicitation that could not find its own
4198
+ * composer mounted its panel in the OTHER chat's — so one conversation's question
4199
+ * appeared under the other conversation, and answering it resolved a tool call
4200
+ * belonging to a chat the user was not looking at.
4201
+ *
4202
+ * Returning `null` instead resolves `cancel`, which is honest: nothing was
4203
+ * shown, so nothing was answered. The warning names the fix, because this is a
4204
+ * setup mistake and only the developer can correct it — the guide's own example
4205
+ * puts `<aparte-elicitation>` inside `<aparte-chat>`.
4206
+ */
3919
4207
  _getComposer() {
3920
4208
  let node = this.parentElement;
3921
4209
  while (node) {
3922
4210
  const composer = node.querySelector("aparte-composer");
3923
4211
  if (composer && typeof composer.showPanel === "function") return composer;
4212
+ const tag = node.tagName?.toLowerCase();
4213
+ const isChatBoundary = tag === "aparte-chat" || tag === "aparte-chat-component" || node.hasAttribute?.("data-aparte-chat");
4214
+ if (isChatBoundary) break;
3924
4215
  node = node.parentElement;
3925
4216
  }
3926
- const doc = document.querySelector("aparte-composer");
3927
- return doc && typeof doc.showPanel === "function" ? doc : null;
3928
- }
3929
- _injectStyles() {
3930
- const id = "aparte-elicitation-styles";
3931
- if (document.getElementById(id)) return;
3932
- const style = document.createElement("style");
3933
- style.id = id;
3934
- style.textContent = `
3935
- .aparte-elic-panel { display:flex; flex-direction:column; gap:6px; padding:6px 4px; width:100%; box-sizing:border-box; max-height:50vh; overflow-y:auto; }
3936
- .aparte-elic-message { margin:0 6px 6px; font-size:.82rem; font-weight:600; color:var(--aparte-text); line-height:1.4; }
3937
- .aparte-elic-field + .aparte-elic-field { margin-top:8px; padding-top:8px; border-top:1px solid var(--aparte-border, rgba(128,128,128,0.12)); }
3938
- .aparte-elic-title { margin:0 6px 4px; font-size:.8rem; font-weight:600; color:var(--aparte-text); }
3939
- .aparte-elic-desc { margin:0 6px 6px; font-size:.76rem; color:var(--aparte-text-muted, rgba(128,128,128,0.75)); }
3940
- .aparte-elic-options { display:flex; flex-direction:column; gap:1px; }
3941
- .aparte-elic-option { display:flex; align-items:flex-start; gap:10px; padding:7px 10px; border-radius:8px; cursor:pointer; border:1px solid transparent; transition:background .12s,border-color .12s; }
3942
- .aparte-elic-option:hover { background:var(--aparte-surface-2, rgba(128,128,128,0.08)); border-color:var(--aparte-border, rgba(128,128,128,0.15)); }
3943
- .aparte-elic-control { margin-top:3px; flex-shrink:0; accent-color:var(--aparte-primary, #6366f1); width:15px; height:15px; cursor:pointer; }
3944
- .aparte-elic-option-body { display:flex; flex-direction:column; gap:2px; flex:1; }
3945
- .aparte-elic-option-title { font-size:.875rem; font-weight:500; color:var(--aparte-text, inherit); line-height:1.4; }
3946
- .aparte-elic-option-desc { font-size:.78rem; color:var(--aparte-text-muted, rgba(128,128,128,0.75)); line-height:1.4; }
3947
- .aparte-elic-other-input, .aparte-elic-text { margin-top:4px; width:100%; padding:6px 8px; border:1px solid var(--aparte-border, rgba(128,128,128,0.2)); border-radius:6px; font-size:.875rem; background:var(--aparte-surface-2, rgba(128,128,128,0.06)); color:var(--aparte-text, inherit); outline:none; box-sizing:border-box; font-family:inherit; }
3948
- textarea.aparte-elic-text { min-height:64px; resize:vertical; }
3949
- .aparte-elic-other-input:focus, .aparte-elic-text:focus { border-color:var(--aparte-primary, #6366f1); }
3950
- .aparte-elic-option--recommended { border-color:var(--aparte-primary, #6366f1) !important; background:color-mix(in srgb, var(--aparte-primary, #6366f1) 6%, transparent); }
3951
- .aparte-elic-footer { display:flex; justify-content:flex-end; margin-top:6px; }
3952
- .aparte-elic-skip { border:none; background:transparent; color:var(--aparte-text-muted, rgba(128,128,128,0.75)); font-size:.8rem; cursor:pointer; padding:4px 8px; border-radius:6px; font-family:inherit; }
3953
- .aparte-elic-skip:hover { background:var(--aparte-surface-2, rgba(128,128,128,0.08)); color:var(--aparte-text); }
3954
- `;
3955
- document.head.appendChild(style);
4217
+ console.warn(
4218
+ "[aparte-elicitation] No <aparte-composer> in this element's subtree, so the request was cancelled — the model will read that as a refusal. Move <aparte-elicitation> inside the <aparte-chat> it belongs to. It is deliberately NOT borrowing another chat's composer: on a page with two chats that put the question under the wrong one."
4219
+ );
4220
+ return null;
3956
4221
  }
3957
4222
  }
3958
4223
  if (typeof customElements !== "undefined" && !customElements.get("aparte-elicitation")) {
@@ -3969,13 +4234,20 @@ function registerAllComponents() {
3969
4234
  }
3970
4235
  export {
3971
4236
  A as APARTE_CONVERSATION_SCHEMA_VERSION,
3972
- a as APARTE_HOST_ATTR,
4237
+ a as APARTE_DEFAULT_BUBBLE_ACTIONS,
4238
+ b as APARTE_DEFAULT_HOST_HANDLERS,
4239
+ c as APARTE_DEFAULT_ICON_FALLBACKS,
4240
+ d as APARTE_DEFAULT_LOCALE,
4241
+ e as APARTE_DEFAULT_SKELETON_FALLBACKS,
4242
+ f as APARTE_DEFAULT_UI_EVENTS,
4243
+ g as APARTE_HOST_ATTR,
4244
+ h as AparteBackendTransport,
3973
4245
  AparteChat,
3974
4246
  AparteChatBubble,
3975
- b as AparteChatHost,
4247
+ i as AparteChatHost,
3976
4248
  AparteChatStatus,
3977
4249
  AparteChatViewport,
3978
- c as AparteClient,
4250
+ j as AparteClient,
3979
4251
  AparteComposer,
3980
4252
  AparteComposerAction,
3981
4253
  AparteComposerAddAttachment,
@@ -3984,54 +4256,57 @@ export {
3984
4256
  AparteComposerInput,
3985
4257
  AparteComposerSend,
3986
4258
  AparteComposerToolbar,
3987
- AparteConfig,
3988
- e as AparteConfigClass,
3989
- f as AparteConversationController,
4259
+ k as AparteConfig,
4260
+ l as AparteConversationController,
3990
4261
  AparteConversationList,
4262
+ m as AparteConversationManager,
4263
+ n as AparteDirectTransport,
3991
4264
  AparteElicitation,
3992
- g as AparteError,
3993
- h as AparteErrorCode,
4265
+ o as AparteError,
4266
+ p as AparteErrorCode,
4267
+ AparteMessageRepository,
3994
4268
  AparteOptgroup,
3995
4269
  AparteOption,
3996
4270
  AparteProgressSpinner,
3997
4271
  AparteSelect,
3998
- i as AparteStreamParser,
3999
- B as BackendTransport,
4000
- C as ConversationManager,
4001
- D as DEFAULT_BUBBLE_ACTIONS,
4002
- j as DEFAULT_HOST_HANDLERS,
4003
- k as DEFAULT_ICON_FALLBACKS,
4004
- l as DEFAULT_LOCALE,
4005
- m as DEFAULT_SKELETON_FALLBACKS,
4006
- n as DEFAULT_UI_EVENTS,
4007
- o as DirectTransport,
4008
- MessageRepository,
4009
- p as applyElementProps,
4010
- q as attachConfig,
4272
+ r as AparteStreamParser,
4273
+ s as aparteGlobalConfig,
4274
+ t as applyElementProps,
4275
+ u as attachConfig,
4011
4276
  buildElicitationPanel,
4012
- s as collectRendererStyles,
4013
- t as contentToText,
4014
- u as contextConfig,
4015
- v as createAparteChatHandler,
4016
- w as createStreamAdapter,
4017
- x as defaultSanitizer,
4018
- y as deriveArtifactKind,
4019
- z as detachConfig,
4020
- E as filesToAttachments,
4277
+ w as collectRendererStyles,
4278
+ x as contentToText,
4279
+ y as contextConfig,
4280
+ z as createAparteChatHandler,
4281
+ B as createStreamAdapter,
4282
+ cssEscape,
4283
+ D as declineDefaultRenderers,
4284
+ E as defaultSanitizer,
4285
+ F as deriveArtifactKind,
4286
+ G as detachConfig,
4287
+ escapeAttr,
4288
+ escapeHtml,
4289
+ J as filesToAttachments,
4290
+ K as getAllRenderers,
4021
4291
  getSegmentRenderer,
4292
+ installDefaultRenderersOnce,
4022
4293
  isAwaitingReply,
4023
- H as isFormatAdapter,
4024
- I as isSafeUrl,
4025
- J as parseAparteEventStream,
4026
- K as parseMarkdownToSegments,
4294
+ O as isFormatAdapter,
4295
+ P as isSafeUrl,
4296
+ Q as isSegmentSettled,
4297
+ R as parseAparteEventStream,
4298
+ S as parseMarkdownToSegments,
4027
4299
  populateBubbleFromMessage,
4028
- N as readableToAsyncIterable,
4300
+ U as readableToAsyncIterable,
4029
4301
  registerAllComponents,
4030
- O as registerDefaultRenderers,
4031
- P as registerSegmentRenderer,
4032
- Q as requestUserInput,
4302
+ V as registerDefaultRenderers,
4303
+ W as registerSegmentRenderer,
4304
+ X as requestUserInput,
4033
4305
  resolveConfig,
4306
+ revokeAttachmentUrls,
4034
4307
  runWithConfig,
4035
- T as unregisterSegmentRenderer
4308
+ $ as segmentDuration,
4309
+ a0 as unregisterSegmentRenderer,
4310
+ uuid
4036
4311
  };
4037
4312
  //# sourceMappingURL=index.js.map