@aparte/core 0.19.2 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +6 -1
  2. package/dist/client/aparte-client.d.ts +76 -97
  3. package/dist/client/aparte-client.d.ts.map +1 -1
  4. package/dist/client/stream-adapter.d.ts +7 -13
  5. package/dist/client/stream-adapter.d.ts.map +1 -1
  6. package/dist/client/wire-history.d.ts +4 -5
  7. package/dist/client/wire-history.d.ts.map +1 -1
  8. package/dist/components/bubble/aparte-chat-bubble.d.ts +89 -58
  9. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  10. package/dist/components/bubble/bubble-sync.d.ts +1 -1
  11. package/dist/components/chat/aparte-chat.d.ts +15 -13
  12. package/dist/components/chat/aparte-chat.d.ts.map +1 -1
  13. package/dist/components/chat/glide-gate.d.ts +13 -0
  14. package/dist/components/chat/glide-gate.d.ts.map +1 -0
  15. package/dist/components/composer/aparte-composer-action.d.ts +3 -3
  16. package/dist/components/composer/aparte-composer-add-attachment.d.ts +2 -2
  17. package/dist/components/composer/aparte-composer-cancel.d.ts +1 -1
  18. package/dist/components/composer/aparte-composer-input.d.ts +4 -7
  19. package/dist/components/composer/aparte-composer-input.d.ts.map +1 -1
  20. package/dist/components/composer/aparte-composer-send.d.ts +9 -11
  21. package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
  22. package/dist/components/composer/aparte-composer.d.ts +50 -80
  23. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  24. package/dist/components/context/aparte-context.d.ts +13 -7
  25. package/dist/components/context/aparte-context.d.ts.map +1 -1
  26. package/dist/components/conversation-list/aparte-conversation-list.d.ts +25 -13
  27. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  28. package/dist/components/elicitation/aparte-elicitation.d.ts +15 -22
  29. package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
  30. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts +10 -11
  31. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -1
  32. package/dist/components/sidebar/aparte-sidebar.d.ts +16 -3
  33. package/dist/components/sidebar/aparte-sidebar.d.ts.map +1 -1
  34. package/dist/components/split/aparte-split.d.ts +12 -3
  35. package/dist/components/split/aparte-split.d.ts.map +1 -1
  36. package/dist/components/status/aparte-chat-status.d.ts +5 -3
  37. package/dist/components/status/aparte-chat-status.d.ts.map +1 -1
  38. package/dist/components/suggestions/aparte-suggestions.d.ts +6 -9
  39. package/dist/components/suggestions/aparte-suggestions.d.ts.map +1 -1
  40. package/dist/components/viewport/aparte-chat-viewport.d.ts +76 -69
  41. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  42. package/dist/config/aparte-config.d.ts +169 -141
  43. package/dist/config/aparte-config.d.ts.map +1 -1
  44. package/dist/config/icon-provider.d.ts +19 -2
  45. package/dist/config/icon-provider.d.ts.map +1 -1
  46. package/dist/config/index.d.ts +1 -1
  47. package/dist/config/index.d.ts.map +1 -1
  48. package/dist/config/locale.d.ts +61 -89
  49. package/dist/config/locale.d.ts.map +1 -1
  50. package/dist/config/sanitize.d.ts.map +1 -1
  51. package/dist/conversations/conversation-controller.d.ts +18 -3
  52. package/dist/conversations/conversation-controller.d.ts.map +1 -1
  53. package/dist/conversations/conversation-manager.d.ts +6 -6
  54. package/dist/conversations/conversation-manager.d.ts.map +1 -1
  55. package/dist/conversations/index.d.ts +1 -1
  56. package/dist/conversations/index.d.ts.map +1 -1
  57. package/dist/custom-elements.json +5656 -4759
  58. package/dist/{dialog-triggers-CEazY76J.js → dialog-triggers-3Rk7PuZa.js} +555 -375
  59. package/dist/dialog-triggers-3Rk7PuZa.js.map +1 -0
  60. package/dist/elicitation/approval-panel.d.ts.map +1 -1
  61. package/dist/elicitation/index.d.ts +6 -1
  62. package/dist/elicitation/index.d.ts.map +1 -1
  63. package/dist/elicitation/panel.d.ts +11 -21
  64. package/dist/elicitation/panel.d.ts.map +1 -1
  65. package/dist/elicitation/types.d.ts +19 -27
  66. package/dist/elicitation/types.d.ts.map +1 -1
  67. package/dist/generated/element-attributes.d.ts +16 -10
  68. package/dist/generated/element-attributes.d.ts.map +1 -1
  69. package/dist/host/aparte-chat-host.d.ts +17 -32
  70. package/dist/host/aparte-chat-host.d.ts.map +1 -1
  71. package/dist/icons/extended.d.ts +1 -2
  72. package/dist/icons/extended.d.ts.map +1 -1
  73. package/dist/icons.d.ts +6 -8
  74. package/dist/icons.d.ts.map +1 -1
  75. package/dist/icons.js.map +1 -1
  76. package/dist/index.css +1 -1
  77. package/dist/index.d.ts +13 -10
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +775 -337
  80. package/dist/index.js.map +1 -1
  81. package/dist/index.node.d.ts +7 -4
  82. package/dist/index.node.d.ts.map +1 -1
  83. package/dist/index.node.js +2 -2
  84. package/dist/index.node.js.map +1 -1
  85. package/dist/interop/dialog-triggers.d.ts +2 -2
  86. package/dist/interop/element-props.d.ts +16 -27
  87. package/dist/interop/element-props.d.ts.map +1 -1
  88. package/dist/parsers/aparte-stream-parser.d.ts.map +1 -1
  89. package/dist/primitives/icon/aparte-icon.d.ts.map +1 -1
  90. package/dist/primitives/select/aparte-option.d.ts +3 -2
  91. package/dist/primitives/select/aparte-option.d.ts.map +1 -1
  92. package/dist/primitives/select/aparte-select.d.ts +24 -21
  93. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  94. package/dist/renderers/markdown-stream.d.ts.map +1 -1
  95. package/dist/renderers/segment-renderers.d.ts +11 -17
  96. package/dist/renderers/segment-renderers.d.ts.map +1 -1
  97. package/dist/renderers/segments/code.d.ts.map +1 -1
  98. package/dist/renderers/segments/error.d.ts.map +1 -1
  99. package/dist/renderers/segments/text.d.ts.map +1 -1
  100. package/dist/renderers/segments/thinking.d.ts.map +1 -1
  101. package/dist/renderers/segments/tool-call.d.ts.map +1 -1
  102. package/dist/runtime/message-repository.d.ts +8 -15
  103. package/dist/runtime/message-repository.d.ts.map +1 -1
  104. package/dist/transport/agent-handler.d.ts +2 -0
  105. package/dist/transport/agent-handler.d.ts.map +1 -1
  106. package/dist/transport/backend-handler.d.ts +4 -6
  107. package/dist/transport/backend-handler.d.ts.map +1 -1
  108. package/dist/transport/backend-transport.d.ts +15 -1
  109. package/dist/transport/backend-transport.d.ts.map +1 -1
  110. package/dist/transport/types.d.ts +7 -10
  111. package/dist/transport/types.d.ts.map +1 -1
  112. package/dist/types/chat.d.ts +10 -18
  113. package/dist/types/chat.d.ts.map +1 -1
  114. package/dist/types/element-attributes.d.ts +6 -12
  115. package/dist/types/element-attributes.d.ts.map +1 -1
  116. package/dist/types/element-map.d.ts +6 -19
  117. package/dist/types/element-map.d.ts.map +1 -1
  118. package/dist/types/event-map.d.ts +11 -17
  119. package/dist/types/event-map.d.ts.map +1 -1
  120. package/dist/types/events.d.ts +5 -14
  121. package/dist/types/events.d.ts.map +1 -1
  122. package/dist/types/imperative-api.d.ts +3 -1
  123. package/dist/types/imperative-api.d.ts.map +1 -1
  124. package/dist/types/index.d.ts +4 -4
  125. package/dist/types/index.d.ts.map +1 -1
  126. package/dist/types/model-provider.d.ts +7 -9
  127. package/dist/types/model-provider.d.ts.map +1 -1
  128. package/dist/types/segments.d.ts +29 -13
  129. package/dist/types/segments.d.ts.map +1 -1
  130. package/dist/types/tools.d.ts +21 -23
  131. package/dist/types/tools.d.ts.map +1 -1
  132. package/dist/utils/copy-text.d.ts +1 -2
  133. package/dist/utils/copy-text.d.ts.map +1 -1
  134. package/dist/utils/css-escape.d.ts +1 -1
  135. package/dist/utils/escape.d.ts +4 -19
  136. package/dist/utils/escape.d.ts.map +1 -1
  137. package/dist/utils/files-to-attachments.d.ts +2 -2
  138. package/dist/utils/fold-text.d.ts +13 -0
  139. package/dist/utils/fold-text.d.ts.map +1 -0
  140. package/dist/utils/is-awaiting-reply.d.ts +2 -5
  141. package/dist/utils/is-awaiting-reply.d.ts.map +1 -1
  142. package/dist/utils/presence.d.ts +5 -5
  143. package/dist/utils/segments.d.ts +54 -78
  144. package/dist/utils/segments.d.ts.map +1 -1
  145. package/dist/utils/tool-input.d.ts +1 -1
  146. package/dist/utils/tool-input.d.ts.map +1 -1
  147. package/dist/utils/transcript.d.ts +2 -2
  148. package/dist/utils/uuid.d.ts +0 -11
  149. package/dist/utils/uuid.d.ts.map +1 -1
  150. package/package.json +30 -4
  151. package/dist/dialog-triggers-CEazY76J.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { a1 as resolveConfig, a9 as eventOrigin, aa as activeElementIn, a6 as subscribeConfigChange, a3 as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, L as escapeAttr, H as cssEscape, ab as mergeSegmentUpdate, M as escapeHtml, ac as writeStreamedMarkdown, U as isSafeUrl, C as copyText, P as getSegmentRenderer, Q as installDefaultRenderersOnce, r as AparteMessageRepository, ad as stampSegmentActivity, ae as stampSegmentOnInsert, af as stampSegmentOnUpdate, ag as renumberSegments, ah as openSegmentIds, ai as isTerminalStatus, aj as adoptMessageSegments, S as isAwaitingReply, Y as populateBubbleFromMessage, a8 as uuid, a2 as revokeAttachmentUrls, X as parseMarkdownToSegments, ak as APARTE_DERIVED_SEGMENTS, e as APARTE_DEFAULT_LOCALE, al as chatBoundaryOf, am as rootOf, R as installDialogTriggersOnce } from "./dialog-triggers-CEazY76J.js";
2
- import { A, a, b, c, d, f, g, h, i, j, k, l, m, n, p, q, s, t, u, v, y, z, B, D, E, F, G, I, J, K, N, O, T, V, W, Z, _, $, a0, a4, a5, a7 } from "./dialog-triggers-CEazY76J.js";
1
+ import { a1 as resolveConfig, a9 as eventOrigin, aa as activeElementIn, a6 as subscribeConfigChange, a3 as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, ab as gateGlide, L as escapeAttr, ac as renderingVersionOf, H as cssEscape, ad as mergeSegmentUpdate, M as escapeHtml, ae as writeStreamedMarkdown, U as isSafeUrl, C as copyText, P as getSegmentRenderer, Q as installDefaultRenderersOnce, r as AparteMessageRepository, af as stampSegmentActivity, ag as stampSegmentOnInsert, ah as stampSegmentOnUpdate, ai as renumberSegments, aj as openSegmentIds, ak as isTerminalStatus, al as adoptMessageSegments, S as isAwaitingReply, Y as populateBubbleFromMessage, a8 as uuid, a2 as revokeAttachmentUrls, X as parseMarkdownToSegments, am as APARTE_DERIVED_SEGMENTS, e as APARTE_DEFAULT_LOCALE, an as chatBoundaryOf, ao as rootOf, R as installDialogTriggersOnce } from "./dialog-triggers-3Rk7PuZa.js";
2
+ import { A, a, b, c, d, f, g, h, i, j, k, l, m, n, p, q, s, t, u, v, y, z, B, D, E, F, G, I, J, K, N, O, T, V, W, Z, _, $, a0, a4, a5, a7 } from "./dialog-triggers-3Rk7PuZa.js";
3
3
  function presenceOn(value) {
4
4
  return value === "" ? true : Boolean(value);
5
5
  }
