@aparte/core 0.19.3 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/dist/client/aparte-client.d.ts +66 -95
  2. package/dist/client/aparte-client.d.ts.map +1 -1
  3. package/dist/client/stream-adapter.d.ts +7 -13
  4. package/dist/client/stream-adapter.d.ts.map +1 -1
  5. package/dist/client/wire-history.d.ts +4 -5
  6. package/dist/client/wire-history.d.ts.map +1 -1
  7. package/dist/components/bubble/aparte-chat-bubble.d.ts +88 -57
  8. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  9. package/dist/components/chat/aparte-chat.d.ts +15 -13
  10. package/dist/components/chat/aparte-chat.d.ts.map +1 -1
  11. package/dist/components/chat/glide-gate.d.ts +13 -0
  12. package/dist/components/chat/glide-gate.d.ts.map +1 -0
  13. package/dist/components/composer/aparte-composer-action.d.ts +2 -2
  14. package/dist/components/composer/aparte-composer-add-attachment.d.ts +1 -1
  15. package/dist/components/composer/aparte-composer-input.d.ts +1 -5
  16. package/dist/components/composer/aparte-composer-input.d.ts.map +1 -1
  17. package/dist/components/composer/aparte-composer-send.d.ts +8 -10
  18. package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
  19. package/dist/components/composer/aparte-composer.d.ts +48 -80
  20. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  21. package/dist/components/context/aparte-context.d.ts +9 -4
  22. package/dist/components/context/aparte-context.d.ts.map +1 -1
  23. package/dist/components/conversation-list/aparte-conversation-list.d.ts +23 -13
  24. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  25. package/dist/components/elicitation/aparte-elicitation.d.ts +15 -22
  26. package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
  27. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts +10 -11
  28. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -1
  29. package/dist/components/sidebar/aparte-sidebar.d.ts +16 -3
  30. package/dist/components/sidebar/aparte-sidebar.d.ts.map +1 -1
  31. package/dist/components/split/aparte-split.d.ts +12 -3
  32. package/dist/components/split/aparte-split.d.ts.map +1 -1
  33. package/dist/components/status/aparte-chat-status.d.ts.map +1 -1
  34. package/dist/components/suggestions/aparte-suggestions.d.ts +6 -9
  35. package/dist/components/suggestions/aparte-suggestions.d.ts.map +1 -1
  36. package/dist/components/viewport/aparte-chat-viewport.d.ts +70 -70
  37. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  38. package/dist/config/aparte-config.d.ts +88 -67
  39. package/dist/config/aparte-config.d.ts.map +1 -1
  40. package/dist/config/icon-provider.d.ts +19 -2
  41. package/dist/config/icon-provider.d.ts.map +1 -1
  42. package/dist/config/index.d.ts +1 -1
  43. package/dist/config/index.d.ts.map +1 -1
  44. package/dist/config/locale.d.ts +57 -89
  45. package/dist/config/locale.d.ts.map +1 -1
  46. package/dist/config/sanitize.d.ts.map +1 -1
  47. package/dist/conversations/conversation-controller.d.ts +18 -3
  48. package/dist/conversations/conversation-controller.d.ts.map +1 -1
  49. package/dist/conversations/conversation-manager.d.ts +3 -3
  50. package/dist/conversations/conversation-manager.d.ts.map +1 -1
  51. package/dist/custom-elements.json +7626 -6906
  52. package/dist/{dialog-triggers-ChnPFG9M.js → dialog-triggers-3Rk7PuZa.js} +444 -287
  53. package/dist/dialog-triggers-3Rk7PuZa.js.map +1 -0
  54. package/dist/elicitation/approval-panel.d.ts.map +1 -1
  55. package/dist/elicitation/index.d.ts +6 -1
  56. package/dist/elicitation/index.d.ts.map +1 -1
  57. package/dist/elicitation/panel.d.ts +11 -21
  58. package/dist/elicitation/panel.d.ts.map +1 -1
  59. package/dist/elicitation/types.d.ts +19 -27
  60. package/dist/elicitation/types.d.ts.map +1 -1
  61. package/dist/generated/element-attributes.d.ts +11 -5
  62. package/dist/generated/element-attributes.d.ts.map +1 -1
  63. package/dist/host/aparte-chat-host.d.ts +17 -32
  64. package/dist/host/aparte-chat-host.d.ts.map +1 -1
  65. package/dist/icons/extended.d.ts +1 -2
  66. package/dist/icons/extended.d.ts.map +1 -1
  67. package/dist/icons.d.ts +6 -8
  68. package/dist/icons.d.ts.map +1 -1
  69. package/dist/icons.js.map +1 -1
  70. package/dist/index.css +1 -1
  71. package/dist/index.d.ts +12 -9
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +682 -327
  74. package/dist/index.js.map +1 -1
  75. package/dist/index.node.d.ts +6 -3
  76. package/dist/index.node.d.ts.map +1 -1
  77. package/dist/index.node.js +2 -2
  78. package/dist/index.node.js.map +1 -1
  79. package/dist/interop/dialog-triggers.d.ts +2 -2
  80. package/dist/interop/element-props.d.ts +16 -27
  81. package/dist/interop/element-props.d.ts.map +1 -1
  82. package/dist/primitives/icon/aparte-icon.d.ts.map +1 -1
  83. package/dist/primitives/select/aparte-option.d.ts +3 -2
  84. package/dist/primitives/select/aparte-option.d.ts.map +1 -1
  85. package/dist/primitives/select/aparte-select.d.ts +24 -21
  86. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  87. package/dist/renderers/markdown-stream.d.ts.map +1 -1
  88. package/dist/renderers/segment-renderers.d.ts +11 -17
  89. package/dist/renderers/segment-renderers.d.ts.map +1 -1
  90. package/dist/renderers/segments/code.d.ts.map +1 -1
  91. package/dist/renderers/segments/error.d.ts.map +1 -1
  92. package/dist/renderers/segments/text.d.ts.map +1 -1
  93. package/dist/renderers/segments/tool-call.d.ts.map +1 -1
  94. package/dist/runtime/message-repository.d.ts +8 -15
  95. package/dist/runtime/message-repository.d.ts.map +1 -1
  96. package/dist/transport/backend-handler.d.ts +4 -6
  97. package/dist/transport/backend-handler.d.ts.map +1 -1
  98. package/dist/transport/backend-transport.d.ts +14 -0
  99. package/dist/transport/backend-transport.d.ts.map +1 -1
  100. package/dist/transport/types.d.ts +7 -10
  101. package/dist/transport/types.d.ts.map +1 -1
  102. package/dist/types/chat.d.ts +10 -18
  103. package/dist/types/chat.d.ts.map +1 -1
  104. package/dist/types/element-attributes.d.ts +6 -12
  105. package/dist/types/element-attributes.d.ts.map +1 -1
  106. package/dist/types/element-map.d.ts +6 -19
  107. package/dist/types/element-map.d.ts.map +1 -1
  108. package/dist/types/event-map.d.ts +11 -17
  109. package/dist/types/event-map.d.ts.map +1 -1
  110. package/dist/types/events.d.ts +5 -14
  111. package/dist/types/events.d.ts.map +1 -1
  112. package/dist/types/index.d.ts +2 -2
  113. package/dist/types/index.d.ts.map +1 -1
  114. package/dist/types/model-provider.d.ts +7 -9
  115. package/dist/types/model-provider.d.ts.map +1 -1
  116. package/dist/types/segments.d.ts +21 -13
  117. package/dist/types/segments.d.ts.map +1 -1
  118. package/dist/types/tools.d.ts +20 -22
  119. package/dist/types/tools.d.ts.map +1 -1
  120. package/dist/utils/copy-text.d.ts +1 -2
  121. package/dist/utils/copy-text.d.ts.map +1 -1
  122. package/dist/utils/css-escape.d.ts +1 -1
  123. package/dist/utils/escape.d.ts +4 -19
  124. package/dist/utils/escape.d.ts.map +1 -1
  125. package/dist/utils/files-to-attachments.d.ts +2 -2
  126. package/dist/utils/fold-text.d.ts +13 -0
  127. package/dist/utils/fold-text.d.ts.map +1 -0
  128. package/dist/utils/is-awaiting-reply.d.ts +2 -5
  129. package/dist/utils/is-awaiting-reply.d.ts.map +1 -1
  130. package/dist/utils/presence.d.ts +5 -5
  131. package/dist/utils/segments.d.ts +54 -78
  132. package/dist/utils/segments.d.ts.map +1 -1
  133. package/dist/utils/tool-input.d.ts +1 -1
  134. package/dist/utils/tool-input.d.ts.map +1 -1
  135. package/dist/utils/transcript.d.ts +2 -2
  136. package/dist/utils/uuid.d.ts +0 -11
  137. package/dist/utils/uuid.d.ts.map +1 -1
  138. package/package.json +29 -3
  139. package/dist/dialog-triggers-ChnPFG9M.js.map +0 -1
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-ChnPFG9M.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-ChnPFG9M.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
  }
@@ -767,17 +800,48 @@ function isWithin(node, ancestor) {
767
800
  }
768
801
  return false;