@@ -194,24 +194,19 @@ class AparteSelect extends HTMLElement {
194
194
  *
195
195
  * `_setupEventListeners()` runs on EVERY `connectedCallback`, and `_render()`'s
196
196
  * idempotency guard means the host element survives a re-connect. So each move
197
- * of the element (a portal, a Vue teleport, any framework re-parent) added
198
- * another option-click listener: measured, one click fired the change handler
199
- * FIVE times after five re-connects. `disconnectedCallback` removed the two
200
- * document-level handlers and could not touch this one.
201
- *
202
- * This is verbatim the bug class `aparte-chat-viewport` documents having fixed
203
- * for its own listeners.
197
+ * of the element (a portal, a Vue teleport, any framework re-parent) would add
198
+ * another option-click listener and one click would fire the change handler once
199
+ * per re-connect, unless the same bound reference is removed and re-added.
204
200
  */
205
201
  _boundHandleOptionClick = this._handleOptionClick.bind(this);
206
202
  _boundHandleDocumentClick = this._handleDocumentClick.bind(this);
207
203
  _boundHandleKeydown = this._handleKeydown.bind(this);
208
204
  /**
209
- * The trigger's own three, bound for the same reason and left inline for a whole
210
- * release: they were arrows on `this`, so nothing could ever remove them, and the
211
- * trigger element survives a re-connect. One re-parent made a click toggle twice —
212
- * open and shut inside the same click, i.e. a control that looks dead — and two made
213
- * it announce `aparte-select-open` twice. The docblock above describes exactly this,
214
- * fixed for the option click and not for its neighbours.
205
+ * The trigger's own three, bound for the same reason as the option click: the
206
+ * trigger element survives a re-connect, so listeners that cannot be removed would
207
+ * stack. One re-parent would make a click toggle twice — open and shut inside the
208
+ * same click, i.e. a control that looks dead — and announce `aparte-select-open`
209
+ * twice.
215
210
  */
216
211
  _boundHandleTriggerClick = () => {
217
212
  this._toggle();
@@ -237,6 +232,7 @@ class AparteSelect extends HTMLElement {
237
232
  this._value = this.getAttribute("value") || "";
238
233
  const wantsOpen = this.hasAttribute("open") && !this._isOpen;
239
234
  this._render();
235
+ this._applySearchable();
240
236
  this._applyDisabled();
241
237
  this._setupEventListeners();
242
238
  this._setupMutationObserver();
@@ -264,6 +260,7 @@ class AparteSelect extends HTMLElement {
264
260
  if (name === "placeholder") {
265
261
  this._updateTriggerLabel();
266
262
  }
263
+ if (name === "searchable") this._applySearchable();
267
264
  if (name === "disabled") {
268
265
  this._applyDisabled();
269
266
  if (!this.hasAttribute("disabled") && this.hasAttribute("open")) this._openDropdown();
@@ -292,6 +289,45 @@ class AparteSelect extends HTMLElement {
292
289
  // ─────────────────────────────────────────────────────────────────────────
293
290
  // Rendering
294
291
  // ─────────────────────────────────────────────────────────────────────────
292
+ /** The filter field: the combobox of a `searchable` select, since it takes the focus. */
293
+ _buildSearchInput(cfg) {
294
+ const searchInput = document.createElement("input");
295
+ searchInput.type = "text";
296
+ searchInput.className = "aparte-select-search";
297
+ searchInput.placeholder = cfg.t("selectSearchPlaceholder") || "Search…";
298
+ searchInput.setAttribute("aria-label", cfg.t("selectSearchLabel") || "Search options");
299
+ searchInput.setAttribute("role", "combobox");
300
+ searchInput.setAttribute("aria-haspopup", "listbox");
301
+ searchInput.setAttribute("aria-autocomplete", "list");
302
+ searchInput.setAttribute("aria-expanded", String(this._isOpen));
303
+ return searchInput;
304
+ }
305
+ /**
306
+ * `searchable` set or removed after the first render: the field comes or goes, and the
307
+ * combobox moves with it, so a framework that
308
+ * binds it a beat late — Angular's `[searchable]` inside an `@if` — still gets a field.
309
+ */
310
+ _applySearchable() {
311
+ if (!this._dropdown || !this._trigger) return;
312
+ const wanted = this.hasAttribute("searchable");
313
+ if (wanted === Boolean(this._searchInput)) return;
314
+ if (this._isOpen) this._closeDropdown();
315
+ const listboxId = this._trigger.getAttribute("aria-controls");
316
+ if (wanted) {
317
+ const input = this._buildSearchInput(resolveConfig(this));
318
+ if (listboxId) input.setAttribute("aria-controls", listboxId);
319
+ this._dropdown.prepend(input);
320
+ this._searchInput = input;
321
+ input.addEventListener("input", this._boundHandleSearchInput);
322
+ this._trigger.setAttribute("role", "button");
323
+ } else {
324
+ this._searchInput.removeEventListener("input", this._boundHandleSearchInput);
325
+ this._searchInput.remove();
326
+ this._searchInput = null;
327
+ this._trigger.setAttribute("role", "combobox");
328
+ }
329
+ this._updateNames();
330
+ }
295
331
  _render() {
296
332
  const placeholder = this.getAttribute("placeholder") || "Select...";
297
333
  const searchable = this.hasAttribute("searchable");
@@ -324,19 +360,8 @@ class AparteSelect extends HTMLElement {
324
360
  const dropdown = document.createElement("div");
325
361
  dropdown.className = "aparte-select-dropdown";
326
362
  dropdown.hidden = !this._isOpen;
327
- let searchInput = null;
328
- if (searchable) {
329
- searchInput = document.createElement("input");
330
- searchInput.type = "text";
331
- searchInput.className = "aparte-select-search";
332
- searchInput.placeholder = cfg.t("selectSearchPlaceholder") || "Search…";
333
- searchInput.setAttribute("aria-label", cfg.t("selectSearchLabel") || "Search options");
334
- searchInput.setAttribute("role", "combobox");
335
- searchInput.setAttribute("aria-haspopup", "listbox");
336
- searchInput.setAttribute("aria-autocomplete", "list");
337
- searchInput.setAttribute("aria-expanded", String(this._isOpen));
338
- dropdown.appendChild(searchInput);
339
- }
363
+ const searchInput = searchable ? this._buildSearchInput(cfg) : null;
364
+ if (searchInput) dropdown.appendChild(searchInput);
340
365
  const optionsContainer = document.createElement("div");
341
366
  optionsContainer.className = "aparte-select-options";
342
367
  optionsContainer.setAttribute("role", "listbox");
@@ -375,9 +400,9 @@ class AparteSelect extends HTMLElement {
375
400
  * Put the roving highlight back after the options changed underneath it.
376
401
  *
377
402
  * `data-active` lives on an option ELEMENT, so replacing the list throws it
378
- * away while `_activeIndex` still claims a position: the highlight disappeared,
379
- * `aria-activedescendant` kept pointing at an id no longer in the document, and
380
- * the next ArrowDown moved from the stale index — skipping an option. Only when
403
+ * away while `_activeIndex` still claims a position: without this the highlight
404
+ * disappears, `aria-activedescendant` points at an id no longer in the document, and
405
+ * the next ArrowDown moves from the stale index — skipping an option. Only when
381
406
  * open and only when a position was held, so a refresh never invents one.
382
407
  */
383
408
  _restoreActive() {
@@ -603,8 +628,8 @@ class AparteSelect extends HTMLElement {
603
628
  * A `combobox` is NAMED by that attribute and VALUED by its visible label span, so
604
629
  * the name is the control and nothing else. A `button` — the shape a `searchable`
605
630
  * trigger takes, since the filter field is then the combobox — is named by its
606
- * CONTENT, which this very attribute overrides: the searchable trigger announced
607
- * "Pick a model, button" and never said which model was selected. So the button's
631
+ * CONTENT, which this very attribute overrides: a name of just the placeholder
632
+ * ("Pick a model, button") would never say which model is selected. So the button's
608
633
  * name carries both halves, and drops the second when it would only repeat the
609
634
  * first (nothing selected: the value IS the placeholder).
610
635
  */
@@ -644,7 +669,7 @@ class AparteSelect extends HTMLElement {
644
669
  }
645
670
  const sizer = this._trigger?.querySelector(".aparte-select-label-sizer");
646
671
  if (sizer) {
647
- const labels = [placeholder, ...Array.from(this.querySelectorAll("aparte-option"), (o) => o.textContent?.trim() || "")];
672
+ const labels = [placeholder, ...Array.from(this.querySelectorAll("aparte-option"), (o) => optionLabelText(o))];
648
673
  const current = Array.from(sizer.children, (s2) => s2.textContent ?? "");
649
674
  if (labels.length !== current.length || labels.some((l2, i2) => l2 !== current[i2])) {
650
675
  sizer.replaceChildren(...labels.map((text) => {
@@ -667,7 +692,7 @@ class AparteSelect extends HTMLElement {
667
692
  }
668
693
  _getSelectedLabel() {
669
694
  for (const opt of this.querySelectorAll("aparte-option")) {
670
- if (opt.getAttribute("value") === this._value) return opt.textContent?.trim() || "";
695
+ if (opt.getAttribute("value") === this._value) return optionLabelText(opt);
671
696
  }
672
697
  return "";
673
698
  }
@@ -693,6 +718,14 @@ class AparteSelect extends HTMLElement {
693
718
  }));
694
719
  }
695
720
  }
721
+ function optionLabelText(option) {
722
+ let text = "";
723
+ for (const node of option.childNodes) {
724
+ if (node.nodeType === Node.ELEMENT_NODE && node.matches(".aparte-option__meta, .aparte-status-dot")) continue;
725
+ text += node.textContent ?? "";
726
+ }
727
+ return text.trim();
728
+ }
696
729
  if (!customElements.get("aparte-select")) {
697
730
  customElements.define("aparte-select", AparteSelect);
698
731
  }
@@ -748,7 +781,7 @@ class AparteIcon extends HTMLElement {
748
781
  }
749
782
  _render() {
750
783
  const name = this.getAttribute("name");
751
- const glyph = name ? resolveConfig(this).getIconProvider()[name]?.() : null;
784
+ const glyph = name ? resolveConfig(this).getIcon(name) : null;
752
785
  this.innerHTML = glyph ?? "";
753
786
  this.firstElementChild?.setAttribute("aria-hidden", "true");
754
787
  }
@@ -756,17 +789,59 @@ class AparteIcon extends HTMLElement {
756
789
  if (!customElements.get("aparte-icon")) {
757
790
  customElements.define("aparte-icon", AparteIcon);
758
791
  }
792
+ function deepActiveElement(root) {
793
+ let active = root.activeElement;
794
+ while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
795
+ return active;
796
+ }
797
+ function isWithin(node, ancestor) {
798
+ for (let n2 = node; n2; n2 = n2.parentNode ?? (n2 instanceof ShadowRoot ? n2.host : null)) {
799
+ if (n2 === ancestor) return true;
800
+ }
801
+ return false;
802
+ }
803
+ function addRetract(panelEl, corner, cfg, reopen, onToggle) {
804
+ const hide = document.createElement("button");
805
+ hide.type = "button";
806
+ hide.className = "aparte-btn aparte-btn--icon aparte-btn--sm aparte-btn--ghost aparte-elic-hide";
807
+ hide.setAttribute("aria-expanded", "true");
808
+ hide.innerHTML = cfg.getIcon("expand");
809
+ const reveal = document.createElement("button");
810
+ reveal.type = "button";
811
+ reveal.className = "aparte-btn aparte-btn--ghost aparte-elic-reveal";
812
+ reveal.setAttribute("aria-expanded", "false");
813
+ const revealText = document.createElement("span");
814
+ reveal.append(revealText);
815
+ reveal.insertAdjacentHTML("beforeend", cfg.getIcon("collapse"));
816
+ const relabel = (next) => {
817
+ const label = next.t("elicitationHide");
818
+ hide.setAttribute("aria-label", label);
819
+ hide.title = label;
820
+ revealText.textContent = next.t("elicitationWaiting");
821
+ };
822
+ relabel(cfg);
823
+ hide.addEventListener("click", () => {
824
+ panelEl.setAttribute("data-collapsed", "");
825
+ onToggle();
826
+ reveal.focus();
827
+ });
828
+ reveal.addEventListener("click", () => {
829
+ panelEl.removeAttribute("data-collapsed");
830
+ onToggle();
831
+ reopen();
832
+ });
833
+ corner.prepend(hide);
834
+ panelEl.append(reveal);
835
+ return { relabel };
836
+ }
759
837
  class AparteElicitation extends HTMLElement {
760
838
  _pending = null;
761
839
  /**
762
840
  * A turn ended — cancel the open question only if it was OUR turn.
763
841
  *
764
- * These two listeners sit on `window` and had no instance filter at all, so a
765
- * Stop (or an error) in one chat cancelled the question a DIFFERENT chat was
766
- * waiting on — and that chat's model was told the user had refused a question
767
- * the user was still looking at. Same defect as the `compact()` handler that
768
- * emptied both chats, which its four sibling handlers in `AparteClient` already
769
- * guarded against.
842
+ * These two listeners sit on `window`, so they filter by instance: a Stop (or an
843
+ * error) in one chat must not cancel the question a DIFFERENT chat is waiting on,
844
+ * whose model would be told the user refused a question they were still looking at.
770
845
  *
771
846
  * The leniency rule is the composer's, deliberately: an event with no
772
847
  * `targetId` is for everyone (a single-chat app never sets one), and a chat we
@@ -830,16 +905,60 @@ class AparteElicitation extends HTMLElement {
830
905
  return new Promise((resolve, reject) => {
831
906
  let done = false;
832
907
  const slot = {};
833
- const focusedBefore = document.activeElement;
908
+ const focusedBefore = deepActiveElement(document);
909
+ const messageText = () => {
910
+ const m2 = request.message;
911
+ return typeof m2 === "function" ? String(m2()) : String(m2 ?? "");
912
+ };
913
+ const CHAT = "aparte-chat, [data-aparte-chat], [data-aparte-host]";
914
+ const EDITABLE = 'input, textarea, select, [contenteditable]:not([contenteditable="false"])';
915
+ const chatRoot = composer.closest(CHAT) ?? composer.parentElement ?? composer;
916
+ const fromNobody = !(focusedBefore instanceof HTMLElement) || focusedBefore === document.body;
917
+ const before = fromNobody ? null : focusedBefore;
918
+ const inChat = before !== null && isWithin(before, chatRoot);
919
+ const typing = before !== null && before.matches(EDITABLE);
920
+ const busyElsewhere = before !== null && !inChat && (typing || before.tagName === "IFRAME" || before.closest(CHAT) !== null);
921
+ const status = (text) => {
922
+ let region = composer.querySelector(":scope > .aparte-elic-status");
923
+ if (!region) {
924
+ region = document.createElement("span");
925
+ region.className = "aparte-sr-only aparte-elic-status";
926
+ region.setAttribute("role", "status");
927
+ composer.appendChild(region);
928
+ }
929
+ const live = region;
930
+ live.textContent = "";
931
+ if (text) setTimeout(() => {
932
+ if (live.isConnected) live.textContent = text;
933
+ }, 60);
934
+ };
935
+ const focusPanel = (panel2, message) => {
936
+ if (busyElsewhere) {
937
+ status(message);
938
+ return;
939
+ }
940
+ const x = window.scrollX, y2 = window.scrollY;
941
+ if (typing && inChat) {
942
+ if (!panel2.el.hasAttribute("tabindex")) panel2.el.setAttribute("tabindex", "-1");
943
+ if (!panel2.el.hasAttribute("role")) panel2.el.setAttribute("role", "group");
944
+ if (message && !panel2.el.hasAttribute("aria-label")) panel2.el.setAttribute("aria-label", message);
945
+ panel2.el.focus();
946
+ } else {
947
+ panel2.focus();
948
+ }
949
+ if (fromNobody && (window.scrollX !== x || window.scrollY !== y2)) window.scrollTo(x, y2);
950
+ };
834
951
  const close = () => {
835
952
  if (done) return false;
836
953
  done = true;
837
954
  this._pending = null;
838
- const active = document.activeElement;
839
- const focusWasInPanel = active instanceof Node && composer.contains(active);
955
+ const active = deepActiveElement(document);
956
+ const focusWasInPanel = active instanceof Node && isWithin(active, composer);
840
957
  composer.hidePanel(slot.token);
841
- if (focusWasInPanel && focusedBefore instanceof HTMLElement && focusedBefore.isConnected) {
842
- focusedBefore.focus();
958
+ status("");
959
+ if (focusWasInPanel) {
960
+ const back = !busyElsewhere && before !== null && before.isConnected && !isWithin(before, composer) ? before : null;
961
+ (back ?? composer).focus();
843
962
  }
844
963
  return true;
845
964
  };
@@ -858,12 +977,20 @@ class AparteElicitation extends HTMLElement {
858
977
  }
859
978
  const cfg = resolveConfig(this);
860
979
  if (request.kind === "approval") {
861
- const approvalMode = () => panel2.isComplete() ? "submit" : "none";
862
- const panel2 = runWithConfig(cfg, () => buildApprovalPanel(request.message, request.options ?? [], () => {
863
- composer.setPanelSubmitEnabled(panel2.isComplete(), approvalMode());
864
- }, request.details));
980
+ const sendable = () => panel2.isComplete() && !panel2.el.hasAttribute("data-collapsed");
981
+ const approvalMode = () => sendable() ? "submit" : "none";
982
+ const syncSubmit2 = () => composer.setPanelSubmitEnabled(sendable(), approvalMode());
983
+ const panel2 = runWithConfig(cfg, () => buildApprovalPanel(request.message, request.options ?? [], syncSubmit2, request.details));
865
984
  panel2.onSettle((answer) => settle({ action: "accept", content: answer }));
866
- this._pending = { abort: () => fail(), composer, relabel: () => panel2.relabel() };
985
+ const retract2 = addRetract(panel2.el, panel2.dismiss, cfg, () => panel2.focus(), syncSubmit2);
986
+ this._pending = {
987
+ abort: () => fail(),
988
+ composer,
989
+ relabel: () => {
990
+ panel2.relabel();
991
+ retract2.relabel(resolveConfig(this));
992
+ }
993
+ };
867
994
  slot.token = composer.showPanel(panel2.el, {
868
995
  submitEnabled: panel2.isComplete(),
869
996
  mode: approvalMode(),
@@ -872,26 +999,31 @@ class AparteElicitation extends HTMLElement {
872
999
  },
873
1000
  onEvict: () => fail()
874
1001
  });
875
- panel2.focus();
1002
+ focusPanel(panel2, messageText());
876
1003
  return;
877
1004
  }
878
1005
  const schema = request.schema ?? { type: "string" };
879
- const panel = runWithConfig(cfg, () => buildElicitationPanel(request.message, schema, () => {
880
- composer.setPanelSubmitEnabled(panel.canProceed(), panel.mode());
881
- }));
1006
+ const syncSubmit = () => {
1007
+ const aside = panel.el.hasAttribute("data-collapsed");
1008
+ composer.setPanelSubmitEnabled(!aside && panel.canProceed(), aside ? "none" : panel.mode());
1009
+ };
1010
+ const panel = runWithConfig(cfg, () => buildElicitationPanel(request.message, schema, syncSubmit));
882
1011
  const skip = document.createElement("button");
883
1012
  skip.type = "button";
884
1013
  skip.className = "aparte-btn aparte-elic-skip";
885
1014
  skip.textContent = cfg.t("elicitationSkip");
886
1015
  skip.addEventListener("click", () => settle({ action: "decline" }));
887
1016
  panel.dismiss.appendChild(skip);
1017
+ const retract = addRetract(panel.el, panel.dismiss, cfg, () => panel.focus(), syncSubmit);
888
1018
  panel.onSettle((content) => settle({ action: "accept", content }));
889
1019
  this._pending = {
890
1020
  abort: () => fail(),
891
1021
  composer,
892
1022
  relabel: () => {
893
1023
  panel.relabel();
894
- skip.textContent = resolveConfig(this).t("elicitationSkip");
1024
+ const now = resolveConfig(this);
1025
+ skip.textContent = now.t("elicitationSkip");
1026
+ retract.relabel(now);
895
1027
  }
896
1028
  };
897
1029
  slot.token = composer.showPanel(panel.el, {
@@ -902,15 +1034,15 @@ class AparteElicitation extends HTMLElement {
902
1034
  * or a turn ending. The composer tears the panel down either way; only
903
1035
  * this callback can settle the promise, and without it the request hung
904
1036
  * AND `_pending` stayed set, so every later question was short-circuited
905
- * for the life of the page. `cancel`, not `decline`: nobody declined
906
- * anything, the question was taken away.
1037
+ * for the life of the page. A rejection (`aborted`), not `decline`:
1038
+ * nobody declined anything, the question was taken away.
907
1039
  */
908
1040
  onEvict: () => fail(),
909
1041
  onSubmit: () => {
910
1042
  if (panel.isComplete()) settle({ action: "accept", content: panel.getContent() });
911
1043
  }
912
1044
  });
913
- panel.focus();
1045
+ focusPanel(panel, messageText());
914
1046
  });
915
1047
  };
916
1048
  _cancelPending() {
@@ -940,12 +1072,11 @@ class AparteElicitation extends HTMLElement {
940
1072
  * The composer to present in: the nearest one in an ancestor subtree, and
941
1073
  * nothing else.
942
1074
  *
943
- * There used to be a `document.querySelector('aparte-composer')` fallback, which
944
- * is the "first chat on the page" bug this repo has now fixed in four other
945
- * places: on a page with two chats, an elicitation that could not find its own
946
- * composer mounted its panel in the OTHER chat's — so one conversation's question
947
- * appeared under the other conversation, and answering it resolved a tool call
948
- * belonging to a chat the user was not looking at.
1075
+ * There is no `document.querySelector('aparte-composer')` fallback: on a page with
1076
+ * two chats, an elicitation that could not find its own composer would mount its
1077
+ * panel in the OTHER chat's, so one conversation's question would appear under the
1078
+ * other conversation, and answering it would resolve a tool call belonging to a
1079
+ * chat the user was not looking at.
949
1080
  *
950
1081
  * Returning `null` instead REJECTS the request, which is honest: nothing was
951
1082
  * shown, so nothing was answered. The warning names the fix, because this is a
@@ -980,6 +1111,7 @@ class AparteChat extends HTMLElement {
980
1111
  }
981
1112
  _observer = null;
982
1113
  _loadingObserver = null;
1114
+ _offGlide = null;
983
1115
  /** Waits for a `<aparte-chat-viewport>` child to appear — see `_watchForViewport`. */
984
1116
  _childObserver = null;
985
1117
  /** The viewport is loading and the gate is on — see `_applyLoadingGate`. */
@@ -1000,8 +1132,12 @@ class AparteChat extends HTMLElement {
1000
1132
  this._syncEmptyWatch();
1001
1133
  this._syncLoadingWatch();
1002
1134
  this._watchForViewport();
1135
+ this._offGlide?.();
1136
+ this._offGlide = gateGlide(this);
1003
1137
  }
1004
1138
  disconnectedCallback() {
1139
+ this._offGlide?.();
1140
+ this._offGlide = null;
1005
1141
  this._observer?.disconnect();
1006
1142
  this._observer = null;
1007
1143
  this._loadingObserver?.disconnect();
@@ -1084,11 +1220,10 @@ class AparteChat extends HTMLElement {
1084
1220
  /**
1085
1221
  * Watch the viewport so `data-empty` follows the conversation.
1086
1222
  *
1087
- * It used to run only under `center-empty`, which made the chat's one statement of
1088
- * "this conversation is empty" a by-product of a layout option: an app that wanted
1089
- * starters on an empty thread, or a welcome of its own, had to count messages itself.
1090
- * Every selector core writes against it still requires `[center-empty]`, so reflecting
1091
- * it everywhere moves no layout.
1223
+ * Runs whether or not `center-empty` is set, so an app that wants starters on an
1224
+ * empty thread, or a welcome of its own, can read `data-empty` instead of counting
1225
+ * messages itself. Every selector core writes against it requires `[center-empty]`,
1226
+ * so reflecting it everywhere moves no layout.
1092
1227
  */
1093
1228
  _syncEmptyWatch() {
1094
1229
  this._observer?.disconnect();
@@ -1115,12 +1250,11 @@ class AparteChat extends HTMLElement {
1115
1250
  this._loadingObserver.observe(viewport, { attributes: true, attributeFilter: ["loading"] });
1116
1251
  }
1117
1252
  /**
1118
- * Both watches above need a viewport to observe, and they were installed once, at
1119
- * connect, when there might not be one yet: `<aparte-chat framework-managed>` IS
1120
- * Angular's host element — upgraded on insert, filled by the template afterwards — and
1121
- * hand-written markup upgrades before its children are parsed. Neither ever got its
1122
- * observer, so the documented "every chat gates" was inert on exactly the chats whose
1123
- * transcript is fetched. This waits for the child to arrive, installs them, and stops.
1253
+ * Both watches above need a viewport to observe, and there might not be one at
1254
+ * connect: `<aparte-chat framework-managed>` IS Angular's host element — upgraded on
1255
+ * insert, filled by the template afterwards — and hand-written markup upgrades before
1256
+ * its children are parsed. This waits for the child to arrive, installs them, and
1257
+ * stops, so every chat gates, including those whose transcript is fetched.
1124
1258
  */
1125
1259
  _watchForViewport() {
1126
1260
  this._childObserver?.disconnect();
@@ -1155,8 +1289,8 @@ class AparteChat extends HTMLElement {
1155
1289
  }
1156
1290
  /**
1157
1291
  * Empty means no message AND none on its way. A viewport that says `loading` holds
1158
- * no bubble yet, and it used to count as empty: the welcome and the centred composer
1159
- * showed while a conversation was being fetched.
1292
+ * no bubble yet but does not count as empty, so the welcome and the centred composer
1293
+ * stay hidden while a conversation is being fetched.
1160
1294
  */
1161
1295
  _updateEmpty() {
1162
1296
  const viewport = this.querySelector("aparte-chat-viewport");
@@ -1198,6 +1332,16 @@ function segmentRenderResultToElement(result, segment) {
1198
1332
  if (el2 && segment && !el2.hasAttribute("data-segment-id")) el2.setAttribute("data-segment-id", segment.id);
1199
1333
  return el2;
1200
1334
  }
1335
+ const pendingAvatarMarks = /* @__PURE__ */ new Map();
1336
+ function flushAvatarMarks() {
1337
+ const batch = [...pendingAvatarMarks].filter(([avatar]) => !avatar.firstChild);
1338
+ pendingAvatarMarks.clear();
1339
+ const shown = batch.map(([, { bubble, role }]) => {
1340
+ const image = getComputedStyle(bubble).getPropertyValue(`--aparte-avatar-image-${role}`).trim();
1341
+ return image !== "" && image !== "none";
1342
+ });
1343
+ batch.forEach(([avatar], i2) => avatar.toggleAttribute("data-image", shown[i2]));
1344
+ }
1201
1345
  class AparteChatBubble extends HTMLElement {
1202
1346
  _contentEl = null;
1203
1347
  _segmentsEl = null;
@@ -1213,8 +1357,20 @@ class AparteChatBubble extends HTMLElement {
1213
1357
  _usage = null;
1214
1358
  /** Cleanup returned by the avatar provider — called on disconnect/re-render. */
1215
1359
  _avatarCleanup = null;
1360
+ /** Whether the avatar slot holds what a provider drew, which goes when the provider does. */
1361
+ _avatarFromProvider = false;
1216
1362
  /** Sibling count for tree-based branch navigation (set by setSiblings()) */
1217
1363
  _siblingCount = 1;
1364
+ /**
1365
+ * Which accessible names the bubble wrote itself, and so may refresh on a language switch
1366
+ * or a name change. A custom shell's own are its author's: refreshed, a root's or a bar's
1367
+ * label of their own became "Assistant response" or "Message actions" at the first notify.
1368
+ */
1369
+ _wroteRootLabel = false;
1370
+ _wroteBarLabel = false;
1371
+ _wroteArrowLabels = false;
1372
+ /** The shell renderer the bubble was last drawn with (`null`: the default frame). */
1373
+ _drawnShell = null;
1218
1374
  /** Sibling index for tree-based branch navigation (set by setSiblings()) */
1219
1375
  _siblingIndex = 0;
1220
1376
  /** True while the user-message inline editor is open. */
@@ -1246,7 +1402,29 @@ class AparteChatBubble extends HTMLElement {
1246
1402
  this._renderAvatar();
1247
1403
  this._relabelSegments();
1248
1404
  this._updateTimestamp(this.getAttribute("timestamp"));
1405
+ if (!this._redrawQueued) {
1406
+ this._redrawQueued = true;
1407
+ queueMicrotask(() => {
1408
+ this._redrawQueued = false;
1409
+ if (this.isConnected) this._redrawIfStale();
1410
+ });
1411
+ }
1249
1412
  };
1413
+ /** A {@link _redrawIfStale} is queued for the end of the current task. */
1414
+ _redrawQueued = false;
1415
+ /** The {@link renderingVersionOf} count the content was last drawn with. */
1416
+ _drawnWithVersion = 0;
1417
+ /**
1418
+ * A Markdown or highlight provider set after this message was drawn: draw it again.
1419
+ * Not mid-stream — the stream reads the providers as it goes, and its end redraws — and
1420
+ * not mid-edit, where the drawn message is hidden behind the editor and drawing it would
1421
+ * show it again beside the editor; leaving the edit calls this.
1422
+ */
1423
+ _redrawIfStale() {
1424
+ if (this._streaming || this._editing || renderingVersionOf(this._cfg) === this._drawnWithVersion) return;
1425
+ if (this._segments.length > 0) this._renderSegments();
1426
+ else this._updateContent();
1427
+ }
1250
1428
  /**
1251
1429
  * Ask every rendered segment to re-read its config-derived text.
1252
1430
  *
@@ -1284,9 +1462,11 @@ class AparteChatBubble extends HTMLElement {
1284
1462
  const set = (selector, label) => {
1285
1463
  this.querySelector(selector)?.setAttribute("aria-label", label);
1286
1464
  };
1287
- set(".aparte-branch-prev", locale.previousResponse ?? "Previous response");
1288
- set(".aparte-branch-next", locale.nextResponse ?? "Next response");
1289
- set(".aparte-action-bar", locale.messageActions ?? "Message actions");
1465
+ if (this._wroteArrowLabels) {
1466
+ set(".aparte-branch-prev", locale.previousResponse ?? "Previous response");
1467
+ set(".aparte-branch-next", locale.nextResponse ?? "Next response");
1468
+ }
1469
+ if (this._wroteBarLabel) set(".aparte-action-bar", locale.messageActions ?? "Message actions");
1290
1470
  }
1291
1471
  /**
1292
1472
  * Config governing this bubble: the instance config of the nearest
@@ -1306,6 +1486,40 @@ class AparteChatBubble extends HTMLElement {
1306
1486
  this.addEventListener("click", this._onBranchPickerClick);
1307
1487
  this.addEventListener("click", this._onLinkClick);
1308
1488
  }
1489
+ /**
1490
+ * The boundary above this bubble changed — under every wrapper the chat's own config is
1491
+ * attached AFTER the first messages are drawn. When that brings another frame
1492
+ * (`setBubbleShellRenderer`), the bubble is drawn again in it and its content put back,
1493
+ * so every message wears the chat's frame.
1494
+ *
1495
+ * Deferred to a microtask: a wrapper detaches the boundary as it unmounts (Vue's
1496
+ * `onBeforeUnmount`, Svelte's `onDestroy`), while the bubbles are still in the page, and
1497
+ * redrawing every one of them just before their removal would cost a frame per message.
1498
+ * After the microtask a removed bubble is no longer connected, and repeated notices fold
1499
+ * into one.
1500
+ */
1501
+ aparteConfigChanged() {
1502
+ queueMicrotask(() => this._redrawFrameIfChanged());
1503
+ }
1504
+ _redrawFrameIfChanged() {
1505
+ if (!this.isConnected || this._editing) return;
1506
+ if ((this._cfg.getBubbleShellRenderer?.() ?? null) === this._drawnShell) return;
1507
+ if (this._avatarCleanup) {
1508
+ try {
1509
+ this._avatarCleanup();
1510
+ } catch {
1511
+ }
1512
+ this._avatarCleanup = null;
1513
+ }
1514
+ this._resetMarkdownStream();
1515
+ this.replaceChildren();
1516
+ this._render();
1517
+ this._updateContent();
1518
+ if (this._segments.length > 0) this._renderSegments();
1519
+ this._updateAttachments();
1520
+ this._updateTimestamp(this.getAttribute("timestamp"));
1521
+ this._updateBranchPicker();
1522
+ }
1309
1523
  disconnectedCallback() {
1310
1524
  window.removeEventListener("aparte-config-change", this._onConfigChange);
1311
1525
  this.removeEventListener("click", this._onBranchPickerClick);
@@ -1418,11 +1632,11 @@ class AparteChatBubble extends HTMLElement {
1418
1632
  * query returns the first match in document order — and sanitized model
1419
1633
  * markdown renders inside that same container, with `data-*` attributes
1420
1634
  * deliberately preserved (they are inert). So a decoy `data-segment-id` planted
1421
- * in an earlier segment's prose used to win over the real segment element.
1635
+ * in an earlier segment's prose would win over the real segment element.
1422
1636
  *
1423
1637
  * Parser ids are unguessable UUIDs, but a tool segment is `tool-${toolCallId}`
1424
- * and the MODEL chooses that id — so this was reachable, and pointing an update
1425
- * at a decoy left a rejected tool rendering as still-running: a spoof against
1638
+ * and the MODEL chooses that id — so a decoy could point an update at the wrong
1639
+ * element and leave a rejected tool rendering as still-running: a spoof against
1426
1640
  * the human-in-the-loop control.
1427
1641
  */
1428
1642
  removeSegment(segmentId) {
@@ -1502,6 +1716,7 @@ class AparteChatBubble extends HTMLElement {
1502
1716
  console.warn(`[AparteChatBubble] _appendSegmentEl ABORT: _segmentsEl is null`);
1503
1717
  return;
1504
1718
  }
1719
+ if (this._segments.length === 1) this._drawnWithVersion = renderingVersionOf(this._cfg);
1505
1720
  const renderer = resolveSegmentRenderer(segment.type, this._cfg);
1506
1721
  if (renderer) {
1507
1722
  const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)), segment);
@@ -1564,6 +1779,9 @@ class AparteChatBubble extends HTMLElement {
1564
1779
  const displayName = this._getDisplayName();
1565
1780
  const initial = this._getAvatarInitial();
1566
1781
  const shell = this._cfg.getBubbleShellRenderer?.();
1782
+ this._drawnShell = shell ?? null;
1783
+ this._avatarFromProvider = false;
1784
+ this._wroteRootLabel = this._wroteBarLabel = this._wroteArrowLabels = !shell;
1567
1785
  if (shell) {
1568
1786
  const out = runWithConfig(this._cfg, () => shell({ role: this._role, name: displayName, avatarInitial: initial }));
1569
1787
  if (out instanceof HTMLElement) this.replaceChildren(out);
@@ -1609,11 +1827,39 @@ class AparteChatBubble extends HTMLElement {
1609
1827
  if (messageEl && !messageEl.hasAttribute("role")) {
1610
1828
  messageEl.setAttribute("role", "article");
1611
1829
  messageEl.setAttribute("aria-label", this._getAriaLabel());
1830
+ this._wroteRootLabel = true;
1612
1831
  }
1613
1832
  this._contentEl = this.querySelector(".aparte-content");
1614
1833
  this._segmentsEl = this.querySelector(".aparte-segments");
1615
1834
  this._attachmentsEl = this.querySelector(".aparte-attachments");
1616
1835
  this._actionBarEl = this.querySelector(".aparte-action-bar");
1836
+ if (this._actionBarEl && !this._actionBarEl.hasAttribute("role")) {
1837
+ this._actionBarEl.setAttribute("role", "toolbar");
1838
+ if (!this._actionBarEl.hasAttribute("aria-label")) {
1839
+ this._actionBarEl.setAttribute("aria-label", this._cfg.getLocale().messageActions ?? "Message actions");
1840
+ this._wroteBarLabel = true;
1841
+ }
1842
+ }
1843
+ if (shell) {
1844
+ const prev = this.querySelector(".aparte-branch-prev");
1845
+ const next = this.querySelector(".aparte-branch-next");
1846
+ const audible = (arrow) => {
1847
+ let text = "";
1848
+ const walker = arrow.ownerDocument.createTreeWalker(arrow, NodeFilter.SHOW_TEXT);
1849
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
1850
+ const hidden = node.parentElement?.closest('[aria-hidden="true"], [hidden]');
1851
+ if (!hidden || !arrow.contains(hidden)) text += node.textContent ?? "";
1852
+ }
1853
+ return text;
1854
+ };
1855
+ const named = (arrow) => !!arrow && (arrow.hasAttribute("aria-label") || arrow.hasAttribute("aria-labelledby") || arrow.hasAttribute("title") || /\p{L}/u.test(audible(arrow)));
1856
+ if ((prev || next) && !named(prev) && !named(next)) {
1857
+ const locale = this._cfg.getLocale();
1858
+ prev?.setAttribute("aria-label", locale.previousResponse ?? "Previous response");
1859
+ next?.setAttribute("aria-label", locale.nextResponse ?? "Next response");
1860
+ this._wroteArrowLabels = true;
1861
+ }
1862
+ }
1617
1863
  this._actionBarEl?.addEventListener("keydown", this._onActionBarKeydown);
1618
1864
  this._branchPickerEl = this.querySelector(".aparte-branch-picker");
1619
1865
  this._footerEl = this.querySelector(".aparte-footer");
@@ -1625,8 +1871,7 @@ class AparteChatBubble extends HTMLElement {
1625
1871
  }
1626
1872
  /**
1627
1873
  * Show the built-in waiting indicator while this bubble is in flight and has
1628
- * nothing to show yet — the gap between "user sends" and the first token, which
1629
- * used to be a bubble with a name and an empty body.
1874
+ * nothing to show yet — the gap between "user sends" and the first token.
1630
1875
  *
1631
1876
  * The dots are CSS (no per-token work, themable, honours reduced-motion); the
1632
1877
  * accessible name is `locale.typing`, next to the `aria-busy` the streaming state
@@ -1653,11 +1898,10 @@ class AparteChatBubble extends HTMLElement {
1653
1898
  /**
1654
1899
  * Hand the avatar host element off to the registered AvatarProvider, if any.
1655
1900
  *
1656
- * With no provider the slot is left exactly as the shell rendered it — which for
1657
- * the default shell means EMPTY, and hidden by `.aparte-avatar:empty`. This used
1658
- * to claim it "falls back to the default initial rendered by `_render()`"; there
1659
- * is no such initial, and believing there was is what made `_updateRole` write
1660
- * one.
1901
+ * With no provider the slot keeps what the shell rendered — for the default shell,
1902
+ * nothing, hidden while empty — and loses what a provider drew there before. An empty
1903
+ * slot still shows when the stylesheet names a picture for its role
1904
+ * (`--aparte-avatar-image-user` / `-assistant`): see `_markAvatarImage`.
1661
1905
  */
1662
1906
  _renderAvatar() {
1663
1907
  const avatar = this.querySelector(".aparte-avatar");
@@ -1670,10 +1914,34 @@ class AparteChatBubble extends HTMLElement {
1670
1914
  this._avatarCleanup = null;
1671
1915
  }
1672
1916
  const provider = this._cfg.getAvatarProvider();
1673
- if (!provider) return;
1917
+ if (!provider) {
1918
+ if (this._avatarFromProvider) {
1919
+ avatar.replaceChildren();
1920
+ this._avatarFromProvider = false;
1921
+ }
1922
+ this._markAvatarImage(avatar);
1923
+ return;
1924
+ }
1674
1925
  avatar.textContent = "";
1675
1926
  const cleanup = provider.render(this._role, avatar);
1927
+ this._avatarFromProvider = true;
1676
1928
  if (typeof cleanup === "function") this._avatarCleanup = cleanup;
1929
+ this._markAvatarImage(avatar);
1930
+ }
1931
+ /**
1932
+ * A picture from the stylesheet alone: an empty slot whose role's `--aparte-avatar-image-*`
1933
+ * names an image gets `data-image`, which lifts the rule that hides an empty slot. Hidden
1934
+ * while empty, the token painted nothing. Read when the message is drawn and on every
1935
+ * config change, in a batch with the other bubbles' (see `pendingAvatarMarks`).
1936
+ */
1937
+ _markAvatarImage(avatar) {
1938
+ if (avatar.firstChild) {
1939
+ pendingAvatarMarks.delete(avatar);
1940
+ avatar.removeAttribute("data-image");
1941
+ return;
1942
+ }
1943
+ if (pendingAvatarMarks.size === 0) queueMicrotask(flushAvatarMarks);
1944
+ pendingAvatarMarks.set(avatar, { bubble: this, role: this._role });
1677
1945
  }
1678
1946
  _updateRole() {
1679
1947
  const message = this.querySelector(".aparte-message");
@@ -1681,13 +1949,13 @@ class AparteChatBubble extends HTMLElement {
1681
1949
  const nameEl = this.querySelector(".aparte-name");
1682
1950
  if (message) {
1683
1951
  message.setAttribute("data-role", this._role);
1684
- if (message.getAttribute("role") === "article") {
1952
+ if (this._wroteRootLabel && message.getAttribute("role") === "article") {
1685
1953
  message.setAttribute("aria-label", this._getAriaLabel());
1686
1954
  }
1687
1955
  }
1688
1956
  if (avatar) {
1689
1957
  avatar.setAttribute("data-role", this._role);
1690
- if (avatar.textContent) avatar.textContent = this._getAvatarInitial();
1958
+ if (avatar.textContent && !this._avatarFromProvider) avatar.textContent = this._getAvatarInitial();
1691
1959
  }
1692
1960
  if (nameEl) {
1693
1961
  nameEl.textContent = this._getDisplayName();
@@ -1698,10 +1966,12 @@ class AparteChatBubble extends HTMLElement {
1698
1966
  _updateName() {
1699
1967
  const avatar = this.querySelector(".aparte-avatar");
1700
1968
  const nameEl = this.querySelector(".aparte-name");
1701
- if (avatar && avatar.textContent && !this._cfg.getAvatarProvider()) {
1969
+ if (avatar && avatar.textContent && !this._cfg.getAvatarProvider() && !this._avatarFromProvider) {
1702
1970
  avatar.textContent = this._getAvatarInitial();
1703
1971
  }
1704
1972
  if (nameEl) nameEl.textContent = this._getDisplayName();
1973
+ const message = this.querySelector(".aparte-message");
1974
+ if (this._wroteRootLabel && message?.getAttribute("role") === "article") message.setAttribute("aria-label", this._getAriaLabel());
1705
1975
  }
1706
1976
  _updateContent() {
1707
1977
  if (!this._contentEl) return;
@@ -1710,6 +1980,7 @@ class AparteChatBubble extends HTMLElement {
1710
1980
  this._updateWaiting();
1711
1981
  return;
1712
1982
  }
1983
+ this._drawnWithVersion = renderingVersionOf(this._cfg);
1713
1984
  this._contentEl.style.display = "";
1714
1985
  runWithConfig(
1715
1986
  this._cfg,
@@ -1747,6 +2018,7 @@ class AparteChatBubble extends HTMLElement {
1747
2018
  }
1748
2019
  _renderSegments() {
1749
2020
  if (!this._segmentsEl) return;
2021
+ this._drawnWithVersion = renderingVersionOf(this._cfg);
1750
2022
  this._segmentsEl.innerHTML = "";
1751
2023
  for (const segment of this._segments) {
1752
2024
  const renderer = resolveSegmentRenderer(segment.type, this._cfg);
@@ -1793,8 +2065,16 @@ class AparteChatBubble extends HTMLElement {
1793
2065
  timestampEl.textContent = "";
1794
2066
  }
1795
2067
  }
2068
+ /**
2069
+ * Named after its author when it has one: a `name` on the bubble is who wrote it (a human
2070
+ * agent answering on the assistant's side, a second assistant), and naming every one of
2071
+ * them "Assistant response" told a screen-reader user the opposite of what the page shows.
2072
+ * Without a name, the role names it.
2073
+ */
1796
2074
  _getAriaLabel() {
1797
2075
  const locale = this._cfg.getLocale();
2076
+ const name = this.getAttribute("name");
2077
+ if (name) return (locale.messageFrom || "Message from {name}").replace("{name}", () => name);
1798
2078
  return this._role === "user" ? locale.yourMessage ?? "Your message" : locale.assistantResponse ?? "Assistant response";
1799
2079
  }
1800
2080
  // ─────────────────────────────────────────────────────────────────────────
@@ -1852,9 +2132,9 @@ class AparteChatBubble extends HTMLElement {
1852
2132
  *
1853
2133
  * An attachment's `url` is documented as "URL or data URI" and a storage adapter
1854
2134
  * re-mints it when a conversation is restored, so it is the app's value, not the
1855
- * composer's — it was escaped and written, where every other URL core emits is
1856
- * asked whether its SCHEME is allowed at all. A picture whose URL is refused is
1857
- * not half-rendered: it falls back to the file chip, which still names the file.
2135
+ * composer's — and, like every other URL core emits, it is asked whether its SCHEME
2136
+ * is allowed at all. A picture whose URL is refused is not half-rendered: it falls
2137
+ * back to the file chip, which still names the file.
1858
2138
  *
1859
2139
  * `blob:` is added because it is the shape core itself mints — `filesToAttachments`
1860
2140
  * calls `URL.createObjectURL`, and `AparteMessage.attachments` documents the same
@@ -1875,21 +2155,12 @@ class AparteChatBubble extends HTMLElement {
1875
2155
  /**
1876
2156
  * The branch arrows are handled by DELEGATION, on this element, bound once.
1877
2157
  *
1878
- * They used to get a fresh listener each, attached by `_render()` to the buttons
1879
- * `_render()` had just created. So a click that landed while a re-render was
1880
- * swapping those nodes hit an element about to be discarded, and did nothing at
1881
- * all — not late, nothing. Invisible on a fast machine; reproducible on
1882
- * WebKit-Linux in CI, where `‹` left the picker on "2 / 2" and a 20-second
1883
- * assertion watched it stay there.
1884
- *
1885
- * Delegation makes `_render()` irrelevant to it: the listener lives on the host,
1886
- * which is never replaced, and `closest()` finds whichever button exists at the
1887
- * moment of the click. It is also less work — one listener per bubble instead of
1888
- * two per bubble per render.
2158
+ * The listener lives on the host, which is never replaced, and `closest()` finds
2159
+ * whichever button exists at the moment of the click — so a click that lands while
2160
+ * a re-render is swapping the buttons is not lost.
1889
2161
  *
1890
2162
  * Bound in `connectedCallback` and removed in `disconnectedCallback` as a stable
1891
- * field, because an inline arrow re-added on every re-connect is how this repo has
1892
- * stacked listeners twice before (the viewport, and `aparte-select`).
2163
+ * field, so a re-connect never stacks listeners.
1893
2164
  */
1894
2165
  _onBranchPickerClick = (event) => {
1895
2166
  const target = event.target;
@@ -1908,7 +2179,7 @@ class AparteChatBubble extends HTMLElement {
1908
2179
  /**
1909
2180
  * A link the MODEL wrote is about to be followed. Announced as a cancelable event
1910
2181
  * before the browser acts, so a host can route it — an external browser, a
1911
- * confirmation, an embedded view — without intercepting the DOM (issue #38). With
2182
+ * confirmation, an embedded view — without intercepting the DOM. With
1912
2183
  * no listener the browser follows it, which the sanitizer has already made open in
1913
2184
  * a new tab for an external URL; `preventDefault()` on the event cancels the click.
1914
2185
  *
@@ -2037,7 +2308,7 @@ class AparteChatBubble extends HTMLElement {
2037
2308
  }
2038
2309
  this._actionBarEl.innerHTML = buttons.join("");
2039
2310
  this._syncCopyAction();
2040
- this._appendCustomActions(icons);
2311
+ this._appendCustomActions();
2041
2312
  this._applyTranscriptBusy();
2042
2313
  this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
2043
2314
  btn.addEventListener("click", (e) => this._handleActionClick(e));
@@ -2047,18 +2318,14 @@ class AparteChatBubble extends HTMLElement {
2047
2318
  /**
2048
2319
  * `role="toolbar"` promises a keyboard model. This is it.
2049
2320
  *
2050
- * The bar has announced itself as a toolbar since it existed, and a toolbar is ONE
2051
- * tab stop whose members are reached with the arrow keys — that is the whole of what
2052
- * the role means to a screen-reader user. What shipped was five independent tab
2053
- * stops per message, so the role described a behaviour that did not exist: a reader
2054
- * told to press Right heard nothing move, and tabbing through a long transcript
2055
- * walked every button of every message.
2321
+ * A toolbar is ONE tab stop whose members are reached with the arrow keys — that is
2322
+ * the whole of what the role means to a screen-reader user. Without it, tabbing
2323
+ * through a long transcript would walk every button of every message.
2056
2324
  *
2057
2325
  * The roving index is re-applied from `_syncFooterVisibility`, which is the single
2058
- * funnel all three build paths already run through (the flag-driven bar, the edit
2059
- * bar, and the branch picker's own visibility pass). Re-deriving it there rather
2060
- * than in each builder is what keeps the invariant true after a `setBubbleActions`
2061
- * rebuild — the drift this pattern usually dies of.
2326
+ * funnel all three build paths run through (the flag-driven bar, the edit bar, and
2327
+ * the branch picker's own visibility pass). Re-deriving it there rather than in each
2328
+ * builder keeps the invariant true after a `setBubbleActions` rebuild.
2062
2329
  *
2063
2330
  * DISABLED buttons are skipped, not merely un-tabbable: while the transcript is busy
2064
2331
  * retry and edit are disabled, and a toolbar whose arrows stop on a dead control
@@ -2106,9 +2373,9 @@ class AparteChatBubble extends HTMLElement {
2106
2373
  buttons[next]?.focus();
2107
2374
  };
2108
2375
  /**
2109
- * An empty action bar is not a bar: with every action off it was still a
2110
- * `role="toolbar"` with nothing in it (announced as such), and it still reserved
2111
- * its fixed height plus the footer's under every bubble. So both follow their
2376
+ * An empty action bar is not a bar: with every action off it would be a
2377
+ * `role="toolbar"` with nothing in it (announced as such) that still reserves its
2378
+ * fixed height plus the footer's under every bubble. So both follow their
2112
2379
  * contents — the footer stays as long as the branch picker or the bar has
2113
2380
  * something to show.
2114
2381
  */
@@ -2122,7 +2389,7 @@ class AparteChatBubble extends HTMLElement {
2122
2389
  this.querySelector(".aparte-message")?.toggleAttribute("data-branches", !pickerHidden);
2123
2390
  }
2124
2391
  /** Append the registered custom action buttons for this bubble's role. */
2125
- _appendCustomActions(icons) {
2392
+ _appendCustomActions() {
2126
2393
  if (!this._actionBarEl) return;
2127
2394
  for (const a2 of this._cfg.getActions("bubble")) {
2128
2395
  const roles = a2.bubble?.roles ?? ["user", "assistant"];
@@ -2133,8 +2400,7 @@ class AparteChatBubble extends HTMLElement {
2133
2400
  btn.dataset["action"] = `custom:${a2.id}`;
2134
2401
  btn.setAttribute("aria-label", a2.label);
2135
2402
  btn.setAttribute("title", a2.label);
2136
- const fromProvider = icons[a2.icon];
2137
- btn.innerHTML = a2.icon.startsWith("<") ? a2.icon : typeof fromProvider === "function" ? fromProvider() : a2.iconFallback ?? "";
2403
+ btn.innerHTML = a2.icon.startsWith("<") ? a2.icon : this._cfg.getIcon(a2.icon) ?? a2.iconFallback ?? "";
2138
2404
  this._actionBarEl.appendChild(btn);
2139
2405
  }
2140
2406
  }
@@ -2323,6 +2589,8 @@ class AparteChatBubble extends HTMLElement {
2323
2589
  if (this._contentEl) this._contentEl.style.display = "";
2324
2590
  this.querySelector(".aparte-message")?.removeAttribute("data-editing");
2325
2591
  this._editing = false;
2592
+ this._redrawFrameIfChanged();
2593
+ this._redrawIfStale();
2326
2594
  this._updateActionBar();
2327
2595
  const returnTo = this._editReturnAction;
2328
2596
  this._editReturnAction = null;
@@ -2379,6 +2647,9 @@ class AparteChatBubble extends HTMLElement {
2379
2647
  }
2380
2648
  this._updateWaiting();
2381
2649
  if (wasStreaming && !streaming) this._updateContent();
2650
+ if (wasStreaming && !streaming && this._segments.length > 0 && renderingVersionOf(this._cfg) !== this._drawnWithVersion) {
2651
+ this._renderSegments();
2652
+ }
2382
2653
  }
2383
2654
  }
2384
2655
  if (!customElements.get("aparte-chat-bubble")) {
@@ -2398,6 +2669,10 @@ class AparteChatStatus extends HTMLElement {
2398
2669
  get _cfg() {
2399
2670
  return resolveConfig(this);
2400
2671
  }
2672
+ /** The word said when no `text` is set: the locale's `typing`, never an English literal. */
2673
+ _word() {
2674
+ return this._cfg.t("typing");
2675
+ }
2401
2676
  constructor() {
2402
2677
  super();
2403
2678
  }
@@ -2454,7 +2729,7 @@ class AparteChatStatus extends HTMLElement {
2454
2729
  return this.hasAttribute("visible");
2455
2730
  }
2456
2731
  _render() {
2457
- const text = this.getAttribute("text") || "Typing";
2732
+ const text = this.getAttribute("text") || this._word();
2458
2733
  const visible = this.hasAttribute("visible");
2459
2734
  if (this.querySelector(".aparte-status-container")) return;
2460
2735
  const custom = this._cfg?.getStatusRenderer?.();
@@ -2525,7 +2800,7 @@ class AparteChatStatus extends HTMLElement {
2525
2800
  const srEl = this.querySelector(".aparte-status-sr");
2526
2801
  if (!srEl) return;
2527
2802
  const dotsOnly = !this.getAttribute("text");
2528
- srEl.textContent = dotsOnly && this.hasAttribute("visible") ? "Typing" : "";
2803
+ srEl.textContent = dotsOnly && this.hasAttribute("visible") ? this._word() : "";
2529
2804
  }
2530
2805
  _updateVisibility(visible) {
2531
2806
  const container = this.querySelector(".aparte-status-container");
@@ -2537,8 +2812,17 @@ class AparteChatStatus extends HTMLElement {
2537
2812
  _updateText(text) {
2538
2813
  const container = this.querySelector(".aparte-status-container");
2539
2814
  if (!container) return;
2815
+ const custom = this._cfg?.getStatusRenderer?.();
2816
+ if (custom) {
2817
+ const next = text || this._word();
2818
+ container.setAttribute("aria-label", next);
2819
+ const result = runWithConfig(this._cfg, () => custom(next));
2820
+ if (result instanceof HTMLElement) container.replaceChildren(result);
2821
+ else container.innerHTML = result;
2822
+ return;
2823
+ }
2540
2824
  this._writeText(text);
2541
- container.setAttribute("aria-label", text || "Typing");
2825
+ container.setAttribute("aria-label", text || this._word());
2542
2826
  }
2543
2827
  }
2544
2828
  if (!customElements.get("aparte-chat-status")) {
@@ -2596,17 +2880,14 @@ class AparteChatViewport extends HTMLElement {
2596
2880
  * Not `readonly`: a test shortens it to run past the window's end in a few frames. */
2597
2881
  _settleWindowMs = 400;
2598
2882
  /**
2599
- * Until when a smooth scroll of ours is in flight (#57). A user send glides to the
2600
- * bottom with `scrollTo({ behavior: 'smooth' })`; the assistant placeholder and the
2601
- * first tokens used to land INSIDE that glide through the streaming path — an instant
2602
- * `scrollTop = scrollHeight` plus the settle chain — and an instant write cancels a
2603
- * running smooth scroll in every engine. Measured before the fix: five instant writes
2604
- * in the send's own frame (the spacer recalculation scrolls synchronously, the
2605
- * mutation observer queues another), so there never was a glide to cut — the view
2606
- * teleported. While this deadline is ahead, every bottom-pin RE-TARGETS the glide
2607
- * (a second smooth `scrollTo` is continuous) instead of writing `scrollTop`; the
2608
- * browser's `scrollend` closes it early where supported. Streaming is instant again
2609
- * after that, as it must be to keep up with token bursts.
2883
+ * Until when a smooth scroll of ours is in flight. A user send glides to the
2884
+ * bottom with `scrollTo({ behavior: 'smooth' })`, and the assistant placeholder and
2885
+ * first tokens land inside that glide. An instant `scrollTop = scrollHeight` write
2886
+ * cancels a running smooth scroll in every engine, so while this deadline is ahead
2887
+ * every bottom-pin RE-TARGETS the glide (a second smooth `scrollTo` is continuous)
2888
+ * instead of writing `scrollTop`; the browser's `scrollend` closes it early where
2889
+ * supported. Streaming is instant after that, as it must be to keep up with token
2890
+ * bursts.
2610
2891
  */
2611
2892
  _glideUntil = 0;
2612
2893
  /** The glide's budget when `scrollend` is not there to close it: Chromium's smooth scroll over a viewport takes ~300–500 ms. */
@@ -2630,6 +2911,26 @@ class AparteChatViewport extends HTMLElement {
2630
2911
  _maxRenderedBubbles = 1e3;
2631
2912
  _resizeObserver = null;
2632
2913
  _mutationObserver = null;
2914
+ /**
2915
+ * Watches the size of the bubbles the bottom spacer measures — the last question and
2916
+ * everything after it. A bubble that has never been on screen measures its
2917
+ * `content-visibility` fallback (320px) until it is shown, and taking its real height
2918
+ * is neither a mutation nor a resize of the scroll container: without this, a spacer
2919
+ * sized from two fallbacks stayed wrong once they came into view.
2920
+ */
2921
+ _tailObserver = null;
2922
+ _tail = [];
2923
+ /** Each tail bubble's border-box height, as measured when it started being watched and as last reported. */
2924
+ _tailHeights = /* @__PURE__ */ new WeakMap();
2925
+ /**
2926
+ * Opening or closing a `<details>` — a tool row, a reasoning block, an artifact card, a host's
2927
+ * own — changes the transcript's height through an ATTRIBUTE, which neither observer sees: the
2928
+ * mutation observer watches children, and the resize observer leaves the wrapper out on
2929
+ * purpose. `toggle` does not bubble, so it is caught on the way down.
2930
+ */
2931
+ _onDisclosureToggle = () => {
2932
+ this._scheduleSpacerUpdate();
2933
+ };
2633
2934
  /** The `[overlay-composer]` shell this viewport floats in, if any. Read at observer setup, like `framework-managed`. */
2634
2935
  _overlayRoot = null;
2635
2936
  /** Watches the overlay shell's childList, so a stack child that mounts later (elicitation) gets observed too. */
@@ -2710,8 +3011,8 @@ class AparteChatViewport extends HTMLElement {
2710
3011
  window.addEventListener("aparte-config-change", this._onConfigChange);
2711
3012
  }
2712
3013
  /**
2713
- * A locale switch changes the reading direction, and `dir` was applied once at
2714
- * render — so a chat already on screen never flipped to RTL until a reload.
3014
+ * A locale switch changes the reading direction, and `dir` follows it, so a chat
3015
+ * already on screen flips to RTL without a reload.
2715
3016
  * Only OUR config: an instance-scoped change elsewhere must not touch us.
2716
3017
  */
2717
3018
  _onConfigChange = (e) => {
@@ -2728,15 +3029,14 @@ class AparteChatViewport extends HTMLElement {
2728
3029
  * A scrollable region that no element can hold focus in cannot be scrolled from
2729
3030
  * the keyboard in WebKit: Chromium and Firefox hand an unfocusable overflow box a
2730
3031
  * caret or a "scroller" tab stop of their own, Safari does not. So a plain-text
2731
- * transcript — no links, no code blocks, nothing focusable inside — was unreadable
2732
- * past the first screen for a Safari keyboard user, with no error and nothing on
2733
- * screen to say so.
3032
+ * transcript — no links, no code blocks, nothing focusable inside — would be
3033
+ * unreadable past the first screen for a Safari keyboard user, with no error and
3034
+ * nothing on screen to say so.
2734
3035
  *
2735
- * `tabindex="0"` on the surface itself, in BOTH DOM modes. The framework mode
2736
- * appeared to work already, and only by accident: the scroll button is inside the
2737
- * host and stays tabbable while it is visually hidden, so Tab happened to land
2738
- * somewhere that scrolled. A tab stop that exists because a hidden button happens
2739
- * to sit there is not an affordance, it is a coincidence.
3036
+ * `tabindex="0"` on the surface itself, in BOTH DOM modes. In framework mode the
3037
+ * scroll button is inside the host and stays tabbable while it is visually hidden,
3038
+ * but a tab stop that exists because a hidden button happens to sit there is a
3039
+ * coincidence, not an affordance.
2740
3040
  *
2741
3041
  * The name ships WITH the tab stop rather than after it: a focusable `role="log"`
2742
3042
  * with no accessible name is announced as an unnamed region, which is a worse
@@ -2865,10 +3165,10 @@ class AparteChatViewport extends HTMLElement {
2865
3165
  * The id of the message currently streaming, if any — searched across the whole
2866
3166
  * TREE, and answering with an OFF-HEAD one first when there are two.
2867
3167
  *
2868
- * It used to scan `getMessages()`, the active path. A retry or an edit on an
3168
+ * Not a scan of `getMessages()`, the active path: a retry or an edit on an
2869
3169
  * earlier bubble calls `addSiblingOf`, which switches the branch to the new pending
2870
- * reply: the turn still being written leaves the path, the scan found nothing, and
2871
- * the rest of that turn was appended to the message that had just replaced it.
3170
+ * reply, so the turn still being written leaves the path and a path scan would find
3171
+ * nothing, appending the rest of that turn to the message that just replaced it.
2872
3172
  *
2873
3173
  * A new pending reply is itself marked streaming, so "the first one found" would
2874
3174
  * answer the head and let the write through — the very case this exists to refuse.
@@ -2891,10 +3191,10 @@ class AparteChatViewport extends HTMLElement {
2891
3191
  /**
2892
3192
  * While a reply streams, the transcript is read-only except for Stop.
2893
3193
  *
2894
- * The streaming message's own footer was already hidden; every OTHER message kept
2895
- * its branch picker and its retry/edit buttons live. Swapping a branch mid-stream
2896
- * re-rendered the active path under the reply being written, and a retry cut that
2897
- * reply off to start another — both seen on the landing page. The state that
3194
+ * The streaming message's own footer is hidden, and the branch picker and the
3195
+ * retry/edit buttons of every OTHER message are disabled: swapping a branch mid-stream would
3196
+ * re-render the active path under the reply being written, and a retry would cut
3197
+ * that reply off to start another. The state that
2898
3198
  * decides it is the whole transcript's, so it lives on the viewport (`data-busy`)
2899
3199
  * and is pushed to the bubbles it holds; a bubble mounted later, under a
2900
3200
  * framework's DOM, reads the attribute itself when it connects.
@@ -3349,10 +3649,13 @@ class AparteChatViewport extends HTMLElement {
3349
3649
  }
3350
3650
  }
3351
3651
  /**
3352
- * Enable or disable auto-scroll
3652
+ * Enable or disable auto-scroll. Turned off, it stays off through the scroll that comes
3653
+ * next — a jump of the page's own, smooth or not — and comes back once the reader is at
3654
+ * the bottom again.
3353
3655
  */
3354
3656
  setAutoScroll(enabled) {
3355
3657
  this._isAutoScrollEnabled = enabled;
3658
+ if (!enabled) this._readerInputAt = performance.now();
3356
3659
  }
3357
3660
  /**
3358
3661
  * Signal that a framework (e.g. Angular) manages the bubble DOM.
@@ -3701,7 +4004,20 @@ class AparteChatViewport extends HTMLElement {
3701
4004
  });
3702
4005
  }
3703
4006
  this._scheduleSpacerUpdate();
4007
+ const movesBubbles = (nodes) => Array.from(nodes).some((n2) => n2 instanceof Element && (n2.tagName === "APARTE-CHAT-BUBBLE" || n2.querySelector("aparte-chat-bubble") !== null));
4008
+ if (mutations.some((m2) => movesBubbles(m2.addedNodes) || movesBubbles(m2.removedNodes))) this._observeTail();
4009
+ });
4010
+ this._tailObserver = new ResizeObserver((entries) => {
4011
+ let changed = false;
4012
+ for (const entry of entries) {
4013
+ const height = Math.round(entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height);
4014
+ if (this._tailHeights.get(entry.target) !== height) changed = true;
4015
+ this._tailHeights.set(entry.target, height);
4016
+ }
4017
+ if (changed) this._scheduleSpacerUpdate();
3704
4018
  });
4019
+ this._observeTail();
4020
+ this.addEventListener("toggle", this._onDisclosureToggle, true);
3705
4021
  const observeTarget = this._frameworkManagedDOM ? this : wrapper;
3706
4022
  if (observeTarget) {
3707
4023
  this._mutationObserver.observe(observeTarget, {
@@ -3753,10 +4069,9 @@ class AparteChatViewport extends HTMLElement {
3753
4069
  * the transcript's padding plus the scrollbar gutter the scroller reserves on both
3754
4070
  * edges, at whatever step its container query has taken — as
3755
4071
  * `--aparte-transcript-inset`. The composer pads by it (composer.css), so its box
3756
- * lands on the row's box at every width. The two used to be independent stacks: the
3757
- * composer cannot know the gutter and a container query cannot reach it, so they sat
3758
- * 10px apart at 768 and the gutter's half apart at 1280, and the reading-column demo
3759
- * showed three left edges 12px apart. Written only when the value changes, for the
4072
+ * lands on the row's box at every width. The composer cannot know the gutter
4073
+ * and a container query cannot reach it, so this is the one place both boxes read
4074
+ * it from. Written only when the value changes, for the
3760
4075
  * same reason `_updateOverlayInset` is. On the HOST, not on this element: the
3761
4076
  * composer is a sibling, and a custom property only travels down.
3762
4077
  */
@@ -3787,7 +4102,8 @@ class AparteChatViewport extends HTMLElement {
3787
4102
  const drop = this._lastScrollTop - top;
3788
4103
  const churn = Math.abs(height - this._lastScrollHeight);
3789
4104
  const now = performance.now();
3790
- const afterMutation = now - this._lastMutationAt < this._settleShadowMs && drop <= this._settleBoundPx;
4105
+ const shrank = height < this._lastScrollHeight - 1;
4106
+ const afterMutation = now - this._lastMutationAt < this._settleShadowMs && (drop <= this._settleBoundPx || shrank);
3791
4107
  const settlingOurs = now - this._ownScrollAt < this._ownScrollWindowMs && now - this._readerInputAt >= this._ownScrollWindowMs && !this._pointerHeld && (drop <= churn + 2 || afterMutation);
3792
4108
  const readerWentUp = drop > 1 && !settlingOurs;
3793
4109
  const readerHandOnIt = drop > 1 && now - this._readerInputAt < this._ownScrollWindowMs;
@@ -3830,9 +4146,8 @@ class AparteChatViewport extends HTMLElement {
3830
4146
  * The browser says a scroll came to rest — the glide is over ONLY if it rested at the
3831
4147
  * bottom. WebKit fires `scrollend` when a smooth scroll is REPLACED by another (our own
3832
4148
  * re-target), 45–60 ms into the glide and hundreds of pixels short; taken at its word,
3833
- * that closed the window and the next pin was the cut this guard exists to prevent
3834
- * (react-webkit and vanilla-webkit, 2 of 2). Short of the bottom, the budget keeps
3835
- * bounding the window.
4149
+ * that would close the window and the next pin would be the cut this guard exists
4150
+ * to prevent. Short of the bottom, the budget keeps bounding the window.
3836
4151
  */
3837
4152
  _handleScrollEnd() {
3838
4153
  if (!this._container) return;
@@ -3842,37 +4157,27 @@ class AparteChatViewport extends HTMLElement {
3842
4157
  /**
3843
4158
  * Confirm over the next few frames that we actually reached the bottom.
3844
4159
  *
3845
- * One assignment is not enough, and the reason is measured rather than guessed.
3846
- * A timeline of a streamed turn on Safari (framework mode) recorded the content
3847
- * settling in TWO layout passes — 1118 → 1121 → 1152 px. `scrollTop =
3848
- * scrollHeight` ran against the middle one, clamped to that layout's max (603),
3849
- * and nothing ran afterwards: the last 31px never closed. Auto-follow stayed
3850
- * armed the whole time, so the component was not disarmed — it was SATISFIED.
3851
- * `_isAtBottom()` answers "yes" for any gap under `_scrollThreshold` (50), which
3852
- * is the right rule for keeping auto-follow armed and the wrong one as a
3853
- * definition of "anchored".
3854
- *
3855
- * Ruled out on the way here, so nobody pays for it twice: not a WebKit
3856
- * padding-accounting difference (a probe writing `scrollTop = 1e7` reached
3857
- * exactly `scrollHeight - clientHeight`), not a missing `characterData`
3858
- * mutation, and not a child resize a ResizeObserver could see.
4160
+ * One assignment is not enough. Content can settle in several layout passes
4161
+ * (1118 → 1121 → 1152 px on Safari in framework mode), and `scrollTop =
4162
+ * scrollHeight` run against a middle pass clamps to that layout's max and leaves
4163
+ * the rest unclosed. Auto-follow stays armed the whole time, so the component is
4164
+ * not disarmed — it is SATISFIED: `_isAtBottom()` answers "yes" for any gap under
4165
+ * `_scrollThreshold` (50), which is the right rule for keeping auto-follow armed
4166
+ * and the wrong one as a definition of "anchored".
3859
4167
  *
3860
4168
  * A BOUNDED retry, not one corrective frame: a single frame lands on the same
3861
- * stale layout and was measured leaving a wider gap than doing nothing. Bounded
3862
- * so it always terminates; re-reads `_isAutoScrollEnabled` every frame so a
3863
- * reader who scrolls away mid-settle is left alone.
4169
+ * stale layout and can leave a wider gap than doing nothing. Bounded so it always
4170
+ * terminates; re-reads `_isAutoScrollEnabled` every frame so a reader who scrolls
4171
+ * away mid-settle is left alone.
3864
4172
  *
3865
- * Bounded by TIME, and it does not stop at the first closed gap — both of those
3866
- * were the second half of the same bug. It used to be four frames (~64ms on an
3867
- * idle 60Hz machine) and to return permanently the frame the gap first closed.
3868
- * A branch swap at the bottom of a long transcript on react-webkit falsified
3869
- * both at once: the rebuild's scrollable max churned 891 -> 1091 -> 891, the gap
3870
- * WAS closed for a frame against the tall layout, the chain returned, the height
3871
- * fell back with WebKit holding `scrollTop` at 720 — and the transcript stood
3872
- * 171px short with auto-follow still armed. A frame count is a proxy for time
3873
- * that fails exactly on the slow engine, so the budget is real milliseconds now,
3874
- * and a closed gap only ends the chain when the window is over. One chain, not
3875
- * one per call: during a stream every token used to start its own.
4173
+ * Bounded by TIME, and it does not stop at the first closed gap. A branch swap at
4174
+ * the bottom of a long transcript churns the scrollable max (891 -> 1091 -> 891):
4175
+ * the gap is closed for a frame against the tall layout, the height falls back with
4176
+ * WebKit holding `scrollTop`, and the transcript would stand short with auto-follow
4177
+ * still armed. A frame count is a proxy for time that fails exactly on the slow
4178
+ * engine, so the budget is real milliseconds, and a closed gap only ends the chain
4179
+ * when the window is over. One chain, not one per call: during a stream every token
4180
+ * would otherwise start its own.
3876
4181
  */
3877
4182
  _settleAtBottom() {
3878
4183
  this._settleUntil = performance.now() + this._settleWindowMs;
@@ -3904,6 +4209,7 @@ class AparteChatViewport extends HTMLElement {
3904
4209
  this._glideSettleTimer = null;
3905
4210
  if (!this._container || !this._isAutoScrollEnabled || this._gliding()) return;
3906
4211
  const max = this._container.scrollHeight - this._container.clientHeight;
4212
+ if (this._container.scrollTop < this._lastScrollTop - 1) return;
3907
4213
  if (max - this._container.scrollTop > 1) this._scrollToBottom();
3908
4214
  }, this._glideWindowMs + 16);
3909
4215
  } else {
@@ -3919,10 +4225,10 @@ class AparteChatViewport extends HTMLElement {
3919
4225
  *
3920
4226
  * The two answer different questions: the flag is intent ("should new content
3921
4227
  * pull the view down"), the button is a fact ("is there anything below the
3922
- * fold"). Mirroring the flag made the button lie whenever the two diverged —
4228
+ * fold"). Mirroring the flag would make the button lie whenever the two diverge —
3923
4229
  * `navigateBranch` deliberately disarms auto-follow, so swapping a branch while
3924
- * already at the bottom of a scrollable transcript left the button offering to
3925
- * scroll nowhere (reported from a React consumer). Re-derived on scroll, on the
4230
+ * already at the bottom of a scrollable transcript would leave the button offering
4231
+ * to scroll nowhere. Re-derived on scroll, on the
3926
4232
  * post-mutation frame and after a path swap, so it converges to the truth
3927
4233
  * whatever a framework's render timing does in between.
3928
4234
  */
@@ -3975,13 +4281,29 @@ class AparteChatViewport extends HTMLElement {
3975
4281
  return;
3976
4282
  }
3977
4283
  const contentBelowUserTop = scrollHeightWithoutSpacer - userTopInContent;
3978
- const needed = this._container.clientHeight - contentBelowUserTop;
4284
+ const covered = parseFloat(getComputedStyle(this._container).scrollPaddingTop) || 0;
4285
+ const needed = this._container.clientHeight - covered - contentBelowUserTop;
3979
4286
  const maxSpacer = this._container.clientHeight;
3980
4287
  this._setSpacerHeight(Math.min(Math.max(0, needed), maxSpacer));
3981
4288
  if (this._isAutoScrollEnabled) {
3982
4289
  this._scrollToBottom();
3983
4290
  }
3984
4291
  }
4292
+ /** Point `_tailObserver` at the last question and the bubbles after it, and only those. */
4293
+ _observeTail() {
4294
+ if (!this._tailObserver) return;
4295
+ const bubbles = Array.from(this.querySelectorAll("aparte-chat-bubble"));
4296
+ let from = bubbles.length;
4297
+ while (from > 0 && bubbles[from - 1].getAttribute("data-role") !== "user") from--;
4298
+ const tail = from > 0 ? bubbles.slice(from - 1) : [];
4299
+ for (const el2 of this._tail) if (!tail.includes(el2)) this._tailObserver.unobserve(el2);
4300
+ for (const el2 of tail) {
4301
+ if (this._tail.includes(el2)) continue;
4302
+ this._tailHeights.set(el2, Math.round(el2.offsetHeight));
4303
+ this._tailObserver.observe(el2, { box: "border-box" });
4304
+ }
4305
+ this._tail = tail;
4306
+ }
3985
4307
  /**
3986
4308
  * Schedule a spacer recalculation on the next animation frame.
3987
4309
  * Batches multiple rapid calls (e.g. during token streaming) into one.
@@ -4037,6 +4359,10 @@ class AparteChatViewport extends HTMLElement {
4037
4359
  this.removeEventListener("aparte-branch-navigate", this._onBranchNavigate);
4038
4360
  this._resizeObserver?.disconnect();
4039
4361
  this._mutationObserver?.disconnect();
4362
+ this._tailObserver?.disconnect();
4363
+ this._tailObserver = null;
4364
+ this._tail = [];
4365
+ this.removeEventListener("toggle", this._onDisclosureToggle, true);
4040
4366
  this._overlayObserver?.disconnect();
4041
4367
  this._overlayObserver = null;
4042
4368
  this._overlayRoot = null;
@@ -4071,14 +4397,11 @@ class AparteComposer extends HTMLElement {
4071
4397
  /**
4072
4398
  * Who owns the one panel slot, and how to tell them they lost it.
4073
4399
  *
4074
- * There is exactly one slot, `showPanel` empties it unconditionally, and nothing
4075
- * used to say whose it was. Three paths therefore closed a panel whose owner was
4076
- * still awaiting an answer, and none of them told the owner: a second `showPanel`,
4077
- * the owner-of-record's own `hidePanel`, and — the one that actually bit —
4078
- * `_handleMessageDone`, which fires on EVERY turn end. A question still open when a
4079
- * turn finished lost its panel while `<aparte-elicitation>` kept `_pending` set,
4080
- * so the request never settled AND every later request was short-circuited for the
4081
- * life of the page.
4400
+ * There is exactly one slot and `showPanel` empties it unconditionally, so every
4401
+ * path that closes a panel whose owner is still awaiting an answer — a second
4402
+ * `showPanel`, `hidePanel` without the owner's token, `_handleMessageDone` (which
4403
+ * fires on EVERY turn end) — must tell the owner. An owner that is not told keeps
4404
+ * its request pending, and every later request is short-circuited behind it.
4082
4405
  */
4083
4406
  _panelToken = null;
4084
4407
  _panelOnEvict = null;
@@ -4101,10 +4424,10 @@ class AparteComposer extends HTMLElement {
4101
4424
  *
4102
4425
  * A wrapper runs `AparteChatHost.bind()` — which calls `attachConfig` — from its
4103
4426
  * POST-mount hook, so this element connects BEFORE the boundary exists. Caching
4104
- * here latched the global config forever: an instance `config` carrying an RTL
4105
- * locale flipped the transcript and not the composer, and the
4106
- * `requireModelSelection` gate was read off the wrong object. `aparte-chat-bubble`
4107
- * has always resolved live, and its JSDoc names this exact race.
4427
+ * would latch the global config forever: an instance `config` carrying an RTL
4428
+ * locale would flip the transcript and not the composer, and the
4429
+ * `requireModelSelection` gate would be read off the wrong object.
4430
+ * `aparte-chat-bubble` resolves live too, and its JSDoc names this race.
4108
4431
  */
4109
4432
  get _cfg() {
4110
4433
  return resolveConfig(this);
@@ -4125,10 +4448,10 @@ class AparteComposer extends HTMLElement {
4125
4448
  * Mirror `locale.direction` onto ourselves, so everything inside — input, buttons,
4126
4449
  * the toolbar row and whatever the consumer put in it — inherits it.
4127
4450
  *
4128
- * The direction used to stop at the transcript: only the viewport applied `dir`, so
4129
- * an RTL locale flipped the conversation and left the composer left-to-right. It is
4130
- * also what makes the toolbar's placement idiom real: `margin-inline-start: auto` in
4131
- * a subtree that inherits no direction is just `margin-left`.
4451
+ * Without it an RTL locale flips the conversation and leaves the composer
4452
+ * left-to-right. It is also what makes the toolbar's placement idiom real:
4453
+ * `margin-inline-start: auto` in a subtree that inherits no direction is just
4454
+ * `margin-left`.
4132
4455
  *
4133
4456
  * One attribute on the host rather than a stamp per child: inheritance is the
4134
4457
  * mechanism, so nothing needs to know about the consumer's markup.
@@ -4182,9 +4505,9 @@ class AparteComposer extends HTMLElement {
4182
4505
  /**
4183
4506
  * The attribute is the state, so the property writes it. It has to exist: React 19
4184
4507
  * and Svelte assign the PROPERTY whenever the element has one of that name, and a
4185
- * getter alone made `<aparte-composer disabled="">` — a spelling the wrappers type
4186
- * as valid — throw and take the render down. `presenceOn` because `''` is what
4187
- * those templates hand a presence property (#62).
4508
+ * getter alone would make `<aparte-composer disabled="">` — a spelling the wrappers
4509
+ * type as valid — throw and take the render down. `presenceOn` because `''` is what
4510
+ * those templates hand a presence property.
4188
4511
  */
4189
4512
  set disabled(value) {
4190
4513
  this.toggleAttribute("disabled", presenceOn(value));
@@ -4270,27 +4593,23 @@ class AparteComposer extends HTMLElement {
4270
4593
  *
4271
4594
  * While a panel is up, the composer is answering a QUESTION, not composing a
4272
4595
  * message — so the affordances that lead nowhere go away with the text input:
4273
- * the attachment picker above all, which stayed clickable while the user was
4274
- * being asked something ("on voyait encore l'icône de upload", reported from a
4275
- * real session). Ratified decision #8: an affordance nothing can honour is not
4276
- * rendered.
4596
+ * the attachment picker above all, which would otherwise stay clickable while the
4597
+ * user is being asked something. An affordance nothing can honour is not rendered.
4277
4598
  *
4278
4599
  * What STAYS, deliberately: the attachments strip, because pending attachments
4279
4600
  * are the user's state and not an action to offer — hiding them would look like
4280
4601
  * losing them; and the toolbar, because switching model still does something.
4281
4602
  *
4282
4603
  * The send button is the part the PANEL decides, through `mode`. `'submit'`
4283
- * keeps it; `'none'` says this panel has no act for it and it is not drawn. That third value is what a panel whose options settle on the first click
4284
- * needs — a single-choice question, an approval — and until it existed such a
4285
- * panel left a permanently disabled button beside options that never routed
4286
- * through it. Flip between them at any time with {@link setPanelSubmitEnabled},
4287
- * which is how a panel that grows an act (an "Other…" field, a written
4288
- * instruction) turns the button back on.
4604
+ * keeps it; `'none'` says this panel has no act for it and it is not drawn. `'none'`
4605
+ * is what a panel whose options settle on the first click needs — a single-choice
4606
+ * question, an approval. Flip between them at any time with
4607
+ * {@link setPanelSubmitEnabled}, which is how a panel that grows an act (an
4608
+ * "Other…" field, a written instruction) turns the button back on.
4289
4609
  *
4290
- * Declared with an attribute + CSS rather than the inline `style.display` this
4291
- * used to set on a child: an attribute is themeable, is visible to a consumer's
4292
- * own rules, and does not clobber a `display` the consumer had set (the restore
4293
- * wrote `''`, not the previous value).
4610
+ * The mode is declared with an attribute + CSS rather than inline `style.display`
4611
+ * on a child: an attribute is themeable, is visible to a consumer's own rules, and
4612
+ * does not clobber a `display` the consumer set.
4294
4613
  *
4295
4614
  * Returns the TOKEN for this panel. Pass it back to `hidePanel` so a presenter
4296
4615
  * that has already lost the slot cannot close the panel that replaced it, and
@@ -4329,18 +4648,6 @@ class AparteComposer extends HTMLElement {
4329
4648
  * no token it closes whatever is there, which is what a consumer driving the
4330
4649
  * composer directly means.
4331
4650
  *
4332
- * BOTH forms notify. The no-token form used to call `_teardownPanel` directly,
4333
- * which nulls `_panelOnEvict` without ever calling it — so the documented public
4334
- * `hidePanel()` closed an open approval panel and left its request pending
4335
- * forever. The turn hung on "waiting for you", and because
4336
- * `AparteConfig.requestUserInput` chains each request on the previous one, NO
4337
- * further question or approval on that config was ever presented again, for the
4338
- * life of the page.
4339
- *
4340
- * The old JSDoc justified the silent branch as "what `reset()` needs". It was not:
4341
- * `reset()` calls `_evictPanel()`, which notifies. The branch had no consumer and
4342
- * one failure mode.
4343
- *
4344
4651
  * The two forms differ on purpose, and the difference is who already knows:
4345
4652
  *
4346
4653
  * - **With a matching token** the OWNER is closing its own panel, which is what
@@ -4400,10 +4707,10 @@ class AparteComposer extends HTMLElement {
4400
4707
  /**
4401
4708
  * Publish the panel mode as an attribute, so CSS can act on it.
4402
4709
  *
4403
- * The same reasoning `data-panel-active` is set for and the same one that took
4404
- * the old `style.display` off a child: an attribute is themeable, is visible to
4405
- * a consumer's own rules, and does not clobber what the consumer set. It is what
4406
- * lets `'none'` remove the send button without this component reaching into it.
4710
+ * The same reasoning `data-panel-active` is set for: an attribute is themeable, is
4711
+ * visible to a consumer's own rules, and does not clobber what the consumer set. It
4712
+ * is what lets `'none'` remove the send button without this component reaching
4713
+ * into it.
4407
4714
  */
4408
4715
  _reflectPanelMode() {
4409
4716
  if (this._panelActive) this.setAttribute("data-panel-mode", this._panelMode);
@@ -4484,6 +4791,7 @@ class AparteComposer extends HTMLElement {
4484
4791
  input?.focus();
4485
4792
  }
4486
4793
  // ── Internal pub/sub ────────────────────────────────────────────────────
4794
+ /** @internal The composer parts' own bus. From outside, listen for `aparte-composer-change`. */
4487
4795
  _emit(event, payload) {
4488
4796
  this._listeners.get(event)?.forEach((cb) => cb(payload));
4489
4797
  if (AparteComposer._STATE_EVENTS.has(event)) {
@@ -4494,6 +4802,7 @@ class AparteComposer extends HTMLElement {
4494
4802
  }));
4495
4803
  }
4496
4804
  }
4805
+ /** @internal The composer parts' own bus. From outside, listen for `aparte-composer-change`. */
4497
4806
  _on(event, cb) {
4498
4807
  if (!this._listeners.has(event)) this._listeners.set(event, /* @__PURE__ */ new Set());
4499
4808
  this._listeners.get(event).add(cb);
@@ -4511,15 +4820,12 @@ class AparteComposer extends HTMLElement {
4511
4820
  * Which chat this composer belongs to: its `target` attribute, or the id of the
4512
4821
  * chat host above it.
4513
4822
  *
4514
- * All four wrappers set `target` themselves, so the attribute alone identified a
4515
- * composer there. In RAW core — the documented quick start, where the markup is
4516
- * hand-written — nothing sets it, so `!this.targetId` was true and this composer
4517
- * accepted every chat's lifecycle events: on a two-chat page, one chat's Stop
4518
- * tore down the other's open elicitation panel while its tool call kept waiting,
4519
- * i.e. the question vanished and the turn hung.
4823
+ * All four wrappers set `target` themselves. In RAW core — the documented quick
4824
+ * start, where the markup is hand-written — nothing sets it, so the chat host above
4825
+ * is what identifies the composer; without either, it would accept every chat's
4826
+ * lifecycle events and, on a two-chat page, one chat's Stop would tear down the
4827
+ * other's open elicitation panel.
4520
4828
  *
4521
- * Found by a two-chat test written for the elicitation presenter, which is the
4522
- * only reason it surfaced: raw core with two chats is a shape nothing exercised.
4523
4829
  * The hosts matched are the ones `aparte-chat-bubble._resolveTargetId()` matches,
4524
4830
  * for the reason written there — Angular's wrapper root IS `<aparte-chat>`, the
4525
4831
  * other three render a `[data-aparte-chat]` div.
@@ -4557,11 +4863,11 @@ class AparteComposer extends HTMLElement {
4557
4863
  * Whether a terminal event ends the turn this composer is following.
4558
4864
  *
4559
4865
  * `_isForThisComposer` answers "is this chat's?", and both turns of a retry belong
4560
- * to the same chat: the client aborts the superseded reply, the engine emits
4561
- * `run-aborted` for it, and that unwind turned Stop back into Send while the new
4562
- * reply was still streaming — and evicted the open panel, i.e. the approval question
4563
- * a tool was still waiting on. An event with no `messageId` still ends the turn: a
4564
- * loop of one's own saying "done" has always meant this one.
4866
+ * to the same chat: the client aborts the superseded reply and the engine emits
4867
+ * `run-aborted` for it, which must not turn Stop back into Send while the new reply
4868
+ * is still streaming, nor evict the open panel (the approval question a tool is
4869
+ * still waiting on). An event with no `messageId` still ends the turn: a loop of
4870
+ * one's own saying "done" means this one.
4565
4871
  */
4566
4872
  _isForThisTurn(e) {
4567
4873
  const id = e.detail?.messageId;
@@ -4589,11 +4895,7 @@ class AparteComposerInput extends HTMLElement {
4589
4895
  _maxHeight = 200;
4590
4896
  _minHeight = 44;
4591
4897
  /**
4592
- * Re-measures the editor when its WIDTH changes (#55). The three fixed instants
4593
- * (connect, next frame, fonts.ready) all miss the same case: a box not yet laid
4594
- * out — the placeholder wraps in a 0 px column, `scrollHeight` reads ~300 px, and
4595
- * that became the inline height until the first keystroke. Width is the variable
4596
- * none of them watched.
4898
+ * Re-measures the editor when its WIDTH changes.
4597
4899
  */
4598
4900
  _resizeObserver = null;
4599
4901
  _unsubscribes = [];
@@ -4778,9 +5080,10 @@ class AparteComposerInput extends HTMLElement {
4778
5080
  e.preventDefault();
4779
5081
  const cd = e.clipboardData;
4780
5082
  if (!cd) return;
4781
- const imageFile = Array.from(cd.items).find((i2) => i2.type.startsWith("image/"))?.getAsFile();
5083
+ const root = this._getRoot();
5084
+ const imageFile = root?.querySelector("aparte-composer-add-attachment") ? Array.from(cd.items).find((i2) => i2.type.startsWith("image/"))?.getAsFile() : void 0;
4782
5085
  if (imageFile) {
4783
- this._getRoot()?.addAttachments([imageFile]);
5086
+ root?.addAttachments([imageFile]);
4784
5087
  return;
4785
5088
  }
4786
5089
  const text = cd.getData("text/plain");
@@ -4842,9 +5145,8 @@ class AparteComposerSend extends HTMLElement {
4842
5145
  *
4843
5146
  * This button has four meanings — send, stop, submit an answer, advance to the
4844
5147
  * next question — and three of them are decided by state it does not own: the
4845
- * root's `streaming`, and this payload. It was read straight out of the event's
4846
- * arguments and thrown away, so nothing could recompute the button's chrome
4847
- * afterwards; a config change had no way to know which of the four to write.
5148
+ * root's `streaming`, and this payload. It is kept so the button's chrome can be
5149
+ * recomputed after the event, for example on a config change.
4848
5150
  */
4849
5151
  _panel = null;
4850
5152
  // Bound handler
@@ -4927,14 +5229,13 @@ class AparteComposerSend extends HTMLElement {
4927
5229
  this._syncState();
4928
5230
  }
4929
5231
  /**
4930
- * Panel open: this one button now means "submit the answer".
5232
+ * Panel open: this one button means "submit the answer".
4931
5233
  *
4932
- * The icon has to move with the meaning: it drew a paper plane while the label
4933
- * already said "Submit", so it read as "send a message" while it meant "answer
4934
- * this question". It used to draw a chevron too, while a form had questions
4935
- * ahead, and turn into a "Next" — a second way to do what the panel's chips do,
4936
- * and a button whose meaning changed under the pointer. It is disabled instead
4937
- * until every question has an answer, which is what the chips' marks explain.
5234
+ * The icon moves with the meaning: a paper plane would read as "send a message"
5235
+ * while the label says "Submit". The button never turns into a "Next" while a form
5236
+ * has questions ahead (the panel's chips do that, and a button whose meaning
5237
+ * changed under the pointer would be a second way to do it). It is disabled
5238
+ * instead until every question has an answer, which is what the chips' marks explain.
4938
5239
  */
4939
5240
  _syncPanelState() {
4940
5241
  const panel = this._panel;
@@ -5099,7 +5400,7 @@ class AparteComposerAttachments extends HTMLElement {
5099
5400
  this.hidden = files.length === 0;
5100
5401
  this._revokeUrls();
5101
5402
  const cfg = resolveConfig(this);
5102
- const removeLabel = (file) => (cfg.t("removeAttachment") || "Remove {name}").replace("{name}", file.name);
5403
+ const removeLabel = (file) => (cfg.t("removeAttachment") || "Remove {name}").replace("{name}", () => file.name);
5103
5404
  this.innerHTML = files.map((file) => {
5104
5405
  const name = escapeHtml(file.name);
5105
5406
  const remove = `<button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-thumb__remove" type="button" aria-label="${escapeAttr(removeLabel(file))}">${cfg.getIcon("close")}</button>`;
@@ -5396,6 +5697,21 @@ class AparteComposerToolbar extends HTMLElement {
5396
5697
  if (!customElements.get("aparte-composer-toolbar")) {
5397
5698
  customElements.define("aparte-composer-toolbar", AparteComposerToolbar);
5398
5699
  }
5700
+ function foldText(text) {
5701
+ return text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
5702
+ }
5703
+ function filterConversationRows(root, needle) {
5704
+ let matches = 0;
5705
+ for (const row of root.querySelectorAll("[data-conv-id]")) {
5706
+ const title = row.querySelector(".aparte-conv-item__title")?.textContent ?? row.textContent ?? "";
5707
+ row.hidden = needle !== "" && !foldText(title).includes(needle);
5708
+ if (!row.hidden) matches++;
5709
+ }
5710
+ for (const group of root.querySelectorAll(".aparte-conv-group")) {
5711
+ group.hidden = needle !== "" && !Array.from(group.querySelectorAll("[data-conv-id]")).some((r) => !r.hidden);
5712
+ }
5713
+ return matches;
5714
+ }
5399
5715
  function rowSelector(attr, id) {
5400
5716
  return `[${attr}="${cssEscape(id)}"]`;
5401
5717
  }
@@ -5427,11 +5743,10 @@ class AparteConversationList extends HTMLElement {
5427
5743
  * Let the list finish the gesture through the registered conversation manager.
5428
5744
  * Reflected as the `manage` attribute.
5429
5745
  *
5430
- * The property is not a nicety here: this is the attribute that authorises a WRITE,
5431
- * and Vue and both Sveltes stringify a bound `false` onto an element that has no
5432
- * property of that name — `manage="false"`, which `hasAttribute` reads as on. So
5433
- * turning management off turned it on, and a delete the host meant to handle itself
5434
- * was also done by the list. `presenceOn`, like `loading` above it.
5746
+ * The property exists because this is the attribute that authorises a WRITE, and Vue
5747
+ * and both Sveltes stringify a bound `false` onto an element that has no property of
5748
+ * that name — `manage="false"`, which `hasAttribute` reads as on. Without it,
5749
+ * turning management off would turn it on. `presenceOn`, like `loading` above it.
5435
5750
  */
5436
5751
  get manage() {
5437
5752
  return this.hasAttribute("manage");
@@ -5499,13 +5814,12 @@ class AparteConversationList extends HTMLElement {
5499
5814
  /**
5500
5815
  * A press holds every render until its click has landed. The host re-assigns
5501
5816
  * `conversations` on every manager change (a save moves a row, a select marks one),
5502
- * and a row replaced between `mousedown` and `mouseup` lost its click: Chromium sent
5503
- * it to the list, where the delegated handler matched nothing, and WebKit sent none.
5504
- * A conversation clicked while the list settled did not open. Keeping the row node
5505
- * would not have saved it: a row moved to its new place leaves the document on the
5506
- * way, and the row that moves is often the one pressed (the conversation just
5507
- * saved). So nothing changes under a pointer that is down, and the render runs once,
5508
- * after the click.
5817
+ * and a row replaced between `mousedown` and `mouseup` loses its click: Chromium
5818
+ * sends it to the list, where the delegated handler matches nothing, and WebKit
5819
+ * sends none. Keeping the row node would not help: a row moved to its new place
5820
+ * leaves the document on the way, and the row that moves is often the one pressed
5821
+ * (the conversation just saved). So nothing changes under a pointer that is down,
5822
+ * and the render runs once, after the click.
5509
5823
  */
5510
5824
  _pressed = false;
5511
5825
  _renderAfterPress = false;
@@ -5525,7 +5839,14 @@ class AparteConversationList extends HTMLElement {
5525
5839
  if (this.isConnected) this._render();
5526
5840
  }, 0);
5527
5841
  };
5842
+ /** The name this element last wrote, so one the app wrote — before or after connect — is left alone. */
5843
+ _ownLabel = null;
5528
5844
  _render() {
5845
+ const current = this.getAttribute("aria-label");
5846
+ if (!this.hasAttribute("aria-labelledby") && (current === null || current === this._ownLabel)) {
5847
+ this._ownLabel = this._t("conversationList");
5848
+ this.setAttribute("aria-label", this._ownLabel);
5849
+ }
5529
5850
  if (this._pressed) {
5530
5851
  this._renderAfterPress = true;
5531
5852
  return;
@@ -5536,10 +5857,12 @@ class AparteConversationList extends HTMLElement {
5536
5857
  const held = focused && this.contains(focused) ? focused.closest("[data-select-id], [data-more-id]") : null;
5537
5858
  const restore = held?.dataset["selectId"] ? rowSelector("data-select-id", held.dataset["selectId"]) : held?.dataset["moreId"] ? rowSelector("data-more-id", held.dataset["moreId"]) : null;
5538
5859
  const heldRow = held?.closest("[data-conv-id]") ?? null;
5539
- const heldIndex = heldRow ? Array.from(this.querySelectorAll("[data-conv-id]")).indexOf(heldRow) : -1;
5860
+ const visible = (el2) => !el2.closest("[hidden]");
5861
+ const heldIndex = heldRow ? Array.from(this.querySelectorAll("[data-conv-id]")).filter(visible).indexOf(heldRow) : -1;
5540
5862
  const loading = this.hasAttribute("loading");
5541
5863
  this.setAttribute("aria-busy", loading ? "true" : "false");
5542
5864
  if (loading) {
5865
+ this.removeAttribute("data-no-match");
5543
5866
  const row = '<span class="aparte-skeleton aparte-skeleton--text"></span>';
5544
5867
  this.innerHTML = '<div class="aparte-conv-list-loading" aria-hidden="true">' + row + row + row + row + row + row + '</div><span class="aparte-conv-list-loading-status aparte-sr-only" role="status"></span>';
5545
5868
  const status = this.querySelector(".aparte-conv-list-loading-status");
@@ -5550,14 +5873,22 @@ class AparteConversationList extends HTMLElement {
5550
5873
  else say();
5551
5874
  return;
5552
5875
  }
5553
- this.innerHTML = this._groups().map((g2) => this._renderGroup(g2)).join("");
5876
+ const line = this.querySelector(":scope > .aparte-conv-list-no-match");
5877
+ const groups = this._groups().map((g2) => this._renderGroup(g2)).join("");
5878
+ if (line) {
5879
+ for (const child of Array.from(this.childNodes)) if (child !== line) child.remove();
5880
+ line.insertAdjacentHTML("beforebegin", groups);
5881
+ } else {
5882
+ this.innerHTML = groups;
5883
+ }
5884
+ this._applyFilter();
5554
5885
  if (!restore) return;
5555
5886
  const back = this.querySelector(restore);
5556
- if (back) {
5887
+ if (back && visible(back)) {
5557
5888
  back.focus();
5558
5889
  return;
5559
5890
  }
5560
- const rows = Array.from(this.querySelectorAll("[data-select-id]"));
5891
+ const rows = Array.from(this.querySelectorAll("[data-select-id]")).filter(visible);
5561
5892
  const next = heldIndex >= 0 ? rows[heldIndex] ?? rows[rows.length - 1] : null;
5562
5893
  if (next) {
5563
5894
  next.focus();
@@ -5569,6 +5900,41 @@ class AparteConversationList extends HTMLElement {
5569
5900
  }
5570
5901
  this.focus();
5571
5902
  }
5903
+ /** The query `filter()` holds, folded; re-applied on every render, so new rows obey it. */
5904
+ _query = "";
5905
+ /**
5906
+ * Show only the conversations whose title contains `query`, case- and
5907
+ * accent-insensitive, and the date groups that still hold one; return how many match.
5908
+ * An empty query shows everything. The query is kept through every render — a new
5909
+ * conversation, a rename — and when nothing matches the list says so in a status line
5910
+ * (the locale's `conversationSearchEmpty`) and carries `data-no-match`.
5911
+ */
5912
+ filter(query) {
5913
+ this._query = foldText(query);
5914
+ return this._applyFilter();
5915
+ }
5916
+ _applyFilter() {
5917
+ const needle = this._query;
5918
+ if (this.hasAttribute("loading")) return 0;
5919
+ const matches = filterConversationRows(this, needle);
5920
+ const noMatch = needle !== "" && matches === 0;
5921
+ this.toggleAttribute("data-no-match", noMatch);
5922
+ let line = this.querySelector(":scope > .aparte-conv-list-no-match");
5923
+ const born = !line;
5924
+ if (!line) {
5925
+ line = document.createElement("p");
5926
+ line.className = "aparte-conv-list-no-match";
5927
+ line.setAttribute("role", "status");
5928
+ this.appendChild(line);
5929
+ }
5930
+ const text = noMatch ? this._t("conversationSearchEmpty") : "";
5931
+ const write = () => {
5932
+ if (line.isConnected && line.textContent !== text) line.textContent = text;
5933
+ };
5934
+ if (born && text && typeof requestAnimationFrame === "function") requestAnimationFrame(write);
5935
+ else write();
5936
+ return matches;
5937
+ }
5572
5938
  _t(key) {
5573
5939
  const locale = resolveConfig(this).getLocale();
5574
5940
  return locale[key] ?? APARTE_DEFAULT_LOCALE[key] ?? "";
@@ -5686,7 +6052,7 @@ class AparteConversationList extends HTMLElement {
5686
6052
  }
5687
6053
  /** The question the confirm step asks — the popover's accessible name while it asks it. */
5688
6054
  _confirmQuestion(conv) {
5689
- return this._t("deleteConversationConfirm").replace("{title}", conv.title || this._t("newChat"));
6055
+ return this._t("deleteConversationConfirm").replace("{title}", () => conv.title || this._t("newChat"));
5690
6056
  }
5691
6057
  _confirmMarkup(conv) {
5692
6058
  const question = escapeHtml(this._confirmQuestion(conv));
@@ -6062,10 +6428,9 @@ class AparteSuggestions extends HTMLElement {
6062
6428
  /** `hidden` was written by this element, so it is this element's to lift. */
6063
6429
  _hidOwn = false;
6064
6430
  /*
6065
- * `empty-only` used to read the send alone: hidden on the first one, and never shown
6066
- * again. Over a restored thread (nothing sent yet) the starters sat on top of the
6067
- * transcript, and after a new chat they never came back. The transcript is the
6068
- * signal now; the send stays as the other half, because it comes first — the row
6431
+ * `empty-only` follows the transcript: shown while it is empty, hidden once it has
6432
+ * messages, so a restored thread (nothing sent yet) has no starters on top of it
6433
+ * and a new chat brings them back. The send is the other half, because it comes first — the row
6069
6434
  * hides on the click rather than a frame later when the bubble lands — and because
6070
6435
  * it is all there is to read when the messages are not `<aparte-chat-bubble>`s
6071
6436
  * (a wrapper's `renderBubble`) or there is no transcript at all. A transcript seen
@@ -6096,9 +6461,8 @@ class AparteSuggestions extends HTMLElement {
6096
6461
  *
6097
6462
  * Both, because both arrive: React 19 and Svelte assign the PROPERTY whenever the
6098
6463
  * element has one of that name, so `<aparte-suggestions suggestions='[…]'>` — the
6099
- * documented spelling, typed valid by both wrappers — reached this setter as a
6100
- * string, `Array.isArray` said no, and the row rendered nothing while the attribute
6101
- * (and so `attributeChangedCallback`) was never written.
6464
+ * documented spelling, typed valid by both wrappers — reaches this setter as a
6465
+ * string while the attribute (and so `attributeChangedCallback`) is never written.
6102
6466
  */
6103
6467
  set suggestions(value) {
6104
6468
  this._suggestions = typeof value === "string" ? parseSuggestions(value) : Array.isArray(value) ? value.filter(isSuggestion) : [];
@@ -6254,9 +6618,13 @@ class AparteSuggestions extends HTMLElement {
6254
6618
  return;
6255
6619
  }
6256
6620
  if (composer !== this._composer) this._watchComposer();
6621
+ const chipHadFocus = this.contains(document.activeElement);
6257
6622
  composer.setValue(detail.prompt);
6258
6623
  if (this.getAttribute("mode") === "fill") composer.focus();
6259
- else composer.submit();
6624
+ else {
6625
+ composer.submit();
6626
+ if (chipHadFocus) composer.focus();
6627
+ }
6260
6628
  }
6261
6629
  }
6262
6630
  if (!customElements.get("aparte-suggestions")) {
@@ -6289,7 +6657,7 @@ const ringSvg = () => {
6289
6657
  };
6290
6658
  class AparteContext extends HTMLElement {
6291
6659
  static get observedAttributes() {
6292
- return ["window", "warn", "danger", "target", "variant"];
6660
+ return ["window", "warn", "danger", "target", "variant", "used"];
6293
6661
  }
6294
6662
  _used = null;
6295
6663
  _level = null;
@@ -6323,10 +6691,18 @@ class AparteContext extends HTMLElement {
6323
6691
  this._compactRequested = false;
6324
6692
  };
6325
6693
  _onRerender = () => this._render();
6326
- /** Tokens the last turn reported, or `null` before the first turn. */
6694
+ /** Tokens the last turn reported — or `used` set, until a turn reports — else `null`. */
6327
6695
  get used() {
6328
6696
  return this._used;
6329
6697
  }
6698
+ /**
6699
+ * Writes the `used` attribute: a reading for a conversation restored without a turn of
6700
+ * this page, which had nothing to report until the next one. `null` clears it.
6701
+ */
6702
+ set used(value) {
6703
+ if (value === null || value === void 0) this.removeAttribute("used");
6704
+ else this.setAttribute("used", String(value));
6705
+ }
6330
6706
  /** The window in force — the attribute, else the current model's. */
6331
6707
  get window() {
6332
6708
  return this._window();
@@ -6359,7 +6735,11 @@ class AparteContext extends HTMLElement {
6359
6735
  window.removeEventListener("aparte-compact-error", this._onCompactError);
6360
6736
  window.removeEventListener("aparte-config-change", this._onRerender);
6361
6737
  }
6362
- attributeChangedCallback() {
6738
+ attributeChangedCallback(name) {
6739
+ if (name === "used") {
6740
+ const n2 = Number(this.getAttribute("used"));
6741
+ this._used = this.hasAttribute("used") && Number.isFinite(n2) && n2 >= 0 ? n2 : null;
6742
+ }
6363
6743
  if (this.isConnected) this._render();
6364
6744
  }
6365
6745
  _window() {
@@ -6665,12 +7045,11 @@ class AparteScrollRail extends HTMLElement {
6665
7045
  * intersection observer reports which bubbles reach a reading band — the top 30% of
6666
7046
  * the surface, from its very top so that the bubble a jump aligned there counts. The
6667
7047
  * rule is a scrollspy's: the LAST question that has reached the band is the one the
6668
- * reader is under. "The largest visible share" was the rule before, and at the bottom
6669
- * of a thread of short turns, where three questions sit in the band, it named the
6670
- * first of them. Scroll arithmetic was the alternative, and every scroll-position
6671
- * rule in the viewport carries a regression note; the one read here is the bottom,
6672
- * because at the bottom the reader is under the latest question whatever the band
6673
- * holds, and the observer has nothing to report on the last few pixels.
7048
+ * reader is under. (The largest visible share would name the first of three questions
7049
+ * sitting in the band at the bottom of a thread of short turns.) The one scroll
7050
+ * position read is the bottom, because there the reader is under the latest question
7051
+ * whatever the band holds, and the observer has nothing to report on the last few
7052
+ * pixels.
6674
7053
  */
6675
7054
  _observe(bubbles) {
6676
7055
  this._unobserve();
@@ -6842,14 +7221,13 @@ class AparteScrollRail extends HTMLElement {
6842
7221
  * Scroll the transcript to a message. Announced first, cancelable; then a
6843
7222
  * `scrollIntoView` on the bubble, smooth unless the reader prefers reduced motion.
6844
7223
  * The clicked tick is the mark until the transcript has stopped moving: while a
6845
- * smooth scroll crosses other questions the band would name each in turn, and it
6846
- * ended on the wrong one two times in three before this hold.
7224
+ * smooth scroll crosses other questions the band would name each in turn.
6847
7225
  *
6848
7226
  * Where the scroll lands is checked once it has settled, and corrected. The bubbles
6849
7227
  * carry `content-visibility: auto`, so a bubble far from the reader has an estimated
6850
7228
  * height until it is rendered: the scroll aims at an estimate, the bubbles above it
6851
- * take their real size on the way, and the message ends up above or below the top —
6852
- * measured from 36px to 1,213px off, in all three engines, the further the worse.
7229
+ * take their real size on the way, and the message ends up above or below the top,
7230
+ * the further away the worse.
6853
7231
  * A second `scrollIntoView` from a rendered neighbourhood lands; a few are allowed.
6854
7232
  */
6855
7233
  jumpTo(messageId) {
@@ -6862,6 +7240,7 @@ class AparteScrollRail extends HTMLElement {
6862
7240
  if (!announced) return;
6863
7241
  const bubble = this._bubbleOf(messageId);
6864
7242
  if (!bubble) return;
7243
+ this._viewport()?.setAutoScroll?.(false);
6865
7244
  this._scrollTo(bubble);
6866
7245
  this._setCurrent(messageId);
6867
7246
  this._hold(messageId);
@@ -6892,7 +7271,8 @@ class AparteScrollRail extends HTMLElement {
6892
7271
  const surface = this._settleSurface;
6893
7272
  const bubble = id ? this._bubbleOf(id) : null;
6894
7273
  if (id && bubble && surface && this._alignPasses < MAX_ALIGN_PASSES) {
6895
- const off = bubble.getBoundingClientRect().top - surface.getBoundingClientRect().top;
7274
+ const aim = surface.getBoundingClientRect().top + (parseFloat(getComputedStyle(surface).scrollPaddingTop) || 0);
7275
+ const off = bubble.getBoundingClientRect().top - aim;
6896
7276
  if (Math.abs(off) > ALIGN_TOLERANCE) {
6897
7277
  this._alignPasses++;
6898
7278
  this._scrollTo(bubble);
@@ -6963,6 +7343,10 @@ class AparteSidebar extends HTMLElement {
6963
7343
  * Same convention as `_relabel`, which leaves a host-authored `aria-label` alone.
6964
7344
  */
6965
7345
  _ownHidden = false;
7346
+ /** The query `filter()` was last given, kept so the sidebar's no-match can follow its lists. */
7347
+ _query = "";
7348
+ /** Watches the lists' own `data-no-match` and `loading`, which change with their renders. */
7349
+ _listWatch = null;
6966
7350
  /** Whether the sidebar is collapsed (hidden in the flow, or closed as a drawer). */
6967
7351
  get collapsed() {
6968
7352
  return this.hasAttribute("collapsed");
@@ -6983,6 +7367,12 @@ class AparteSidebar extends HTMLElement {
6983
7367
  this._hostIntentSeeded = true;
6984
7368
  }
6985
7369
  this.addEventListener("input", this._onInput);
7370
+ if (typeof MutationObserver === "function") {
7371
+ this._listWatch = new MutationObserver((records) => {
7372
+ if (records.some((r) => r.target !== this)) this._syncNoMatch();
7373
+ });
7374
+ this._listWatch.observe(this, { subtree: true, attributes: true, attributeFilter: ["data-no-match", "loading"] });
7375
+ }
6986
7376
  document.addEventListener("keydown", this._onKeydown);
6987
7377
  document.addEventListener("click", this._onDocumentClick);
6988
7378
  window.addEventListener("aparte-config-change", this._onConfigChange);
@@ -7014,6 +7404,8 @@ class AparteSidebar extends HTMLElement {
7014
7404
  }
7015
7405
  disconnectedCallback() {
7016
7406
  this.removeEventListener("input", this._onInput);
7407
+ this._listWatch?.disconnect();
7408
+ this._listWatch = null;
7017
7409
  document.removeEventListener("keydown", this._onKeydown);
7018
7410
  document.removeEventListener("click", this._onDocumentClick);
7019
7411
  window.removeEventListener("aparte-config-change", this._onConfigChange);
@@ -7192,20 +7584,43 @@ class AparteSidebar extends HTMLElement {
7192
7584
  this.filter(input.value);
7193
7585
  };
7194
7586
  /**
7195
- * Hide the conversation rows whose title does not contain `query`, and the date
7196
- * groups left empty. Case- and accent-insensitive. An empty query shows everything.
7587
+ * Filter the conversation lists inside by title (case- and accent-insensitive; an
7588
+ * empty query shows everything), and return how many conversations match. Each list
7589
+ * keeps the query through its own renders and says when nothing matches; the
7590
+ * sidebar carries `data-no-match` then too.
7197
7591
  */
7198
7592
  filter(query) {
7199
- const needle = fold(query);
7200
- for (const list of this.querySelectorAll("aparte-conversation-list")) {
7201
- for (const row of list.querySelectorAll("[data-conv-id]")) {
7202
- const title = row.querySelector(".aparte-conv-item__title")?.textContent ?? row.textContent ?? "";
7203
- row.hidden = needle !== "" && !fold(title).includes(needle);
7204
- }
7205
- for (const group of list.querySelectorAll(".aparte-conv-group")) {
7206
- group.hidden = needle !== "" && !Array.from(group.querySelectorAll("[data-conv-id]")).some((r) => !r.hidden);
7593
+ this._query = query;
7594
+ let matches = 0;
7595
+ let waiting = false;
7596
+ for (const list of this._lists()) {
7597
+ if (typeof list.filter === "function") matches += list.filter(query);
7598
+ else {
7599
+ matches += filterConversationRows(list, foldText(query));
7600
+ waiting = true;
7207
7601
  }
7208
7602
  }
7603
+ if (waiting && typeof customElements !== "undefined") {
7604
+ void customElements.whenDefined("aparte-conversation-list").then(() => {
7605
+ if (this._query === query) for (const list of this._lists()) list.filter?.(query);
7606
+ this._syncNoMatch();
7607
+ });
7608
+ }
7609
+ this._syncNoMatch();
7610
+ return matches;
7611
+ }
7612
+ _lists() {
7613
+ return Array.from(this.querySelectorAll("aparte-conversation-list"));
7614
+ }
7615
+ /**
7616
+ * `data-no-match` while the held query matches nothing in any list — read from the lists
7617
+ * themselves, so it follows their renders (a conversation that matches arriving, the last
7618
+ * match leaving) and is never set while one of them is still loading.
7619
+ */
7620
+ _syncNoMatch() {
7621
+ const needle = foldText(this._query);
7622
+ const none = needle !== "" && this._lists().every((list) => typeof list.filter === "function" ? list.hasAttribute("data-no-match") : !list.hasAttribute("loading") && filterConversationRows(list, needle) === 0);
7623
+ this.toggleAttribute("data-no-match", none);
7209
7624
  }
7210
7625
  // ─── Locale ───────────────────────────────────────────────────────────
7211
7626
  _onConfigChange = () => this._relabel();
@@ -7231,9 +7646,6 @@ function visibleFocusable(root) {
7231
7646
  function nearestSidebar(control) {
7232
7647
  return control.closest("aparte-sidebar") ?? rootOf(control).querySelector("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
7233
7648
  }
7234
- function fold(text) {
7235
- return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
7236
- }
7237
7649
  if (!customElements.get("aparte-sidebar")) {
7238
7650
  customElements.define("aparte-sidebar", AparteSidebar);
7239
7651
  }
@@ -7385,7 +7797,7 @@ class AparteSplit extends HTMLElement {
7385
7797
  get stacked() {
7386
7798
  return this.hasAttribute("data-stacked") || this.hasAttribute("single") || this.classList.contains("aparte-split--only-start") || this.classList.contains("aparte-split--only-end");
7387
7799
  }
7388
- /** One pane on demand, whatever the width (#54). Reflected. */
7800
+ /** One pane on demand, whatever the width. Reflected. */
7389
7801
  get single() {
7390
7802
  return this.hasAttribute("single");
7391
7803
  }
@@ -7456,6 +7868,7 @@ class AparteSplit extends HTMLElement {
7456
7868
  this._watchBreakpoint();
7457
7869
  this._watchSize();
7458
7870
  this._ready = true;
7871
+ this._syncPaneControls();
7459
7872
  }
7460
7873
  disconnectedCallback() {
7461
7874
  this._handle?.removeEventListener("pointerdown", this._onPointerDown);
@@ -7507,6 +7920,7 @@ class AparteSplit extends HTMLElement {
7507
7920
  }
7508
7921
  case "pane": {
7509
7922
  const pane = this.pane;
7923
+ this._syncPaneControls();
7510
7924
  if (pane === this._lastPane) return;
7511
7925
  this._lastPane = pane;
7512
7926
  this._commit();
@@ -7524,6 +7938,7 @@ class AparteSplit extends HTMLElement {
7524
7938
  case "single":
7525
7939
  if (this.single && this._dragging) this._endDrag("pointer", this._dragStartPercent);
7526
7940
  this._stampHandle();
7941
+ this._syncPaneControls();
7527
7942
  return;
7528
7943
  case "breakpoint":
7529
7944
  this._watchBreakpoint();
@@ -7606,8 +8021,8 @@ class AparteSplit extends HTMLElement {
7606
8021
  *
7607
8022
  * While STACKED there is nothing to read: one pane is `display: none` and the other
7608
8023
  * spans the single track, so the ratio is 0 or 100 whatever the seam was set to.
7609
- * Measuring it there is how tapping "Preview" on a phone used to write `position="0"`
7610
- * into the attribute, the event and the host's storage.
8024
+ * Measuring it there would write `position="0"` into the attribute, the event and the
8025
+ * host's storage when "Preview" is tapped on a phone.
7611
8026
  */
7612
8027
  _achievedPercent() {
7613
8028
  if (this.stacked) return this._position;
@@ -7938,6 +8353,7 @@ class AparteSplit extends HTMLElement {
7938
8353
  const was = this.hasAttribute("data-stacked");
7939
8354
  if (stacked && !was && this._dragging) this._endDrag("pointer", this._dragStartPercent);
7940
8355
  this.toggleAttribute("data-stacked", stacked);
8356
+ if (stacked !== was) this._syncPaneControls();
7941
8357
  if (stacked && !was) {
7942
8358
  if (!this.hasAttribute("pane")) this.showPane("start");
7943
8359
  } else if (!stacked && was) {
@@ -7969,6 +8385,28 @@ class AparteSplit extends HTMLElement {
7969
8385
  }
7970
8386
  this.showPane(which ?? (this.pane === "start" ? "end" : "start"));
7971
8387
  };
8388
+ /**
8389
+ * The two-button switch says which pane it shows: `aria-pressed` on every
8390
+ * `[data-aparte-split-pane="start"|"end"]` that reaches this split, true on the one
8391
+ * naming the pane in view, while the split is stacked; side by side both panes are in
8392
+ * view and neither button is pressed, so none carries it. A toggle (an id, or empty) is
8393
+ * an action and gets none. Read when the split connects, whenever `pane` changes and
8394
+ * when it stacks or unstacks, so a control added later is stamped at the next switch.
8395
+ */
8396
+ _syncPaneControls() {
8397
+ const roots = /* @__PURE__ */ new Set([rootOf(this), document]);
8398
+ for (const root of roots) {
8399
+ for (const control of root.querySelectorAll("[data-aparte-split-pane]")) {
8400
+ const which = (control.getAttribute("data-aparte-split-pane") ?? "").trim();
8401
+ if (which !== "start" && which !== "end") continue;
8402
+ if (control.localName !== "button" && control.getAttribute("role") !== "button") continue;
8403
+ const target = control.closest("aparte-split") ?? rootOf(control).querySelector("aparte-split") ?? document.querySelector("aparte-split");
8404
+ if (target !== this) continue;
8405
+ if (this.stacked) control.setAttribute("aria-pressed", String(which === this.pane));
8406
+ else control.removeAttribute("aria-pressed");
8407
+ }
8408
+ }
8409
+ }
7972
8410
  // ─── Locale ───────────────────────────────────────────────────────────
7973
8411
  _onConfigChange = () => this._relabel();
7974
8412
  /**