769
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
+ }
770
837
  class AparteElicitation extends HTMLElement {
771
838
  _pending = null;
772
839
  /**
773
840
  * A turn ended — cancel the open question only if it was OUR turn.
774
841
  *
775
- * These two listeners sit on `window` and had no instance filter at all, so a
776
- * Stop (or an error) in one chat cancelled the question a DIFFERENT chat was
777
- * waiting on — and that chat's model was told the user had refused a question
778
- * the user was still looking at. Same defect as the `compact()` handler that
779
- * emptied both chats, which its four sibling handlers in `AparteClient` already
780
- * 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.
781
845
  *
782
846
  * The leniency rule is the composer's, deliberately: an event with no
783
847
  * `targetId` is for everyone (a single-chat app never sets one), and a chat we
@@ -913,12 +977,20 @@ class AparteElicitation extends HTMLElement {
913
977
  }
914
978
  const cfg = resolveConfig(this);
915
979
  if (request.kind === "approval") {
916
- const approvalMode = () => panel2.isComplete() ? "submit" : "none";
917
- const panel2 = runWithConfig(cfg, () => buildApprovalPanel(request.message, request.options ?? [], () => {
918
- composer.setPanelSubmitEnabled(panel2.isComplete(), approvalMode());
919
- }, 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));
920
984
  panel2.onSettle((answer) => settle({ action: "accept", content: answer }));
921
- 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
+ };
922
994
  slot.token = composer.showPanel(panel2.el, {
923
995
  submitEnabled: panel2.isComplete(),
924
996
  mode: approvalMode(),
@@ -931,22 +1003,27 @@ class AparteElicitation extends HTMLElement {
931
1003
  return;
932
1004
  }
933
1005
  const schema = request.schema ?? { type: "string" };
934
- const panel = runWithConfig(cfg, () => buildElicitationPanel(request.message, schema, () => {
935
- composer.setPanelSubmitEnabled(panel.canProceed(), panel.mode());
936
- }));
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));
937
1011
  const skip = document.createElement("button");
938
1012
  skip.type = "button";
939
1013
  skip.className = "aparte-btn aparte-elic-skip";
940
1014
  skip.textContent = cfg.t("elicitationSkip");
941
1015
  skip.addEventListener("click", () => settle({ action: "decline" }));
942
1016
  panel.dismiss.appendChild(skip);
1017
+ const retract = addRetract(panel.el, panel.dismiss, cfg, () => panel.focus(), syncSubmit);
943
1018
  panel.onSettle((content) => settle({ action: "accept", content }));
944
1019
  this._pending = {
945
1020
  abort: () => fail(),
946
1021
  composer,
947
1022
  relabel: () => {
948
1023
  panel.relabel();
949
- skip.textContent = resolveConfig(this).t("elicitationSkip");
1024
+ const now = resolveConfig(this);
1025
+ skip.textContent = now.t("elicitationSkip");
1026
+ retract.relabel(now);
950
1027
  }
951
1028
  };
952
1029
  slot.token = composer.showPanel(panel.el, {
@@ -957,8 +1034,8 @@ class AparteElicitation extends HTMLElement {
957
1034
  * or a turn ending. The composer tears the panel down either way; only
958
1035
  * this callback can settle the promise, and without it the request hung
959
1036
  * AND `_pending` stayed set, so every later question was short-circuited
960
- * for the life of the page. `cancel`, not `decline`: nobody declined
961
- * 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.
962
1039
  */
963
1040
  onEvict: () => fail(),
964
1041
  onSubmit: () => {
@@ -995,12 +1072,11 @@ class AparteElicitation extends HTMLElement {
995
1072
  * The composer to present in: the nearest one in an ancestor subtree, and
996
1073
  * nothing else.
997
1074
  *
998
- * There used to be a `document.querySelector('aparte-composer')` fallback, which
999
- * is the "first chat on the page" bug this repo has now fixed in four other
1000
- * places: on a page with two chats, an elicitation that could not find its own
1001
- * composer mounted its panel in the OTHER chat's — so one conversation's question
1002
- * appeared under the other conversation, and answering it resolved a tool call
1003
- * 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.
1004
1080
  *
1005
1081
  * Returning `null` instead REJECTS the request, which is honest: nothing was
1006
1082
  * shown, so nothing was answered. The warning names the fix, because this is a
@@ -1035,6 +1111,7 @@ class AparteChat extends HTMLElement {
1035
1111
  }
1036
1112
  _observer = null;
1037
1113
  _loadingObserver = null;
1114
+ _offGlide = null;
1038
1115
  /** Waits for a `<aparte-chat-viewport>` child to appear — see `_watchForViewport`. */
1039
1116
  _childObserver = null;
1040
1117
  /** The viewport is loading and the gate is on — see `_applyLoadingGate`. */
@@ -1055,8 +1132,12 @@ class AparteChat extends HTMLElement {
1055
1132
  this._syncEmptyWatch();
1056
1133
  this._syncLoadingWatch();
1057
1134
  this._watchForViewport();
1135
+ this._offGlide?.();
1136
+ this._offGlide = gateGlide(this);
1058
1137
  }
1059
1138
  disconnectedCallback() {
1139
+ this._offGlide?.();
1140
+ this._offGlide = null;
1060
1141
  this._observer?.disconnect();
1061
1142
  this._observer = null;
1062
1143
  this._loadingObserver?.disconnect();
@@ -1139,11 +1220,10 @@ class AparteChat extends HTMLElement {
1139
1220
  /**
1140
1221
  * Watch the viewport so `data-empty` follows the conversation.
1141
1222
  *
1142
- * It used to run only under `center-empty`, which made the chat's one statement of
1143
- * "this conversation is empty" a by-product of a layout option: an app that wanted
1144
- * starters on an empty thread, or a welcome of its own, had to count messages itself.
1145
- * Every selector core writes against it still requires `[center-empty]`, so reflecting
1146
- * 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.
1147
1227
  */
1148
1228
  _syncEmptyWatch() {
1149
1229
  this._observer?.disconnect();
@@ -1170,12 +1250,11 @@ class AparteChat extends HTMLElement {
1170
1250
  this._loadingObserver.observe(viewport, { attributes: true, attributeFilter: ["loading"] });
1171
1251
  }
1172
1252
  /**
1173
- * Both watches above need a viewport to observe, and they were installed once, at
1174
- * connect, when there might not be one yet: `<aparte-chat framework-managed>` IS
1175
- * Angular's host element — upgraded on insert, filled by the template afterwards — and
1176
- * hand-written markup upgrades before its children are parsed. Neither ever got its
1177
- * observer, so the documented "every chat gates" was inert on exactly the chats whose
1178
- * 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.
1179
1258
  */
1180
1259
  _watchForViewport() {
1181
1260
  this._childObserver?.disconnect();
@@ -1210,8 +1289,8 @@ class AparteChat extends HTMLElement {
1210
1289
  }
1211
1290
  /**
1212
1291
  * Empty means no message AND none on its way. A viewport that says `loading` holds
1213
- * no bubble yet, and it used to count as empty: the welcome and the centred composer
1214
- * 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.
1215
1294
  */
1216
1295
  _updateEmpty() {
1217
1296
  const viewport = this.querySelector("aparte-chat-viewport");
@@ -1253,6 +1332,16 @@ function segmentRenderResultToElement(result, segment) {
1253
1332
  if (el2 && segment && !el2.hasAttribute("data-segment-id")) el2.setAttribute("data-segment-id", segment.id);
1254
1333
  return el2;
1255
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
+ }
1256
1345
  class AparteChatBubble extends HTMLElement {
1257
1346
  _contentEl = null;
1258
1347
  _segmentsEl = null;
@@ -1268,8 +1357,20 @@ class AparteChatBubble extends HTMLElement {
1268
1357
  _usage = null;
1269
1358
  /** Cleanup returned by the avatar provider — called on disconnect/re-render. */
1270
1359
  _avatarCleanup = null;
1360
+ /** Whether the avatar slot holds what a provider drew, which goes when the provider does. */
1361
+ _avatarFromProvider = false;
1271
1362
  /** Sibling count for tree-based branch navigation (set by setSiblings()) */
1272
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;
1273
1374
  /** Sibling index for tree-based branch navigation (set by setSiblings()) */
1274
1375
  _siblingIndex = 0;
1275
1376
  /** True while the user-message inline editor is open. */
@@ -1301,7 +1402,29 @@ class AparteChatBubble extends HTMLElement {
1301
1402
  this._renderAvatar();
1302
1403
  this._relabelSegments();
1303
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
+ }
1304
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
+ }
1305
1428
  /**
1306
1429
  * Ask every rendered segment to re-read its config-derived text.
1307
1430
  *
@@ -1339,9 +1462,11 @@ class AparteChatBubble extends HTMLElement {
1339
1462
  const set = (selector, label) => {
1340
1463
  this.querySelector(selector)?.setAttribute("aria-label", label);
1341
1464
  };
1342
- set(".aparte-branch-prev", locale.previousResponse ?? "Previous response");
1343
- set(".aparte-branch-next", locale.nextResponse ?? "Next response");
1344
- 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");
1345
1470
  }
1346
1471
  /**
1347
1472
  * Config governing this bubble: the instance config of the nearest
@@ -1361,6 +1486,40 @@ class AparteChatBubble extends HTMLElement {
1361
1486
  this.addEventListener("click", this._onBranchPickerClick);
1362
1487
  this.addEventListener("click", this._onLinkClick);
1363
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
+ }
1364
1523
  disconnectedCallback() {
1365
1524
  window.removeEventListener("aparte-config-change", this._onConfigChange);
1366
1525
  this.removeEventListener("click", this._onBranchPickerClick);
@@ -1473,11 +1632,11 @@ class AparteChatBubble extends HTMLElement {
1473
1632
  * query returns the first match in document order — and sanitized model
1474
1633
  * markdown renders inside that same container, with `data-*` attributes
1475
1634
  * deliberately preserved (they are inert). So a decoy `data-segment-id` planted
1476
- * 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.
1477
1636
  *
1478
1637
  * Parser ids are unguessable UUIDs, but a tool segment is `tool-${toolCallId}`
1479
- * and the MODEL chooses that id — so this was reachable, and pointing an update
1480
- * 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
1481
1640
  * the human-in-the-loop control.
1482
1641
  */
1483
1642
  removeSegment(segmentId) {
@@ -1557,6 +1716,7 @@ class AparteChatBubble extends HTMLElement {
1557
1716
  console.warn(`[AparteChatBubble] _appendSegmentEl ABORT: _segmentsEl is null`);
1558
1717
  return;
1559
1718
  }
1719
+ if (this._segments.length === 1) this._drawnWithVersion = renderingVersionOf(this._cfg);
1560
1720
  const renderer = resolveSegmentRenderer(segment.type, this._cfg);
1561
1721
  if (renderer) {
1562
1722
  const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)), segment);
@@ -1619,6 +1779,9 @@ class AparteChatBubble extends HTMLElement {
1619
1779
  const displayName = this._getDisplayName();
1620
1780
  const initial = this._getAvatarInitial();
1621
1781
  const shell = this._cfg.getBubbleShellRenderer?.();
1782
+ this._drawnShell = shell ?? null;
1783
+ this._avatarFromProvider = false;
1784
+ this._wroteRootLabel = this._wroteBarLabel = this._wroteArrowLabels = !shell;
1622
1785
  if (shell) {
1623
1786
  const out = runWithConfig(this._cfg, () => shell({ role: this._role, name: displayName, avatarInitial: initial }));
1624
1787
  if (out instanceof HTMLElement) this.replaceChildren(out);
@@ -1664,11 +1827,39 @@ class AparteChatBubble extends HTMLElement {
1664
1827
  if (messageEl && !messageEl.hasAttribute("role")) {
1665
1828
  messageEl.setAttribute("role", "article");
1666
1829
  messageEl.setAttribute("aria-label", this._getAriaLabel());
1830
+ this._wroteRootLabel = true;
1667
1831
  }
1668
1832
  this._contentEl = this.querySelector(".aparte-content");
1669
1833
  this._segmentsEl = this.querySelector(".aparte-segments");
1670
1834
  this._attachmentsEl = this.querySelector(".aparte-attachments");
1671
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
+ }
1672
1863
  this._actionBarEl?.addEventListener("keydown", this._onActionBarKeydown);
1673
1864
  this._branchPickerEl = this.querySelector(".aparte-branch-picker");
1674
1865
  this._footerEl = this.querySelector(".aparte-footer");
@@ -1680,8 +1871,7 @@ class AparteChatBubble extends HTMLElement {
1680
1871
  }
1681
1872
  /**
1682
1873
  * Show the built-in waiting indicator while this bubble is in flight and has
1683
- * nothing to show yet — the gap between "user sends" and the first token, which
1684
- * 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.
1685
1875
  *
1686
1876
  * The dots are CSS (no per-token work, themable, honours reduced-motion); the
1687
1877
  * accessible name is `locale.typing`, next to the `aria-busy` the streaming state
@@ -1708,11 +1898,10 @@ class AparteChatBubble extends HTMLElement {
1708
1898
  /**
1709
1899
  * Hand the avatar host element off to the registered AvatarProvider, if any.
1710
1900
  *
1711
- * With no provider the slot is left exactly as the shell rendered it — which for
1712
- * the default shell means EMPTY, and hidden by `.aparte-avatar:empty`. This used
1713
- * to claim it "falls back to the default initial rendered by `_render()`"; there
1714
- * is no such initial, and believing there was is what made `_updateRole` write
1715
- * 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`.
1716
1905
  */
1717
1906
  _renderAvatar() {
1718
1907
  const avatar = this.querySelector(".aparte-avatar");
@@ -1725,10 +1914,34 @@ class AparteChatBubble extends HTMLElement {
1725
1914
  this._avatarCleanup = null;
1726
1915
  }
1727
1916
  const provider = this._cfg.getAvatarProvider();
1728
- 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
+ }
1729
1925
  avatar.textContent = "";
1730
1926
  const cleanup = provider.render(this._role, avatar);
1927
+ this._avatarFromProvider = true;
1731
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 });
1732
1945
  }
1733
1946
  _updateRole() {
1734
1947
  const message = this.querySelector(".aparte-message");
@@ -1736,13 +1949,13 @@ class AparteChatBubble extends HTMLElement {
1736
1949
  const nameEl = this.querySelector(".aparte-name");
1737
1950
  if (message) {
1738
1951
  message.setAttribute("data-role", this._role);
1739
- if (message.getAttribute("role") === "article") {
1952
+ if (this._wroteRootLabel && message.getAttribute("role") === "article") {
1740
1953
  message.setAttribute("aria-label", this._getAriaLabel());
1741
1954
  }
1742
1955
  }
1743
1956
  if (avatar) {
1744
1957
  avatar.setAttribute("data-role", this._role);
1745
- if (avatar.textContent) avatar.textContent = this._getAvatarInitial();
1958
+ if (avatar.textContent && !this._avatarFromProvider) avatar.textContent = this._getAvatarInitial();
1746
1959
  }
1747
1960
  if (nameEl) {
1748
1961
  nameEl.textContent = this._getDisplayName();
@@ -1753,10 +1966,12 @@ class AparteChatBubble extends HTMLElement {
1753
1966
  _updateName() {
1754
1967
  const avatar = this.querySelector(".aparte-avatar");
1755
1968
  const nameEl = this.querySelector(".aparte-name");
1756
- if (avatar && avatar.textContent && !this._cfg.getAvatarProvider()) {
1969
+ if (avatar && avatar.textContent && !this._cfg.getAvatarProvider() && !this._avatarFromProvider) {
1757
1970
  avatar.textContent = this._getAvatarInitial();
1758
1971
  }
1759
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());
1760
1975
  }
1761
1976
  _updateContent() {
1762
1977
  if (!this._contentEl) return;
@@ -1765,6 +1980,7 @@ class AparteChatBubble extends HTMLElement {
1765
1980
  this._updateWaiting();
1766
1981
  return;
1767
1982
  }
1983
+ this._drawnWithVersion = renderingVersionOf(this._cfg);
1768
1984
  this._contentEl.style.display = "";
1769
1985
  runWithConfig(
1770
1986
  this._cfg,
@@ -1802,6 +2018,7 @@ class AparteChatBubble extends HTMLElement {
1802
2018
  }
1803
2019
  _renderSegments() {
1804
2020
  if (!this._segmentsEl) return;
2021
+ this._drawnWithVersion = renderingVersionOf(this._cfg);
1805
2022
  this._segmentsEl.innerHTML = "";
1806
2023
  for (const segment of this._segments) {
1807
2024
  const renderer = resolveSegmentRenderer(segment.type, this._cfg);
@@ -1848,8 +2065,16 @@ class AparteChatBubble extends HTMLElement {
1848
2065
  timestampEl.textContent = "";
1849
2066
  }
1850
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
+ */
1851
2074
  _getAriaLabel() {
1852
2075
  const locale = this._cfg.getLocale();
2076
+ const name = this.getAttribute("name");
2077
+ if (name) return (locale.messageFrom || "Message from {name}").replace("{name}", () => name);
1853
2078
  return this._role === "user" ? locale.yourMessage ?? "Your message" : locale.assistantResponse ?? "Assistant response";
1854
2079
  }
1855
2080
  // ─────────────────────────────────────────────────────────────────────────
@@ -1907,9 +2132,9 @@ class AparteChatBubble extends HTMLElement {
1907
2132
  *
1908
2133
  * An attachment's `url` is documented as "URL or data URI" and a storage adapter
1909
2134
  * re-mints it when a conversation is restored, so it is the app's value, not the
1910
- * composer's — it was escaped and written, where every other URL core emits is
1911
- * asked whether its SCHEME is allowed at all. A picture whose URL is refused is
1912
- * 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.
1913
2138
  *
1914
2139
  * `blob:` is added because it is the shape core itself mints — `filesToAttachments`
1915
2140
  * calls `URL.createObjectURL`, and `AparteMessage.attachments` documents the same
@@ -1930,21 +2155,12 @@ class AparteChatBubble extends HTMLElement {
1930
2155
  /**
1931
2156
  * The branch arrows are handled by DELEGATION, on this element, bound once.
1932
2157
  *
1933
- * They used to get a fresh listener each, attached by `_render()` to the buttons
1934
- * `_render()` had just created. So a click that landed while a re-render was
1935
- * swapping those nodes hit an element about to be discarded, and did nothing at
1936
- * all — not late, nothing. Invisible on a fast machine; reproducible on
1937
- * WebKit-Linux in CI, where `‹` left the picker on "2 / 2" and a 20-second
1938
- * assertion watched it stay there.
1939
- *
1940
- * Delegation makes `_render()` irrelevant to it: the listener lives on the host,
1941
- * which is never replaced, and `closest()` finds whichever button exists at the
1942
- * moment of the click. It is also less work — one listener per bubble instead of
1943
- * 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.
1944
2161
  *
1945
2162
  * Bound in `connectedCallback` and removed in `disconnectedCallback` as a stable
1946
- * field, because an inline arrow re-added on every re-connect is how this repo has
1947
- * stacked listeners twice before (the viewport, and `aparte-select`).
2163
+ * field, so a re-connect never stacks listeners.
1948
2164
  */
1949
2165
  _onBranchPickerClick = (event) => {
1950
2166
  const target = event.target;
@@ -1963,7 +2179,7 @@ class AparteChatBubble extends HTMLElement {
1963
2179
  /**
1964
2180
  * A link the MODEL wrote is about to be followed. Announced as a cancelable event
1965
2181
  * before the browser acts, so a host can route it — an external browser, a
1966
- * confirmation, an embedded view — without intercepting the DOM (issue #38). With
2182
+ * confirmation, an embedded view — without intercepting the DOM. With
1967
2183
  * no listener the browser follows it, which the sanitizer has already made open in
1968
2184
  * a new tab for an external URL; `preventDefault()` on the event cancels the click.
1969
2185
  *
@@ -2092,7 +2308,7 @@ class AparteChatBubble extends HTMLElement {
2092
2308
  }
2093
2309
  this._actionBarEl.innerHTML = buttons.join("");
2094
2310
  this._syncCopyAction();
2095
- this._appendCustomActions(icons);
2311
+ this._appendCustomActions();
2096
2312
  this._applyTranscriptBusy();
2097
2313
  this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
2098
2314
  btn.addEventListener("click", (e) => this._handleActionClick(e));
@@ -2102,18 +2318,14 @@ class AparteChatBubble extends HTMLElement {
2102
2318
  /**
2103
2319
  * `role="toolbar"` promises a keyboard model. This is it.
2104
2320
  *
2105
- * The bar has announced itself as a toolbar since it existed, and a toolbar is ONE
2106
- * tab stop whose members are reached with the arrow keys — that is the whole of what
2107
- * the role means to a screen-reader user. What shipped was five independent tab
2108
- * stops per message, so the role described a behaviour that did not exist: a reader
2109
- * told to press Right heard nothing move, and tabbing through a long transcript
2110
- * 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.
2111
2324
  *
2112
2325
  * The roving index is re-applied from `_syncFooterVisibility`, which is the single
2113
- * funnel all three build paths already run through (the flag-driven bar, the edit
2114
- * bar, and the branch picker's own visibility pass). Re-deriving it there rather
2115
- * than in each builder is what keeps the invariant true after a `setBubbleActions`
2116
- * 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.
2117
2329
  *
2118
2330
  * DISABLED buttons are skipped, not merely un-tabbable: while the transcript is busy
2119
2331
  * retry and edit are disabled, and a toolbar whose arrows stop on a dead control
@@ -2161,9 +2373,9 @@ class AparteChatBubble extends HTMLElement {
2161
2373
  buttons[next]?.focus();
2162
2374
  };
2163
2375
  /**
2164
- * An empty action bar is not a bar: with every action off it was still a
2165
- * `role="toolbar"` with nothing in it (announced as such), and it still reserved
2166
- * 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
2167
2379
  * contents — the footer stays as long as the branch picker or the bar has
2168
2380
  * something to show.
2169
2381
  */
@@ -2177,7 +2389,7 @@ class AparteChatBubble extends HTMLElement {
2177
2389
  this.querySelector(".aparte-message")?.toggleAttribute("data-branches", !pickerHidden);
2178
2390
  }
2179
2391
  /** Append the registered custom action buttons for this bubble's role. */
2180
- _appendCustomActions(icons) {
2392
+ _appendCustomActions() {
2181
2393
  if (!this._actionBarEl) return;
2182
2394
  for (const a2 of this._cfg.getActions("bubble")) {
2183
2395
  const roles = a2.bubble?.roles ?? ["user", "assistant"];
@@ -2188,8 +2400,7 @@ class AparteChatBubble extends HTMLElement {
2188
2400
  btn.dataset["action"] = `custom:${a2.id}`;
2189
2401
  btn.setAttribute("aria-label", a2.label);
2190
2402
  btn.setAttribute("title", a2.label);
2191
- const fromProvider = icons[a2.icon];
2192
- 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 ?? "";
2193
2404
  this._actionBarEl.appendChild(btn);
2194
2405
  }
2195
2406
  }
@@ -2378,6 +2589,8 @@ class AparteChatBubble extends HTMLElement {
2378
2589
  if (this._contentEl) this._contentEl.style.display = "";
2379
2590
  this.querySelector(".aparte-message")?.removeAttribute("data-editing");
2380
2591
  this._editing = false;
2592
+ this._redrawFrameIfChanged();
2593
+ this._redrawIfStale();
2381
2594
  this._updateActionBar();
2382
2595
  const returnTo = this._editReturnAction;
2383
2596
  this._editReturnAction = null;
@@ -2434,6 +2647,9 @@ class AparteChatBubble extends HTMLElement {
2434
2647
  }
2435
2648
  this._updateWaiting();
2436
2649
  if (wasStreaming && !streaming) this._updateContent();
2650
+ if (wasStreaming && !streaming && this._segments.length > 0 && renderingVersionOf(this._cfg) !== this._drawnWithVersion) {
2651
+ this._renderSegments();
2652
+ }
2437
2653
  }
2438
2654
  }
2439
2655
  if (!customElements.get("aparte-chat-bubble")) {
@@ -2596,6 +2812,15 @@ class AparteChatStatus extends HTMLElement {
2596
2812
  _updateText(text) {
2597
2813
  const container = this.querySelector(".aparte-status-container");
2598
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
+ }
2599
2824
  this._writeText(text);
2600
2825
  container.setAttribute("aria-label", text || this._word());
2601
2826
  }
@@ -2655,17 +2880,14 @@ class AparteChatViewport extends HTMLElement {
2655
2880
  * Not `readonly`: a test shortens it to run past the window's end in a few frames. */
2656
2881
  _settleWindowMs = 400;
2657
2882
  /**
2658
- * Until when a smooth scroll of ours is in flight (#57). A user send glides to the
2659
- * bottom with `scrollTo({ behavior: 'smooth' })`; the assistant placeholder and the
2660
- * first tokens used to land INSIDE that glide through the streaming path — an instant
2661
- * `scrollTop = scrollHeight` plus the settle chain — and an instant write cancels a
2662
- * running smooth scroll in every engine. Measured before the fix: five instant writes
2663
- * in the send's own frame (the spacer recalculation scrolls synchronously, the
2664
- * mutation observer queues another), so there never was a glide to cut — the view
2665
- * teleported. While this deadline is ahead, every bottom-pin RE-TARGETS the glide
2666
- * (a second smooth `scrollTo` is continuous) instead of writing `scrollTop`; the
2667
- * browser's `scrollend` closes it early where supported. Streaming is instant again
2668
- * 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.
2669
2891
  */
2670
2892
  _glideUntil = 0;
2671
2893
  /** The glide's budget when `scrollend` is not there to close it: Chromium's smooth scroll over a viewport takes ~300–500 ms. */
@@ -2689,11 +2911,22 @@ class AparteChatViewport extends HTMLElement {
2689
2911
  _maxRenderedBubbles = 1e3;
2690
2912
  _resizeObserver = null;
2691
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();
2692
2925
  /**
2693
2926
  * Opening or closing a `<details>` — a tool row, a reasoning block, an artifact card, a host's
2694
2927
  * own — changes the transcript's height through an ATTRIBUTE, which neither observer sees: the
2695
2928
  * mutation observer watches children, and the resize observer leaves the wrapper out on
2696
- * purpose. `toggle` does not bubble, so it is caught on the way down (#105).
2929
+ * purpose. `toggle` does not bubble, so it is caught on the way down.
2697
2930
  */
2698
2931
  _onDisclosureToggle = () => {
2699
2932
  this._scheduleSpacerUpdate();
@@ -2778,8 +3011,8 @@ class AparteChatViewport extends HTMLElement {
2778
3011
  window.addEventListener("aparte-config-change", this._onConfigChange);
2779
3012
  }
2780
3013
  /**
2781
- * A locale switch changes the reading direction, and `dir` was applied once at
2782
- * 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.
2783
3016
  * Only OUR config: an instance-scoped change elsewhere must not touch us.
2784
3017
  */
2785
3018
  _onConfigChange = (e) => {
@@ -2796,15 +3029,14 @@ class AparteChatViewport extends HTMLElement {
2796
3029
  * A scrollable region that no element can hold focus in cannot be scrolled from
2797
3030
  * the keyboard in WebKit: Chromium and Firefox hand an unfocusable overflow box a
2798
3031
  * caret or a "scroller" tab stop of their own, Safari does not. So a plain-text
2799
- * transcript — no links, no code blocks, nothing focusable inside — was unreadable
2800
- * past the first screen for a Safari keyboard user, with no error and nothing on
2801
- * 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.
2802
3035
  *
2803
- * `tabindex="0"` on the surface itself, in BOTH DOM modes. The framework mode
2804
- * appeared to work already, and only by accident: the scroll button is inside the
2805
- * host and stays tabbable while it is visually hidden, so Tab happened to land
2806
- * somewhere that scrolled. A tab stop that exists because a hidden button happens
2807
- * 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.
2808
3040
  *
2809
3041
  * The name ships WITH the tab stop rather than after it: a focusable `role="log"`
2810
3042
  * with no accessible name is announced as an unnamed region, which is a worse
@@ -2933,10 +3165,10 @@ class AparteChatViewport extends HTMLElement {
2933
3165
  * The id of the message currently streaming, if any — searched across the whole
2934
3166
  * TREE, and answering with an OFF-HEAD one first when there are two.
2935
3167
  *
2936
- * 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
2937
3169
  * earlier bubble calls `addSiblingOf`, which switches the branch to the new pending
2938
- * reply: the turn still being written leaves the path, the scan found nothing, and
2939
- * 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.
2940
3172
  *
2941
3173
  * A new pending reply is itself marked streaming, so "the first one found" would
2942
3174
  * answer the head and let the write through — the very case this exists to refuse.
@@ -2959,10 +3191,10 @@ class AparteChatViewport extends HTMLElement {
2959
3191
  /**
2960
3192
  * While a reply streams, the transcript is read-only except for Stop.
2961
3193
  *
2962
- * The streaming message's own footer was already hidden; every OTHER message kept
2963
- * its branch picker and its retry/edit buttons live. Swapping a branch mid-stream
2964
- * re-rendered the active path under the reply being written, and a retry cut that
2965
- * 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
2966
3198
  * decides it is the whole transcript's, so it lives on the viewport (`data-busy`)
2967
3199
  * and is pushed to the bubbles it holds; a bubble mounted later, under a
2968
3200
  * framework's DOM, reads the attribute itself when it connects.
@@ -3417,10 +3649,13 @@ class AparteChatViewport extends HTMLElement {
3417
3649
  }
3418
3650
  }
3419
3651
  /**
3420
- * 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.
3421
3655
  */
3422
3656
  setAutoScroll(enabled) {
3423
3657
  this._isAutoScrollEnabled = enabled;
3658
+ if (!enabled) this._readerInputAt = performance.now();
3424
3659
  }
3425
3660
  /**
3426
3661
  * Signal that a framework (e.g. Angular) manages the bubble DOM.
@@ -3769,7 +4004,19 @@ class AparteChatViewport extends HTMLElement {
3769
4004
  });
3770
4005
  }
3771
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();
3772
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();
4018
+ });
4019
+ this._observeTail();
3773
4020
  this.addEventListener("toggle", this._onDisclosureToggle, true);
3774
4021
  const observeTarget = this._frameworkManagedDOM ? this : wrapper;
3775
4022
  if (observeTarget) {
@@ -3822,10 +4069,9 @@ class AparteChatViewport extends HTMLElement {
3822
4069
  * the transcript's padding plus the scrollbar gutter the scroller reserves on both
3823
4070
  * edges, at whatever step its container query has taken — as
3824
4071
  * `--aparte-transcript-inset`. The composer pads by it (composer.css), so its box
3825
- * lands on the row's box at every width. The two used to be independent stacks: the
3826
- * composer cannot know the gutter and a container query cannot reach it, so they sat
3827
- * 10px apart at 768 and the gutter's half apart at 1280, and the reading-column demo
3828
- * 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
3829
4075
  * same reason `_updateOverlayInset` is. On the HOST, not on this element: the
3830
4076
  * composer is a sibling, and a custom property only travels down.
3831
4077
  */
@@ -3856,7 +4102,8 @@ class AparteChatViewport extends HTMLElement {
3856
4102
  const drop = this._lastScrollTop - top;
3857
4103
  const churn = Math.abs(height - this._lastScrollHeight);
3858
4104
  const now = performance.now();
3859
- 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);
3860
4107
  const settlingOurs = now - this._ownScrollAt < this._ownScrollWindowMs && now - this._readerInputAt >= this._ownScrollWindowMs && !this._pointerHeld && (drop <= churn + 2 || afterMutation);
3861
4108
  const readerWentUp = drop > 1 && !settlingOurs;
3862
4109
  const readerHandOnIt = drop > 1 && now - this._readerInputAt < this._ownScrollWindowMs;
@@ -3899,9 +4146,8 @@ class AparteChatViewport extends HTMLElement {
3899
4146
  * The browser says a scroll came to rest — the glide is over ONLY if it rested at the
3900
4147
  * bottom. WebKit fires `scrollend` when a smooth scroll is REPLACED by another (our own
3901
4148
  * re-target), 45–60 ms into the glide and hundreds of pixels short; taken at its word,
3902
- * that closed the window and the next pin was the cut this guard exists to prevent
3903
- * (react-webkit and vanilla-webkit, 2 of 2). Short of the bottom, the budget keeps
3904
- * 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.
3905
4151
  */
3906
4152
  _handleScrollEnd() {
3907
4153
  if (!this._container) return;
@@ -3911,37 +4157,27 @@ class AparteChatViewport extends HTMLElement {
3911
4157
  /**
3912
4158
  * Confirm over the next few frames that we actually reached the bottom.
3913
4159
  *
3914
- * One assignment is not enough, and the reason is measured rather than guessed.
3915
- * A timeline of a streamed turn on Safari (framework mode) recorded the content
3916
- * settling in TWO layout passes — 1118 → 1121 → 1152 px. `scrollTop =
3917
- * scrollHeight` ran against the middle one, clamped to that layout's max (603),
3918
- * and nothing ran afterwards: the last 31px never closed. Auto-follow stayed
3919
- * armed the whole time, so the component was not disarmed — it was SATISFIED.
3920
- * `_isAtBottom()` answers "yes" for any gap under `_scrollThreshold` (50), which
3921
- * is the right rule for keeping auto-follow armed and the wrong one as a
3922
- * definition of "anchored".
3923
- *
3924
- * Ruled out on the way here, so nobody pays for it twice: not a WebKit
3925
- * padding-accounting difference (a probe writing `scrollTop = 1e7` reached
3926
- * exactly `scrollHeight - clientHeight`), not a missing `characterData`
3927
- * 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".
3928
4167
  *
3929
4168
  * A BOUNDED retry, not one corrective frame: a single frame lands on the same
3930
- * stale layout and was measured leaving a wider gap than doing nothing. Bounded
3931
- * so it always terminates; re-reads `_isAutoScrollEnabled` every frame so a
3932
- * 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.
3933
4172
  *
3934
- * Bounded by TIME, and it does not stop at the first closed gap — both of those
3935
- * were the second half of the same bug. It used to be four frames (~64ms on an
3936
- * idle 60Hz machine) and to return permanently the frame the gap first closed.
3937
- * A branch swap at the bottom of a long transcript on react-webkit falsified
3938
- * both at once: the rebuild's scrollable max churned 891 -> 1091 -> 891, the gap
3939
- * WAS closed for a frame against the tall layout, the chain returned, the height
3940
- * fell back with WebKit holding `scrollTop` at 720 — and the transcript stood
3941
- * 171px short with auto-follow still armed. A frame count is a proxy for time
3942
- * that fails exactly on the slow engine, so the budget is real milliseconds now,
3943
- * and a closed gap only ends the chain when the window is over. One chain, not
3944
- * 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.
3945
4181
  */
3946
4182
  _settleAtBottom() {
3947
4183
  this._settleUntil = performance.now() + this._settleWindowMs;
@@ -3973,6 +4209,7 @@ class AparteChatViewport extends HTMLElement {
3973
4209
  this._glideSettleTimer = null;
3974
4210
  if (!this._container || !this._isAutoScrollEnabled || this._gliding()) return;
3975
4211
  const max = this._container.scrollHeight - this._container.clientHeight;
4212
+ if (this._container.scrollTop < this._lastScrollTop - 1) return;
3976
4213
  if (max - this._container.scrollTop > 1) this._scrollToBottom();
3977
4214
  }, this._glideWindowMs + 16);
3978
4215
  } else {
@@ -3988,10 +4225,10 @@ class AparteChatViewport extends HTMLElement {
3988
4225
  *
3989
4226
  * The two answer different questions: the flag is intent ("should new content
3990
4227
  * pull the view down"), the button is a fact ("is there anything below the
3991
- * 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 —
3992
4229
  * `navigateBranch` deliberately disarms auto-follow, so swapping a branch while
3993
- * already at the bottom of a scrollable transcript left the button offering to
3994
- * 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
3995
4232
  * post-mutation frame and after a path swap, so it converges to the truth
3996
4233
  * whatever a framework's render timing does in between.
3997
4234
  */
@@ -4044,13 +4281,29 @@ class AparteChatViewport extends HTMLElement {
4044
4281
  return;
4045
4282
  }
4046
4283
  const contentBelowUserTop = scrollHeightWithoutSpacer - userTopInContent;
4047
- const needed = this._container.clientHeight - contentBelowUserTop;
4284
+ const covered = parseFloat(getComputedStyle(this._container).scrollPaddingTop) || 0;
4285
+ const needed = this._container.clientHeight - covered - contentBelowUserTop;
4048
4286
  const maxSpacer = this._container.clientHeight;
4049
4287
  this._setSpacerHeight(Math.min(Math.max(0, needed), maxSpacer));
4050
4288
  if (this._isAutoScrollEnabled) {
4051
4289
  this._scrollToBottom();
4052
4290
  }
4053
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
+ }
4054
4307
  /**
4055
4308
  * Schedule a spacer recalculation on the next animation frame.
4056
4309
  * Batches multiple rapid calls (e.g. during token streaming) into one.
@@ -4106,6 +4359,9 @@ class AparteChatViewport extends HTMLElement {
4106
4359
  this.removeEventListener("aparte-branch-navigate", this._onBranchNavigate);
4107
4360
  this._resizeObserver?.disconnect();
4108
4361
  this._mutationObserver?.disconnect();
4362
+ this._tailObserver?.disconnect();
4363
+ this._tailObserver = null;
4364
+ this._tail = [];
4109
4365
  this.removeEventListener("toggle", this._onDisclosureToggle, true);
4110
4366
  this._overlayObserver?.disconnect();
4111
4367
  this._overlayObserver = null;
@@ -4141,14 +4397,11 @@ class AparteComposer extends HTMLElement {
4141
4397
  /**
4142
4398
  * Who owns the one panel slot, and how to tell them they lost it.
4143
4399
  *
4144
- * There is exactly one slot, `showPanel` empties it unconditionally, and nothing
4145
- * used to say whose it was. Three paths therefore closed a panel whose owner was
4146
- * still awaiting an answer, and none of them told the owner: a second `showPanel`,
4147
- * the owner-of-record's own `hidePanel`, and — the one that actually bit —
4148
- * `_handleMessageDone`, which fires on EVERY turn end. A question still open when a
4149
- * turn finished lost its panel while `<aparte-elicitation>` kept `_pending` set,
4150
- * so the request never settled AND every later request was short-circuited for the
4151
- * 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.
4152
4405
  */
4153
4406
  _panelToken = null;
4154
4407
  _panelOnEvict = null;
@@ -4171,10 +4424,10 @@ class AparteComposer extends HTMLElement {
4171
4424
  *
4172
4425
  * A wrapper runs `AparteChatHost.bind()` — which calls `attachConfig` — from its
4173
4426
  * POST-mount hook, so this element connects BEFORE the boundary exists. Caching
4174
- * here latched the global config forever: an instance `config` carrying an RTL
4175
- * locale flipped the transcript and not the composer, and the
4176
- * `requireModelSelection` gate was read off the wrong object. `aparte-chat-bubble`
4177
- * 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.
4178
4431
  */
4179
4432
  get _cfg() {
4180
4433
  return resolveConfig(this);
@@ -4195,10 +4448,10 @@ class AparteComposer extends HTMLElement {
4195
4448
  * Mirror `locale.direction` onto ourselves, so everything inside — input, buttons,
4196
4449
  * the toolbar row and whatever the consumer put in it — inherits it.
4197
4450
  *
4198
- * The direction used to stop at the transcript: only the viewport applied `dir`, so
4199
- * an RTL locale flipped the conversation and left the composer left-to-right. It is
4200
- * also what makes the toolbar's placement idiom real: `margin-inline-start: auto` in
4201
- * 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`.
4202
4455
  *
4203
4456
  * One attribute on the host rather than a stamp per child: inheritance is the
4204
4457
  * mechanism, so nothing needs to know about the consumer's markup.
@@ -4252,9 +4505,9 @@ class AparteComposer extends HTMLElement {
4252
4505
  /**
4253
4506
  * The attribute is the state, so the property writes it. It has to exist: React 19
4254
4507
  * and Svelte assign the PROPERTY whenever the element has one of that name, and a
4255
- * getter alone made `<aparte-composer disabled="">` — a spelling the wrappers type
4256
- * as valid — throw and take the render down. `presenceOn` because `''` is what
4257
- * 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.
4258
4511
  */
4259
4512
  set disabled(value) {
4260
4513
  this.toggleAttribute("disabled", presenceOn(value));
@@ -4340,27 +4593,23 @@ class AparteComposer extends HTMLElement {
4340
4593
  *
4341
4594
  * While a panel is up, the composer is answering a QUESTION, not composing a
4342
4595
  * message — so the affordances that lead nowhere go away with the text input:
4343
- * the attachment picker above all, which stayed clickable while the user was
4344
- * being asked something ("on voyait encore l'icône de upload", reported from a
4345
- * real session). Ratified decision #8: an affordance nothing can honour is not
4346
- * 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.
4347
4598
  *
4348
4599
  * What STAYS, deliberately: the attachments strip, because pending attachments
4349
4600
  * are the user's state and not an action to offer — hiding them would look like
4350
4601
  * losing them; and the toolbar, because switching model still does something.
4351
4602
  *
4352
4603
  * The send button is the part the PANEL decides, through `mode`. `'submit'`
4353
- * 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
4354
- * needs — a single-choice question, an approval — and until it existed such a
4355
- * panel left a permanently disabled button beside options that never routed
4356
- * through it. Flip between them at any time with {@link setPanelSubmitEnabled},
4357
- * which is how a panel that grows an act (an "Other…" field, a written
4358
- * 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.
4359
4609
  *
4360
- * Declared with an attribute + CSS rather than the inline `style.display` this
4361
- * used to set on a child: an attribute is themeable, is visible to a consumer's
4362
- * own rules, and does not clobber a `display` the consumer had set (the restore
4363
- * 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.
4364
4613
  *
4365
4614
  * Returns the TOKEN for this panel. Pass it back to `hidePanel` so a presenter
4366
4615
  * that has already lost the slot cannot close the panel that replaced it, and
@@ -4399,18 +4648,6 @@ class AparteComposer extends HTMLElement {
4399
4648
  * no token it closes whatever is there, which is what a consumer driving the
4400
4649
  * composer directly means.
4401
4650
  *
4402
- * BOTH forms notify. The no-token form used to call `_teardownPanel` directly,
4403
- * which nulls `_panelOnEvict` without ever calling it — so the documented public
4404
- * `hidePanel()` closed an open approval panel and left its request pending
4405
- * forever. The turn hung on "waiting for you", and because
4406
- * `AparteConfig.requestUserInput` chains each request on the previous one, NO
4407
- * further question or approval on that config was ever presented again, for the
4408
- * life of the page.
4409
- *
4410
- * The old JSDoc justified the silent branch as "what `reset()` needs". It was not:
4411
- * `reset()` calls `_evictPanel()`, which notifies. The branch had no consumer and
4412
- * one failure mode.
4413
- *
4414
4651
  * The two forms differ on purpose, and the difference is who already knows:
4415
4652
  *
4416
4653
  * - **With a matching token** the OWNER is closing its own panel, which is what
@@ -4470,10 +4707,10 @@ class AparteComposer extends HTMLElement {
4470
4707
  /**
4471
4708
  * Publish the panel mode as an attribute, so CSS can act on it.
4472
4709
  *
4473
- * The same reasoning `data-panel-active` is set for and the same one that took
4474
- * the old `style.display` off a child: an attribute is themeable, is visible to
4475
- * a consumer's own rules, and does not clobber what the consumer set. It is what
4476
- * 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.
4477
4714
  */
4478
4715
  _reflectPanelMode() {
4479
4716
  if (this._panelActive) this.setAttribute("data-panel-mode", this._panelMode);
@@ -4583,15 +4820,12 @@ class AparteComposer extends HTMLElement {
4583
4820
  * Which chat this composer belongs to: its `target` attribute, or the id of the
4584
4821
  * chat host above it.
4585
4822
  *
4586
- * All four wrappers set `target` themselves, so the attribute alone identified a
4587
- * composer there. In RAW core — the documented quick start, where the markup is
4588
- * hand-written — nothing sets it, so `!this.targetId` was true and this composer
4589
- * accepted every chat's lifecycle events: on a two-chat page, one chat's Stop
4590
- * tore down the other's open elicitation panel while its tool call kept waiting,
4591
- * 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.
4592
4828
  *
4593
- * Found by a two-chat test written for the elicitation presenter, which is the
4594
- * only reason it surfaced: raw core with two chats is a shape nothing exercised.
4595
4829
  * The hosts matched are the ones `aparte-chat-bubble._resolveTargetId()` matches,
4596
4830
  * for the reason written there — Angular's wrapper root IS `<aparte-chat>`, the
4597
4831
  * other three render a `[data-aparte-chat]` div.
@@ -4629,11 +4863,11 @@ class AparteComposer extends HTMLElement {
4629
4863
  * Whether a terminal event ends the turn this composer is following.
4630
4864
  *
4631
4865
  * `_isForThisComposer` answers "is this chat's?", and both turns of a retry belong
4632
- * to the same chat: the client aborts the superseded reply, the engine emits
4633
- * `run-aborted` for it, and that unwind turned Stop back into Send while the new
4634
- * reply was still streaming — and evicted the open panel, i.e. the approval question
4635
- * a tool was still waiting on. An event with no `messageId` still ends the turn: a
4636
- * 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.
4637
4871
  */
4638
4872
  _isForThisTurn(e) {
4639
4873
  const id = e.detail?.messageId;
@@ -4661,11 +4895,7 @@ class AparteComposerInput extends HTMLElement {
4661
4895
  _maxHeight = 200;
4662
4896
  _minHeight = 44;
4663
4897
  /**
4664
- * Re-measures the editor when its WIDTH changes (#55). The three fixed instants
4665
- * (connect, next frame, fonts.ready) all miss the same case: a box not yet laid
4666
- * out — the placeholder wraps in a 0 px column, `scrollHeight` reads ~300 px, and
4667
- * that became the inline height until the first keystroke. Width is the variable
4668
- * none of them watched.
4898
+ * Re-measures the editor when its WIDTH changes.
4669
4899
  */
4670
4900
  _resizeObserver = null;
4671
4901
  _unsubscribes = [];
@@ -4850,9 +5080,10 @@ class AparteComposerInput extends HTMLElement {
4850
5080
  e.preventDefault();
4851
5081
  const cd = e.clipboardData;
4852
5082
  if (!cd) return;
4853
- 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;
4854
5085
  if (imageFile) {
4855
- this._getRoot()?.addAttachments([imageFile]);
5086
+ root?.addAttachments([imageFile]);
4856
5087
  return;
4857
5088
  }
4858
5089
  const text = cd.getData("text/plain");
@@ -4914,9 +5145,8 @@ class AparteComposerSend extends HTMLElement {
4914
5145
  *
4915
5146
  * This button has four meanings — send, stop, submit an answer, advance to the
4916
5147
  * next question — and three of them are decided by state it does not own: the
4917
- * root's `streaming`, and this payload. It was read straight out of the event's
4918
- * arguments and thrown away, so nothing could recompute the button's chrome
4919
- * 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.
4920
5150
  */
4921
5151
  _panel = null;
4922
5152
  // Bound handler
@@ -4999,14 +5229,13 @@ class AparteComposerSend extends HTMLElement {
4999
5229
  this._syncState();
5000
5230
  }
5001
5231
  /**
5002
- * Panel open: this one button now means "submit the answer".
5232
+ * Panel open: this one button means "submit the answer".
5003
5233
  *
5004
- * The icon has to move with the meaning: it drew a paper plane while the label
5005
- * already said "Submit", so it read as "send a message" while it meant "answer
5006
- * this question". It used to draw a chevron too, while a form had questions
5007
- * ahead, and turn into a "Next" — a second way to do what the panel's chips do,
5008
- * and a button whose meaning changed under the pointer. It is disabled instead
5009
- * 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.
5010
5239
  */
5011
5240
  _syncPanelState() {
5012
5241
  const panel = this._panel;
@@ -5171,7 +5400,7 @@ class AparteComposerAttachments extends HTMLElement {
5171
5400
  this.hidden = files.length === 0;
5172
5401
  this._revokeUrls();
5173
5402
  const cfg = resolveConfig(this);
5174
- 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);
5175
5404
  this.innerHTML = files.map((file) => {
5176
5405
  const name = escapeHtml(file.name);
5177
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>`;
@@ -5468,6 +5697,21 @@ class AparteComposerToolbar extends HTMLElement {
5468
5697
  if (!customElements.get("aparte-composer-toolbar")) {
5469
5698
  customElements.define("aparte-composer-toolbar", AparteComposerToolbar);
5470
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
+ }
5471
5715
  function rowSelector(attr, id) {
5472
5716
  return `[${attr}="${cssEscape(id)}"]`;
5473
5717
  }
@@ -5499,11 +5743,10 @@ class AparteConversationList extends HTMLElement {
5499
5743
  * Let the list finish the gesture through the registered conversation manager.
5500
5744
  * Reflected as the `manage` attribute.
5501
5745
  *
5502
- * The property is not a nicety here: this is the attribute that authorises a WRITE,
5503
- * and Vue and both Sveltes stringify a bound `false` onto an element that has no
5504
- * property of that name — `manage="false"`, which `hasAttribute` reads as on. So
5505
- * turning management off turned it on, and a delete the host meant to handle itself
5506
- * 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.
5507
5750
  */
5508
5751
  get manage() {
5509
5752
  return this.hasAttribute("manage");
@@ -5571,13 +5814,12 @@ class AparteConversationList extends HTMLElement {
5571
5814
  /**
5572
5815
  * A press holds every render until its click has landed. The host re-assigns
5573
5816
  * `conversations` on every manager change (a save moves a row, a select marks one),
5574
- * and a row replaced between `mousedown` and `mouseup` lost its click: Chromium sent
5575
- * it to the list, where the delegated handler matched nothing, and WebKit sent none.
5576
- * A conversation clicked while the list settled did not open. Keeping the row node
5577
- * would not have saved it: a row moved to its new place leaves the document on the
5578
- * way, and the row that moves is often the one pressed (the conversation just
5579
- * saved). So nothing changes under a pointer that is down, and the render runs once,
5580
- * 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.
5581
5823
  */
5582
5824
  _pressed = false;
5583
5825
  _renderAfterPress = false;
@@ -5615,10 +5857,12 @@ class AparteConversationList extends HTMLElement {
5615
5857
  const held = focused && this.contains(focused) ? focused.closest("[data-select-id], [data-more-id]") : null;
5616
5858
  const restore = held?.dataset["selectId"] ? rowSelector("data-select-id", held.dataset["selectId"]) : held?.dataset["moreId"] ? rowSelector("data-more-id", held.dataset["moreId"]) : null;
5617
5859
  const heldRow = held?.closest("[data-conv-id]") ?? null;
5618
- 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;
5619
5862
  const loading = this.hasAttribute("loading");
5620
5863
  this.setAttribute("aria-busy", loading ? "true" : "false");
5621
5864
  if (loading) {
5865
+ this.removeAttribute("data-no-match");
5622
5866
  const row = '<span class="aparte-skeleton aparte-skeleton--text"></span>';
5623
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>';
5624
5868
  const status = this.querySelector(".aparte-conv-list-loading-status");
@@ -5629,14 +5873,22 @@ class AparteConversationList extends HTMLElement {
5629
5873
  else say();
5630
5874
  return;
5631
5875
  }
5632
- 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();
5633
5885
  if (!restore) return;
5634
5886
  const back = this.querySelector(restore);
5635
- if (back) {
5887
+ if (back && visible(back)) {
5636
5888
  back.focus();
5637
5889
  return;
5638
5890
  }
5639
- const rows = Array.from(this.querySelectorAll("[data-select-id]"));
5891
+ const rows = Array.from(this.querySelectorAll("[data-select-id]")).filter(visible);
5640
5892
  const next = heldIndex >= 0 ? rows[heldIndex] ?? rows[rows.length - 1] : null;
5641
5893
  if (next) {
5642
5894
  next.focus();
@@ -5648,6 +5900,41 @@ class AparteConversationList extends HTMLElement {
5648
5900
  }
5649
5901
  this.focus();
5650
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
+ }
5651
5938
  _t(key) {
5652
5939
  const locale = resolveConfig(this).getLocale();
5653
5940
  return locale[key] ?? APARTE_DEFAULT_LOCALE[key] ?? "";
@@ -5765,7 +6052,7 @@ class AparteConversationList extends HTMLElement {
5765
6052
  }
5766
6053
  /** The question the confirm step asks — the popover's accessible name while it asks it. */
5767
6054
  _confirmQuestion(conv) {
5768
- return this._t("deleteConversationConfirm").replace("{title}", conv.title || this._t("newChat"));
6055
+ return this._t("deleteConversationConfirm").replace("{title}", () => conv.title || this._t("newChat"));
5769
6056
  }
5770
6057
  _confirmMarkup(conv) {
5771
6058
  const question = escapeHtml(this._confirmQuestion(conv));
@@ -6141,10 +6428,9 @@ class AparteSuggestions extends HTMLElement {
6141
6428
  /** `hidden` was written by this element, so it is this element's to lift. */
6142
6429
  _hidOwn = false;
6143
6430
  /*
6144
- * `empty-only` used to read the send alone: hidden on the first one, and never shown
6145
- * again. Over a restored thread (nothing sent yet) the starters sat on top of the
6146
- * transcript, and after a new chat they never came back. The transcript is the
6147
- * 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
6148
6434
  * hides on the click rather than a frame later when the bubble lands — and because
6149
6435
  * it is all there is to read when the messages are not `<aparte-chat-bubble>`s
6150
6436
  * (a wrapper's `renderBubble`) or there is no transcript at all. A transcript seen
@@ -6175,9 +6461,8 @@ class AparteSuggestions extends HTMLElement {
6175
6461
  *
6176
6462
  * Both, because both arrive: React 19 and Svelte assign the PROPERTY whenever the
6177
6463
  * element has one of that name, so `<aparte-suggestions suggestions='[…]'>` — the
6178
- * documented spelling, typed valid by both wrappers — reached this setter as a
6179
- * string, `Array.isArray` said no, and the row rendered nothing while the attribute
6180
- * (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.
6181
6466
  */
6182
6467
  set suggestions(value) {
6183
6468
  this._suggestions = typeof value === "string" ? parseSuggestions(value) : Array.isArray(value) ? value.filter(isSuggestion) : [];
@@ -6372,7 +6657,7 @@ const ringSvg = () => {
6372
6657
  };
6373
6658
  class AparteContext extends HTMLElement {
6374
6659
  static get observedAttributes() {
6375
- return ["window", "warn", "danger", "target", "variant"];
6660
+ return ["window", "warn", "danger", "target", "variant", "used"];
6376
6661
  }
6377
6662
  _used = null;
6378
6663
  _level = null;
@@ -6406,10 +6691,18 @@ class AparteContext extends HTMLElement {
6406
6691
  this._compactRequested = false;
6407
6692
  };
6408
6693
  _onRerender = () => this._render();
6409
- /** 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`. */
6410
6695
  get used() {
6411
6696
  return this._used;
6412
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
+ }
6413
6706
  /** The window in force — the attribute, else the current model's. */
6414
6707
  get window() {
6415
6708
  return this._window();
@@ -6442,7 +6735,11 @@ class AparteContext extends HTMLElement {
6442
6735
  window.removeEventListener("aparte-compact-error", this._onCompactError);
6443
6736
  window.removeEventListener("aparte-config-change", this._onRerender);
6444
6737
  }
6445
- 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
+ }
6446
6743
  if (this.isConnected) this._render();
6447
6744
  }
6448
6745
  _window() {
@@ -6748,12 +7045,11 @@ class AparteScrollRail extends HTMLElement {
6748
7045
  * intersection observer reports which bubbles reach a reading band — the top 30% of
6749
7046
  * the surface, from its very top so that the bubble a jump aligned there counts. The
6750
7047
  * rule is a scrollspy's: the LAST question that has reached the band is the one the
6751
- * reader is under. "The largest visible share" was the rule before, and at the bottom
6752
- * of a thread of short turns, where three questions sit in the band, it named the
6753
- * first of them. Scroll arithmetic was the alternative, and every scroll-position
6754
- * rule in the viewport carries a regression note; the one read here is the bottom,
6755
- * because at the bottom the reader is under the latest question whatever the band
6756
- * 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.
6757
7053
  */
6758
7054
  _observe(bubbles) {
6759
7055
  this._unobserve();
@@ -6925,14 +7221,13 @@ class AparteScrollRail extends HTMLElement {
6925
7221
  * Scroll the transcript to a message. Announced first, cancelable; then a
6926
7222
  * `scrollIntoView` on the bubble, smooth unless the reader prefers reduced motion.
6927
7223
  * The clicked tick is the mark until the transcript has stopped moving: while a
6928
- * smooth scroll crosses other questions the band would name each in turn, and it
6929
- * 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.
6930
7225
  *
6931
7226
  * Where the scroll lands is checked once it has settled, and corrected. The bubbles
6932
7227
  * carry `content-visibility: auto`, so a bubble far from the reader has an estimated
6933
7228
  * height until it is rendered: the scroll aims at an estimate, the bubbles above it
6934
- * take their real size on the way, and the message ends up above or below the top —
6935
- * 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.
6936
7231
  * A second `scrollIntoView` from a rendered neighbourhood lands; a few are allowed.
6937
7232
  */
6938
7233
  jumpTo(messageId) {
@@ -6945,6 +7240,7 @@ class AparteScrollRail extends HTMLElement {
6945
7240
  if (!announced) return;
6946
7241
  const bubble = this._bubbleOf(messageId);
6947
7242
  if (!bubble) return;
7243
+ this._viewport()?.setAutoScroll?.(false);
6948
7244
  this._scrollTo(bubble);
6949
7245
  this._setCurrent(messageId);
6950
7246
  this._hold(messageId);
@@ -6975,7 +7271,8 @@ class AparteScrollRail extends HTMLElement {
6975
7271
  const surface = this._settleSurface;
6976
7272
  const bubble = id ? this._bubbleOf(id) : null;
6977
7273
  if (id && bubble && surface && this._alignPasses < MAX_ALIGN_PASSES) {
6978
- 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;
6979
7276
  if (Math.abs(off) > ALIGN_TOLERANCE) {
6980
7277
  this._alignPasses++;
6981
7278
  this._scrollTo(bubble);
@@ -7046,6 +7343,10 @@ class AparteSidebar extends HTMLElement {
7046
7343
  * Same convention as `_relabel`, which leaves a host-authored `aria-label` alone.
7047
7344
  */
7048
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;
7049
7350
  /** Whether the sidebar is collapsed (hidden in the flow, or closed as a drawer). */
7050
7351
  get collapsed() {
7051
7352
  return this.hasAttribute("collapsed");
@@ -7066,6 +7367,12 @@ class AparteSidebar extends HTMLElement {
7066
7367
  this._hostIntentSeeded = true;
7067
7368
  }
7068
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
+ }
7069
7376
  document.addEventListener("keydown", this._onKeydown);
7070
7377
  document.addEventListener("click", this._onDocumentClick);
7071
7378
  window.addEventListener("aparte-config-change", this._onConfigChange);
@@ -7097,6 +7404,8 @@ class AparteSidebar extends HTMLElement {
7097
7404
  }
7098
7405
  disconnectedCallback() {
7099
7406
  this.removeEventListener("input", this._onInput);
7407
+ this._listWatch?.disconnect();
7408
+ this._listWatch = null;
7100
7409
  document.removeEventListener("keydown", this._onKeydown);
7101
7410
  document.removeEventListener("click", this._onDocumentClick);
7102
7411
  window.removeEventListener("aparte-config-change", this._onConfigChange);
@@ -7275,20 +7584,43 @@ class AparteSidebar extends HTMLElement {
7275
7584
  this.filter(input.value);
7276
7585
  };
7277
7586
  /**
7278
- * Hide the conversation rows whose title does not contain `query`, and the date
7279
- * 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.
7280
7591
  */
7281
7592
  filter(query) {
7282
- const needle = fold(query);
7283
- for (const list of this.querySelectorAll("aparte-conversation-list")) {
7284
- for (const row of list.querySelectorAll("[data-conv-id]")) {
7285
- const title = row.querySelector(".aparte-conv-item__title")?.textContent ?? row.textContent ?? "";
7286
- row.hidden = needle !== "" && !fold(title).includes(needle);
7287
- }
7288
- for (const group of list.querySelectorAll(".aparte-conv-group")) {
7289
- 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;
7290
7601
  }
7291
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);
7292
7624
  }
7293
7625
  // ─── Locale ───────────────────────────────────────────────────────────
7294
7626
  _onConfigChange = () => this._relabel();
@@ -7314,9 +7646,6 @@ function visibleFocusable(root) {
7314
7646
  function nearestSidebar(control) {
7315
7647
  return control.closest("aparte-sidebar") ?? rootOf(control).querySelector("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
7316
7648
  }
7317
- function fold(text) {
7318
- return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
7319
- }
7320
7649
  if (!customElements.get("aparte-sidebar")) {
7321
7650
  customElements.define("aparte-sidebar", AparteSidebar);
7322
7651
  }
@@ -7468,7 +7797,7 @@ class AparteSplit extends HTMLElement {
7468
7797
  get stacked() {
7469
7798
  return this.hasAttribute("data-stacked") || this.hasAttribute("single") || this.classList.contains("aparte-split--only-start") || this.classList.contains("aparte-split--only-end");
7470
7799
  }
7471
- /** One pane on demand, whatever the width (#54). Reflected. */
7800
+ /** One pane on demand, whatever the width. Reflected. */
7472
7801
  get single() {
7473
7802
  return this.hasAttribute("single");
7474
7803
  }
@@ -7539,6 +7868,7 @@ class AparteSplit extends HTMLElement {
7539
7868
  this._watchBreakpoint();
7540
7869
  this._watchSize();
7541
7870
  this._ready = true;
7871
+ this._syncPaneControls();
7542
7872
  }
7543
7873
  disconnectedCallback() {
7544
7874
  this._handle?.removeEventListener("pointerdown", this._onPointerDown);
@@ -7590,6 +7920,7 @@ class AparteSplit extends HTMLElement {
7590
7920
  }
7591
7921
  case "pane": {
7592
7922
  const pane = this.pane;
7923
+ this._syncPaneControls();
7593
7924
  if (pane === this._lastPane) return;
7594
7925
  this._lastPane = pane;
7595
7926
  this._commit();
@@ -7607,6 +7938,7 @@ class AparteSplit extends HTMLElement {
7607
7938
  case "single":
7608
7939
  if (this.single && this._dragging) this._endDrag("pointer", this._dragStartPercent);
7609
7940
  this._stampHandle();
7941
+ this._syncPaneControls();
7610
7942
  return;
7611
7943
  case "breakpoint":
7612
7944
  this._watchBreakpoint();
@@ -7689,8 +8021,8 @@ class AparteSplit extends HTMLElement {
7689
8021
  *
7690
8022
  * While STACKED there is nothing to read: one pane is `display: none` and the other
7691
8023
  * spans the single track, so the ratio is 0 or 100 whatever the seam was set to.
7692
- * Measuring it there is how tapping "Preview" on a phone used to write `position="0"`
7693
- * 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.
7694
8026
  */
7695
8027
  _achievedPercent() {
7696
8028
  if (this.stacked) return this._position;
@@ -8021,6 +8353,7 @@ class AparteSplit extends HTMLElement {
8021
8353
  const was = this.hasAttribute("data-stacked");
8022
8354
  if (stacked && !was && this._dragging) this._endDrag("pointer", this._dragStartPercent);
8023
8355
  this.toggleAttribute("data-stacked", stacked);
8356
+ if (stacked !== was) this._syncPaneControls();
8024
8357
  if (stacked && !was) {
8025
8358
  if (!this.hasAttribute("pane")) this.showPane("start");
8026
8359
  } else if (!stacked && was) {
@@ -8052,6 +8385,28 @@ class AparteSplit extends HTMLElement {
8052
8385
  }
8053
8386
  this.showPane(which ?? (this.pane === "start" ? "end" : "start"));
8054
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
+ }
8055
8410
  // ─── Locale ───────────────────────────────────────────────────────────
8056
8411
  _onConfigChange = () => this._relabel();
8057
8412
  /**