@aparte/core 0.16.11 → 0.17.1

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 (86) hide show
  1. package/README.md +11 -4
  2. package/dist/client/aparte-client.d.ts +42 -3
  3. package/dist/client/aparte-client.d.ts.map +1 -1
  4. package/dist/components/bubble/aparte-chat-bubble.d.ts +22 -0
  5. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  6. package/dist/components/chat/aparte-chat.d.ts +55 -14
  7. package/dist/components/chat/aparte-chat.d.ts.map +1 -1
  8. package/dist/components/composer/aparte-composer-action.d.ts +5 -3
  9. package/dist/components/composer/aparte-composer-action.d.ts.map +1 -1
  10. package/dist/components/composer/aparte-composer-toolbar.d.ts +5 -2
  11. package/dist/components/composer/aparte-composer-toolbar.d.ts.map +1 -1
  12. package/dist/components/composer/aparte-composer.d.ts +27 -0
  13. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  14. package/dist/components/context/aparte-context.d.ts +8 -0
  15. package/dist/components/context/aparte-context.d.ts.map +1 -1
  16. package/dist/components/conversation-list/aparte-conversation-list.d.ts +82 -17
  17. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  18. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -1
  19. package/dist/components/sidebar/aparte-sidebar.d.ts.map +1 -1
  20. package/dist/components/split/aparte-split.d.ts.map +1 -1
  21. package/dist/components/suggestions/aparte-suggestions.d.ts +37 -3
  22. package/dist/components/suggestions/aparte-suggestions.d.ts.map +1 -1
  23. package/dist/components/viewport/aparte-chat-viewport.d.ts +55 -1
  24. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  25. package/dist/config/action-provider.d.ts +14 -14
  26. package/dist/config/action-provider.d.ts.map +1 -1
  27. package/dist/config/aparte-config.d.ts +109 -14
  28. package/dist/config/aparte-config.d.ts.map +1 -1
  29. package/dist/config/bubble-shell-renderer.d.ts +50 -12
  30. package/dist/config/bubble-shell-renderer.d.ts.map +1 -1
  31. package/dist/config/locale.d.ts +33 -1
  32. package/dist/config/locale.d.ts.map +1 -1
  33. package/dist/config/sanitize.d.ts.map +1 -1
  34. package/dist/conversations/conversation-controller.d.ts +14 -0
  35. package/dist/conversations/conversation-controller.d.ts.map +1 -1
  36. package/dist/conversations/conversation-manager.d.ts +57 -19
  37. package/dist/conversations/conversation-manager.d.ts.map +1 -1
  38. package/dist/conversations/types.d.ts +18 -11
  39. package/dist/conversations/types.d.ts.map +1 -1
  40. package/dist/custom-elements.json +12007 -11027
  41. package/dist/{dialog-triggers-cYGTlfZf.js → dialog-triggers-CJWw0P3l.js} +652 -161
  42. package/dist/dialog-triggers-CJWw0P3l.js.map +1 -0
  43. package/dist/generated/element-attributes.d.ts +15 -9
  44. package/dist/generated/element-attributes.d.ts.map +1 -1
  45. package/dist/host/aparte-chat-host.d.ts +30 -0
  46. package/dist/host/aparte-chat-host.d.ts.map +1 -1
  47. package/dist/index.css +1 -8393
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +644 -111
  51. package/dist/index.js.map +1 -1
  52. package/dist/index.node.d.ts +1 -1
  53. package/dist/index.node.d.ts.map +1 -1
  54. package/dist/index.node.js +1 -1
  55. package/dist/index.node.js.map +1 -1
  56. package/dist/interop/element-props.d.ts +1 -1
  57. package/dist/primitives/select/aparte-optgroup.d.ts +1 -0
  58. package/dist/primitives/select/aparte-optgroup.d.ts.map +1 -1
  59. package/dist/primitives/select/aparte-select.d.ts +12 -1
  60. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  61. package/dist/renderers/markdown-stream.d.ts.map +1 -1
  62. package/dist/renderers/segments/tool-call.d.ts.map +1 -1
  63. package/dist/runtime/message-repository.d.ts +16 -3
  64. package/dist/runtime/message-repository.d.ts.map +1 -1
  65. package/dist/types/chat.d.ts +41 -46
  66. package/dist/types/chat.d.ts.map +1 -1
  67. package/dist/types/event-map.d.ts +7 -7
  68. package/dist/types/event-map.d.ts.map +1 -1
  69. package/dist/types/events.d.ts +7 -6
  70. package/dist/types/events.d.ts.map +1 -1
  71. package/dist/types/imperative-api.d.ts +16 -0
  72. package/dist/types/imperative-api.d.ts.map +1 -1
  73. package/dist/types/index.d.ts +3 -3
  74. package/dist/types/index.d.ts.map +1 -1
  75. package/dist/types/models.d.ts +9 -0
  76. package/dist/types/models.d.ts.map +1 -1
  77. package/dist/types/segments.d.ts +56 -13
  78. package/dist/types/segments.d.ts.map +1 -1
  79. package/dist/types/tools.d.ts +14 -0
  80. package/dist/types/tools.d.ts.map +1 -1
  81. package/dist/utils/event-target.d.ts +31 -0
  82. package/dist/utils/event-target.d.ts.map +1 -0
  83. package/dist/utils/transcript.d.ts +13 -0
  84. package/dist/utils/transcript.d.ts.map +1 -0
  85. package/package.json +2 -2
  86. package/dist/dialog-triggers-cYGTlfZf.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { $ as resolveConfig, a4 as subscribeConfigChange, a1 as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, J as escapeAttr, F as cssEscape, a7 as mergeSegmentUpdate, K as escapeHtml, a8 as writeStreamedMarkdown, C as copyText, N as getSegmentRenderer, O as installDefaultRenderersOnce, r as AparteMessageRepository, a9 as stampSegmentActivity, aa as stampSegmentOnInsert, ab as stampSegmentOnUpdate, ac as renumberSegments, ad as openSegmentIds, ae as isTerminalStatus, af as adoptMessageSegments, Q as isAwaitingReply, W as populateBubbleFromMessage, a6 as uuid, a0 as revokeAttachmentUrls, e as APARTE_DEFAULT_LOCALE, P as installDialogTriggersOnce } from "./dialog-triggers-cYGTlfZf.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, G, H, I, L, M, R, S, T, U, V, X, Y, Z, _, a2, a3, a5 } from "./dialog-triggers-cYGTlfZf.js";
1
+ import { $ as resolveConfig, a7 as eventOrigin, a8 as activeElementIn, a4 as subscribeConfigChange, a1 as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, J as escapeAttr, F as cssEscape, a9 as mergeSegmentUpdate, K as escapeHtml, aa as writeStreamedMarkdown, S as isSafeUrl, C as copyText, N as getSegmentRenderer, O as installDefaultRenderersOnce, r as AparteMessageRepository, ab as stampSegmentActivity, ac as stampSegmentOnInsert, ad as stampSegmentOnUpdate, ae as renumberSegments, af as openSegmentIds, ag as isTerminalStatus, ah as adoptMessageSegments, Q as isAwaitingReply, W as populateBubbleFromMessage, a6 as uuid, a0 as revokeAttachmentUrls, V as parseMarkdownToSegments, ai as APARTE_DERIVED_SEGMENTS, e as APARTE_DEFAULT_LOCALE, aj as chatBoundaryOf, ak as rootOf, P as installDialogTriggersOnce } from "./dialog-triggers-CJWw0P3l.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, G, H, I, L, M, R, T, U, X, Y, Z, _, a2, a3, a5 } from "./dialog-triggers-CJWw0P3l.js";
3
3
  function presenceOn(value) {
4
4
  return value === "" ? true : Boolean(value);
5
5
  }
@@ -95,6 +95,9 @@ class AparteOptgroup extends HTMLElement {
95
95
  get collapsible() {
96
96
  return this.hasAttribute("collapsible");
97
97
  }
98
+ set collapsible(val) {
99
+ this.toggleAttribute("collapsible", presenceOn(val));
100
+ }
98
101
  get collapsed() {
99
102
  return this.hasAttribute("collapsed");
100
103
  }
@@ -202,6 +205,31 @@ class AparteSelect extends HTMLElement {
202
205
  _boundHandleOptionClick = this._handleOptionClick.bind(this);
203
206
  _boundHandleDocumentClick = this._handleDocumentClick.bind(this);
204
207
  _boundHandleKeydown = this._handleKeydown.bind(this);
208
+ /**
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.
215
+ */
216
+ _boundHandleTriggerClick = () => {
217
+ this._toggle();
218
+ };
219
+ _boundHandleTriggerKeydown = (e) => {
220
+ if (e.key === "Enter" || e.key === " ") {
221
+ if (this._isOpen) return;
222
+ e.preventDefault();
223
+ this._toggle();
224
+ }
225
+ if (e.key === "ArrowDown" && !this._isOpen) {
226
+ e.preventDefault();
227
+ this._openDropdown();
228
+ }
229
+ };
230
+ _boundHandleSearchInput = (e) => {
231
+ this._filterOptions(e.target.value.toLowerCase());
232
+ };
205
233
  static get observedAttributes() {
206
234
  return ["value", "placeholder", "disabled", "searchable", "open"];
207
235
  }
@@ -335,6 +363,7 @@ class AparteSelect extends HTMLElement {
335
363
  this._updateTriggerLabel();
336
364
  }
337
365
  _setupMutationObserver() {
366
+ this._observer?.disconnect();
338
367
  this._observer = new MutationObserver(() => {
339
368
  this._updateDropdownContent();
340
369
  this._updateTriggerLabel();
@@ -382,24 +411,14 @@ class AparteSelect extends HTMLElement {
382
411
  this._restoreActive();
383
412
  }
384
413
  _setupEventListeners() {
385
- this._trigger?.addEventListener("click", () => this._toggle());
386
- this._trigger?.addEventListener("keydown", (e) => {
387
- if (e.key === "Enter" || e.key === " ") {
388
- if (this._isOpen) return;
389
- e.preventDefault();
390
- this._toggle();
391
- }
392
- if (e.key === "ArrowDown" && !this._isOpen) {
393
- e.preventDefault();
394
- this._openDropdown();
395
- }
396
- });
414
+ this._trigger?.removeEventListener("click", this._boundHandleTriggerClick);
415
+ this._trigger?.addEventListener("click", this._boundHandleTriggerClick);
416
+ this._trigger?.removeEventListener("keydown", this._boundHandleTriggerKeydown);
417
+ this._trigger?.addEventListener("keydown", this._boundHandleTriggerKeydown);
397
418
  this.removeEventListener("click", this._boundHandleOptionClick);
398
419
  this.addEventListener("click", this._boundHandleOptionClick);
399
- this._searchInput?.addEventListener("input", (e) => {
400
- const query = e.target.value.toLowerCase();
401
- this._filterOptions(query);
402
- });
420
+ this._searchInput?.removeEventListener("input", this._boundHandleSearchInput);
421
+ this._searchInput?.addEventListener("input", this._boundHandleSearchInput);
403
422
  document.addEventListener("click", this._boundHandleDocumentClick);
404
423
  document.addEventListener("keydown", this._boundHandleKeydown);
405
424
  }
@@ -410,13 +429,14 @@ class AparteSelect extends HTMLElement {
410
429
  }
411
430
  }
412
431
  _handleDocumentClick(e) {
413
- if (!this.contains(e.target)) {
432
+ const origin = eventOrigin(e);
433
+ if (!origin || !this.contains(origin)) {
414
434
  this._closeDropdown();
415
435
  }
416
436
  }
417
437
  _handleKeydown(e) {
418
438
  if (!this._isOpen) return;
419
- const inSearch = document.activeElement === this._searchInput;
439
+ const inSearch = activeElementIn(this) === this._searchInput;
420
440
  switch (e.key) {
421
441
  case "Escape":
422
442
  e.preventDefault();
@@ -951,11 +971,21 @@ class AparteElicitation extends HTMLElement {
951
971
  if (typeof customElements !== "undefined" && !customElements.get("aparte-elicitation")) {
952
972
  customElements.define("aparte-elicitation", AparteElicitation);
953
973
  }
974
+ function isTranscriptEmpty(viewport) {
975
+ return !viewport.hasAttribute("loading") && !viewport.querySelector("aparte-chat-bubble");
976
+ }
954
977
  class AparteChat extends HTMLElement {
955
978
  static get observedAttributes() {
956
- return ["placeholder", "disabled", "submit-on-enter", "center-empty", "attachments"];
979
+ return ["placeholder", "disabled", "submit-on-enter", "attachments"];
957
980
  }
958
981
  _observer = null;
982
+ _loadingObserver = null;
983
+ /** Waits for a `<aparte-chat-viewport>` child to appear — see `_watchForViewport`. */
984
+ _childObserver = null;
985
+ /** The viewport is loading and the gate is on — see `_applyLoadingGate`. */
986
+ _gated = false;
987
+ /** The composer's `disabled` is the gate's to remove, not one the consumer set. */
988
+ _gateOwns = false;
959
989
  /**
960
990
  * True only for the composition THIS element injected. An author-provided
961
991
  * composer (or a `framework-managed` host) is never edited by the attachments
@@ -968,21 +998,27 @@ class AparteChat extends HTMLElement {
968
998
  this._forwardAttr("disabled");
969
999
  this._forwardAttr("submit-on-enter");
970
1000
  this._syncEmptyWatch();
1001
+ this._syncLoadingWatch();
1002
+ this._watchForViewport();
971
1003
  }
972
1004
  disconnectedCallback() {
973
1005
  this._observer?.disconnect();
974
1006
  this._observer = null;
1007
+ this._loadingObserver?.disconnect();
1008
+ this._loadingObserver = null;
1009
+ this._childObserver?.disconnect();
1010
+ this._childObserver = null;
975
1011
  }
976
1012
  attributeChangedCallback(name, oldValue, newValue) {
977
1013
  if (oldValue === newValue) return;
978
- if (name === "center-empty") {
979
- this._syncEmptyWatch();
980
- return;
981
- }
982
1014
  if (name === "attachments") {
983
1015
  this._syncAttachments();
984
1016
  return;
985
1017
  }
1018
+ if (name === "disabled" && newValue === null && this._gated) {
1019
+ this._gateOwns = true;
1020
+ return;
1021
+ }
986
1022
  const composer = this.querySelector("aparte-composer");
987
1023
  if (!composer) return;
988
1024
  if (newValue !== null) composer.setAttribute(name, newValue);
@@ -1045,24 +1081,86 @@ class AparteChat extends HTMLElement {
1045
1081
  if (!this.hasAttribute(name)) return;
1046
1082
  this.querySelector("aparte-composer")?.setAttribute(name, this.getAttribute(name) ?? "");
1047
1083
  }
1048
- /** Start/stop watching the viewport so `center-empty` toggles itself. */
1084
+ /**
1085
+ * Watch the viewport so `data-empty` follows the conversation.
1086
+ *
1087
+ * It used to run only under `center-empty`, which made the chat's one statement of
1088
+ * "this conversation is empty" a by-product of a layout option: an app that wanted
1089
+ * starters on an empty thread, or a welcome of its own, had to count messages itself.
1090
+ * Every selector core writes against it still requires `[center-empty]`, so reflecting
1091
+ * it everywhere moves no layout.
1092
+ */
1049
1093
  _syncEmptyWatch() {
1050
1094
  this._observer?.disconnect();
1051
1095
  this._observer = null;
1052
- if (!this.hasAttribute("center-empty")) {
1053
- this.removeAttribute("data-empty");
1054
- return;
1055
- }
1056
1096
  const viewport = this.querySelector("aparte-chat-viewport");
1057
1097
  if (!viewport) return;
1058
1098
  this._updateEmpty();
1059
1099
  this._observer = new MutationObserver(() => this._updateEmpty());
1060
- this._observer.observe(viewport, { childList: true, subtree: true });
1100
+ this._observer.observe(viewport, { childList: true, subtree: true, attributes: true, attributeFilter: ["loading"] });
1101
+ }
1102
+ /**
1103
+ * A transcript on its way cannot take a message: while the viewport says `loading`
1104
+ * the composer is disabled, whatever else the chat is set to. A send in that window
1105
+ * raced the fetch — the controller appended to a history that `setMessages`
1106
+ * overwrote a moment later. Independent of `center-empty`: every chat gates.
1107
+ */
1108
+ _syncLoadingWatch() {
1109
+ this._loadingObserver?.disconnect();
1110
+ this._loadingObserver = null;
1111
+ const viewport = this.querySelector("aparte-chat-viewport");
1112
+ if (!viewport) return;
1113
+ this._applyLoadingGate();
1114
+ this._loadingObserver = new MutationObserver(() => this._applyLoadingGate());
1115
+ this._loadingObserver.observe(viewport, { attributes: true, attributeFilter: ["loading"] });
1116
+ }
1117
+ /**
1118
+ * Both watches above need a viewport to observe, and they were installed once, at
1119
+ * connect, when there might not be one yet: `<aparte-chat framework-managed>` IS
1120
+ * Angular's host element — upgraded on insert, filled by the template afterwards — and
1121
+ * hand-written markup upgrades before its children are parsed. Neither ever got its
1122
+ * observer, so the documented "every chat gates" was inert on exactly the chats whose
1123
+ * transcript is fetched. This waits for the child to arrive, installs them, and stops.
1124
+ */
1125
+ _watchForViewport() {
1126
+ this._childObserver?.disconnect();
1127
+ this._childObserver = null;
1128
+ if (this.querySelector("aparte-chat-viewport")) return;
1129
+ this._childObserver = new MutationObserver(() => {
1130
+ if (!this.querySelector("aparte-chat-viewport")) return;
1131
+ this._childObserver?.disconnect();
1132
+ this._childObserver = null;
1133
+ this._syncEmptyWatch();
1134
+ this._syncLoadingWatch();
1135
+ });
1136
+ this._childObserver.observe(this, { childList: true, subtree: true });
1137
+ }
1138
+ /**
1139
+ * The gate only ever ADDS `disabled` and only ever removes what it added: a `disabled`
1140
+ * the consumer set — on this element, or on a composer of their own — stays.
1141
+ */
1142
+ _applyLoadingGate() {
1143
+ const composer = this.querySelector("aparte-composer");
1144
+ const loading = this.querySelector("aparte-chat-viewport")?.hasAttribute("loading") ?? false;
1145
+ if (!composer) return;
1146
+ if (loading && !this._gated) {
1147
+ this._gated = true;
1148
+ this._gateOwns = !composer.hasAttribute("disabled");
1149
+ composer.setAttribute("disabled", "");
1150
+ } else if (!loading && this._gated) {
1151
+ this._gated = false;
1152
+ if (this._gateOwns && !this.hasAttribute("disabled")) composer.removeAttribute("disabled");
1153
+ this._gateOwns = false;
1154
+ }
1061
1155
  }
1156
+ /**
1157
+ * Empty means no message AND none on its way. A viewport that says `loading` holds
1158
+ * no bubble yet, and it used to count as empty: the welcome and the centred composer
1159
+ * showed while a conversation was being fetched.
1160
+ */
1062
1161
  _updateEmpty() {
1063
1162
  const viewport = this.querySelector("aparte-chat-viewport");
1064
- const empty = !viewport || !viewport.querySelector("aparte-chat-bubble");
1065
- this.toggleAttribute("data-empty", empty);
1163
+ this.toggleAttribute("data-empty", !viewport || isTranscriptEmpty(viewport));
1066
1164
  }
1067
1165
  }
1068
1166
  if (!customElements.get("aparte-chat")) {
@@ -1507,6 +1605,11 @@ class AparteChatBubble extends HTMLElement {
1507
1605
  </div>
1508
1606
  `;
1509
1607
  }
1608
+ const messageEl = this.querySelector(".aparte-message");
1609
+ if (messageEl && !messageEl.hasAttribute("role")) {
1610
+ messageEl.setAttribute("role", "article");
1611
+ messageEl.setAttribute("aria-label", this._getAriaLabel());
1612
+ }
1510
1613
  this._contentEl = this.querySelector(".aparte-content");
1511
1614
  this._segmentsEl = this.querySelector(".aparte-segments");
1512
1615
  this._attachmentsEl = this.querySelector(".aparte-attachments");
@@ -1578,7 +1681,9 @@ class AparteChatBubble extends HTMLElement {
1578
1681
  const nameEl = this.querySelector(".aparte-name");
1579
1682
  if (message) {
1580
1683
  message.setAttribute("data-role", this._role);
1581
- message.setAttribute("aria-label", this._getAriaLabel());
1684
+ if (message.getAttribute("role") === "article") {
1685
+ message.setAttribute("aria-label", this._getAriaLabel());
1686
+ }
1582
1687
  }
1583
1688
  if (avatar) {
1584
1689
  avatar.setAttribute("data-role", this._role);
@@ -1714,7 +1819,7 @@ class AparteChatBubble extends HTMLElement {
1714
1819
  }
1715
1820
  this._attachmentsEl.innerHTML = this._attachments.map((a4) => {
1716
1821
  const name = escapeHtml(a4.name);
1717
- if (a4.type.startsWith("image/")) {
1822
+ if (a4.type.startsWith("image/") && this._isShowableUrl(a4.url)) {
1718
1823
  return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumbnail__image" src="${escapeHtml(a4.url)}" alt="${name}" loading="lazy" /><span class="aparte-thumb__name">${name}</span></div>`;
1719
1824
  }
1720
1825
  return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumbnail__label">${escapeHtml(this._fileExt(a4.name))}</span><span class="aparte-thumb__name">${name}</span></div>`;
@@ -1742,6 +1847,24 @@ class AparteChatBubble extends HTMLElement {
1742
1847
  });
1743
1848
  }
1744
1849
  /** Uppercased file extension (≤4 chars), or 'FILE' when there is none. */
1850
+ /**
1851
+ * The same answer `copyAttributes` gives before it writes an `src`, plus `blob:`.
1852
+ *
1853
+ * An attachment's `url` is documented as "URL or data URI" and a storage adapter
1854
+ * re-mints it when a conversation is restored, so it is the app's value, not the
1855
+ * composer's — it was escaped and written, where every other URL core emits is
1856
+ * asked whether its SCHEME is allowed at all. A picture whose URL is refused is
1857
+ * not half-rendered: it falls back to the file chip, which still names the file.
1858
+ *
1859
+ * `blob:` is added because it is the shape core itself mints — `filesToAttachments`
1860
+ * calls `URL.createObjectURL`, and `AparteMessage.attachments` documents the same
1861
+ * on hydration. `isSafeUrl` refuses it, and rightly: that list governs MODEL
1862
+ * markup, which has no way to make a blob URL resolve.
1863
+ */
1864
+ _isShowableUrl(url) {
1865
+ if (!url) return false;
1866
+ return isSafeUrl(url, "img") || /^blob:/i.test(url.trim());
1867
+ }
1745
1868
  _fileExt(filename) {
1746
1869
  const dot = filename.lastIndexOf(".");
1747
1870
  return dot > 0 ? filename.slice(dot + 1).toUpperCase().slice(0, 4) : "FILE";
@@ -2255,7 +2378,7 @@ class AparteChatBubble extends HTMLElement {
2255
2378
  }
2256
2379
  }
2257
2380
  this._updateWaiting();
2258
- if (wasStreaming && !streaming) this._highlightContentCode();
2381
+ if (wasStreaming && !streaming) this._updateContent();
2259
2382
  }
2260
2383
  }
2261
2384
  if (!customElements.get("aparte-chat-bubble")) {
@@ -2421,6 +2544,20 @@ class AparteChatStatus extends HTMLElement {
2421
2544
  if (!customElements.get("aparte-chat-status")) {
2422
2545
  customElements.define("aparte-chat-status", AparteChatStatus);
2423
2546
  }
2547
+ function withParsedSegments(message, blocks) {
2548
+ if (message.role !== "assistant" || message.segments?.length) return message;
2549
+ if (typeof message.content !== "string" || !message.content.trim()) return message;
2550
+ const segments = parseMarkdownToSegments(message.content, { blocks });
2551
+ const onlyProse = segments.length <= 1 && segments.every((s2) => s2.type === "text");
2552
+ return onlyProse ? message : markDerivedSegments({ ...message, segments });
2553
+ }
2554
+ function markDerivedSegments(message) {
2555
+ Object.defineProperty(message, APARTE_DERIVED_SEGMENTS, { value: true, enumerable: false });
2556
+ return message;
2557
+ }
2558
+ function hasDerivedSegments(message) {
2559
+ return message[APARTE_DERIVED_SEGMENTS] === true;
2560
+ }
2424
2561
  class AparteChatViewport extends HTMLElement {
2425
2562
  // The scroll surface: an internal `.aparte-viewport-container` div (core mode)
2426
2563
  // or the host element itself (framework-managed mode). HTMLElement covers both.
@@ -2443,6 +2580,11 @@ class AparteChatViewport extends HTMLElement {
2443
2580
  _lastScrollTop = 0;
2444
2581
  /** The scroll height at the last scroll event: how much it moved since bounds what churn can do. */
2445
2582
  _lastScrollHeight = 0;
2583
+ /** When the transcript last mutated under our observer — an engine's settle adjustment follows one. */
2584
+ _lastMutationAt = Number.NEGATIVE_INFINITY;
2585
+ /** How long after a mutation an engine's settle adjustment can arrive, and how far it moves. */
2586
+ _settleShadowMs = 100;
2587
+ _settleBoundPx = 100;
2446
2588
  /** When this component last moved `scrollTop` itself — see `_handleScroll`. "Never" is
2447
2589
  * -Infinity rather than 0: `performance.now()` can be under a second old. */
2448
2590
  _ownScrollAt = Number.NEGATIVE_INFINITY;
@@ -2504,7 +2646,53 @@ class AparteChatViewport extends HTMLElement {
2504
2646
  */
2505
2647
  _frameworkManagedDOM = false;
2506
2648
  static get observedAttributes() {
2507
- return ["scroll-threshold", "max-rendered-bubbles"];
2649
+ return ["scroll-threshold", "max-rendered-bubbles", "loading"];
2650
+ }
2651
+ /**
2652
+ * The transcript is on its way: a conversation was chosen and its messages are
2653
+ * being fetched. Two skeleton turns stand where they will be, the scroll surface
2654
+ * is `aria-busy`, and a visually hidden line names the wait. Reflected as the
2655
+ * `loading` attribute — `AparteConversationController` sets it through its
2656
+ * binding; a loop of your own sets it by hand. Not the same thing as `data-busy`,
2657
+ * which is a reply streaming into a transcript that is already there.
2658
+ */
2659
+ get loading() {
2660
+ return this.hasAttribute("loading");
2661
+ }
2662
+ set loading(value) {
2663
+ this.toggleAttribute("loading", presenceOn(value));
2664
+ }
2665
+ setLoading(loading) {
2666
+ this.loading = loading;
2667
+ }
2668
+ /** Draw or remove the wait, from the attribute. Safe before the structure exists. */
2669
+ _syncLoading() {
2670
+ const container = this._container;
2671
+ if (!container) return;
2672
+ const on = this.hasAttribute("loading");
2673
+ container.setAttribute("aria-busy", on ? "true" : "false");
2674
+ if (this._frameworkManagedDOM) return;
2675
+ const existing = container.querySelector(":scope > .aparte-viewport-loading");
2676
+ if (!on) {
2677
+ existing?.remove();
2678
+ container.querySelector(":scope > .aparte-viewport-loading-status")?.remove();
2679
+ return;
2680
+ }
2681
+ if (existing) return;
2682
+ const cfg = resolveConfig(this);
2683
+ const skeleton = document.createElement("div");
2684
+ skeleton.className = "aparte-viewport-loading";
2685
+ skeleton.setAttribute("aria-hidden", "true");
2686
+ skeleton.innerHTML = '<span class="aparte-skeleton aparte-skeleton--rect aparte-viewport-loading__user"></span><span class="aparte-skeleton aparte-skeleton--text"></span><span class="aparte-skeleton aparte-skeleton--text"></span><span class="aparte-skeleton aparte-skeleton--text"></span><span class="aparte-skeleton aparte-skeleton--text aparte-viewport-loading__last"></span>';
2687
+ const status = document.createElement("span");
2688
+ status.className = "aparte-viewport-loading-status aparte-sr-only";
2689
+ status.setAttribute("role", "status");
2690
+ container.prepend(skeleton, status);
2691
+ const say = () => {
2692
+ if (status.isConnected) status.textContent = cfg.t("loadingConversation");
2693
+ };
2694
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(say);
2695
+ else say();
2508
2696
  }
2509
2697
  constructor() {
2510
2698
  super();
@@ -2589,6 +2777,9 @@ class AparteChatViewport extends HTMLElement {
2589
2777
  this._maxRenderedBubbles = parseInt(newValue || "1000", 10);
2590
2778
  this._pruneRenderedBubbles();
2591
2779
  break;
2780
+ case "loading":
2781
+ this._syncLoading();
2782
+ break;
2592
2783
  }
2593
2784
  }
2594
2785
  /**
@@ -2670,12 +2861,32 @@ class AparteChatViewport extends HTMLElement {
2670
2861
  if (streaming !== null && streaming !== head) return null;
2671
2862
  return head;
2672
2863
  }
2673
- /** The id of the message currently streaming, if any. */
2864
+ /**
2865
+ * The id of the message currently streaming, if any — searched across the whole
2866
+ * TREE, and answering with an OFF-HEAD one first when there are two.
2867
+ *
2868
+ * It used to scan `getMessages()`, the active path. A retry or an edit on an
2869
+ * earlier bubble calls `addSiblingOf`, which switches the branch to the new pending
2870
+ * reply: the turn still being written leaves the path, the scan found nothing, and
2871
+ * the rest of that turn was appended to the message that had just replaced it.
2872
+ *
2873
+ * A new pending reply is itself marked streaming, so "the first one found" would
2874
+ * answer the head and let the write through — the very case this exists to refuse.
2875
+ * Answering the off-head one instead makes `_activeMessageId` refuse while ANY turn
2876
+ * elsewhere in the tree is still open. That also refuses the new turn's own first
2877
+ * deltas for as long as the superseded one has not settled, which is the trade this
2878
+ * whole path already makes: losing the tail is visible, writing it onto someone
2879
+ * else's message is not.
2880
+ */
2674
2881
  _streamingMessageId() {
2675
- for (const message of this._repo.getMessages()) {
2676
- if (message.isStreaming) return message.id;
2882
+ const head = this._repo.headId;
2883
+ let atHead = null;
2884
+ for (const message of this._repo.getAllMessages()) {
2885
+ if (!message.isStreaming) continue;
2886
+ if (message.id !== head) return message.id;
2887
+ atHead = message.id;
2677
2888
  }
2678
- return null;
2889
+ return atHead;
2679
2890
  }
2680
2891
  /**
2681
2892
  * While a reply streams, the transcript is read-only except for Stop.
@@ -2817,6 +3028,12 @@ class AparteChatViewport extends HTMLElement {
2817
3028
  /**
2818
3029
  * Atomic update for a message by ID
2819
3030
  * Supports updating content, status, segments, and other metadata
3031
+ *
3032
+ * An id the tree does not hold is a silent no-op, deliberately: the message may have
3033
+ * been truncated away by an edit while a turn was still writing to it, and inventing
3034
+ * it back would put a message the reader deleted on screen. It does mean a late error
3035
+ * card can have nowhere to land — `aparte-message-error` still fires, so a host that
3036
+ * wants to show one has the event.
2820
3037
  */
2821
3038
  updateMessage(messageId, updates) {
2822
3039
  const message = this._repo.getMessageById(messageId);
@@ -2855,6 +3072,7 @@ class AparteChatViewport extends HTMLElement {
2855
3072
  * the framework owns the DOM and will create the bubble element itself.
2856
3073
  */
2857
3074
  appendMessage(message, options) {
3075
+ message = withParsedSegments(message, resolveConfig(this).getStreamBlocks());
2858
3076
  const stored = options?.historical ? adoptMessageSegments(message) : message.segments?.length ? { ...message, segments: message.segments.reduce(
2859
3077
  (acc, segment) => {
2860
3078
  acc.push(stampSegmentOnInsert(
@@ -2868,6 +3086,7 @@ class AparteChatViewport extends HTMLElement {
2868
3086
  []
2869
3087
  ) } : { ...message };
2870
3088
  if (!options?.historical && isAwaitingReply(message)) stored.isStreaming = true;
3089
+ if (hasDerivedSegments(message)) markDerivedSegments(stored);
2871
3090
  this._repo.addOrUpdateMessage(this._repo.headId, stored);
2872
3091
  if (!this._frameworkManagedDOM) {
2873
3092
  const wrapper = this.querySelector(".aparte-messages-wrapper");
@@ -3050,12 +3269,15 @@ class AparteChatViewport extends HTMLElement {
3050
3269
  */
3051
3270
  importTree(tree) {
3052
3271
  this.clearAll({ revokeAttachments: false });
3272
+ const blocks = resolveConfig(this).getStreamBlocks();
3053
3273
  this._repo.import({
3054
3274
  ...tree,
3055
- messages: tree.messages.map((entry) => ({
3056
- ...entry,
3057
- message: adoptMessageSegments(entry.message)
3058
- }))
3275
+ messages: tree.messages.map((entry) => {
3276
+ const parsed = withParsedSegments(entry.message, blocks);
3277
+ const message = adoptMessageSegments(parsed);
3278
+ if (hasDerivedSegments(parsed)) markDerivedSegments(message);
3279
+ return { ...entry, message };
3280
+ })
3059
3281
  });
3060
3282
  this._reRenderActivePath();
3061
3283
  }
@@ -3070,7 +3292,7 @@ class AparteChatViewport extends HTMLElement {
3070
3292
  */
3071
3293
  clearAll(options) {
3072
3294
  if (options?.revokeAttachments !== false) {
3073
- for (const message of this._repo.getMessages()) {
3295
+ for (const message of this._repo.getAllMessages()) {
3074
3296
  revokeAttachmentUrls(message.attachments);
3075
3297
  }
3076
3298
  }
@@ -3292,6 +3514,7 @@ class AparteChatViewport extends HTMLElement {
3292
3514
  container.appendChild(wrapper);
3293
3515
  this.appendChild(container);
3294
3516
  this._container = container;
3517
+ this._syncLoading();
3295
3518
  this._scrollBtn = document.createElement("button");
3296
3519
  this._scrollBtn.className = "aparte-btn aparte-btn--surface aparte-btn--circle aparte-btn--lg aparte-scroll-btn aparte-scroll-btn--hidden";
3297
3520
  this._scrollBtn.setAttribute("type", "button");
@@ -3382,6 +3605,19 @@ class AparteChatViewport extends HTMLElement {
3382
3605
  * 1 run in 6: the rebuild's height churn moved scrollTop within the second after
3383
3606
  * the click, and the click made that look like the reader's).
3384
3607
  */
3608
+ /**
3609
+ * Is a pointer pressed on the surface right now? A press on the TEXT is not a scroll
3610
+ * gesture (`_noteReaderInput` counts only the gutter), but a hand that is down and
3611
+ * dragging a selection past the edge scrolls the container all the same — and that
3612
+ * decrease is the reader's. Held from the press to its release, wherever it lands.
3613
+ */
3614
+ _pointerHeld = false;
3615
+ _holdPointer = () => {
3616
+ this._pointerHeld = true;
3617
+ };
3618
+ _releasePointer = () => {
3619
+ this._pointerHeld = false;
3620
+ };
3385
3621
  _noteReaderInput = (e) => {
3386
3622
  if (e.type === "keydown" && !this._scrollKeys.has(e.key)) return;
3387
3623
  if (e.type === "pointerdown") {
@@ -3414,6 +3650,9 @@ class AparteChatViewport extends HTMLElement {
3414
3650
  for (const type of ["wheel", "touchmove", "pointerdown", "keydown"]) {
3415
3651
  this._container?.addEventListener(type, this._noteReaderInput, { passive: true });
3416
3652
  }
3653
+ this._container?.addEventListener("pointerdown", this._holdPointer, { passive: true });
3654
+ document.addEventListener("pointerup", this._releasePointer, true);
3655
+ document.addEventListener("pointercancel", this._releasePointer, true);
3417
3656
  this._scrollBtn?.addEventListener("click", this._onScrollBtnClick);
3418
3657
  this.addEventListener("aparte-branch-navigate", this._onBranchNavigate);
3419
3658
  }
@@ -3446,6 +3685,7 @@ class AparteChatViewport extends HTMLElement {
3446
3685
  this._updateOverlayInset();
3447
3686
  }
3448
3687
  this._mutationObserver = new MutationObserver((mutations) => {
3688
+ this._lastMutationAt = performance.now();
3449
3689
  if (this._frameworkManagedDOM) this._keepScrollButtonLast();
3450
3690
  if (this._isAutoScrollEnabled && !this._gliding()) {
3451
3691
  const added = mutations.flatMap((m2) => Array.from(m2.addedNodes)).filter((n2) => n2 instanceof Element && n2.tagName === "APARTE-CHAT-BUBBLE");
@@ -3547,7 +3787,8 @@ class AparteChatViewport extends HTMLElement {
3547
3787
  const drop = this._lastScrollTop - top;
3548
3788
  const churn = Math.abs(height - this._lastScrollHeight);
3549
3789
  const now = performance.now();
3550
- const settlingOurs = drop <= churn + 2 && now - this._ownScrollAt < this._ownScrollWindowMs && now - this._readerInputAt >= this._ownScrollWindowMs;
3790
+ const afterMutation = now - this._lastMutationAt < this._settleShadowMs && drop <= this._settleBoundPx;
3791
+ const settlingOurs = now - this._ownScrollAt < this._ownScrollWindowMs && now - this._readerInputAt >= this._ownScrollWindowMs && !this._pointerHeld && (drop <= churn + 2 || afterMutation);
3551
3792
  const readerWentUp = drop > 1 && !settlingOurs;
3552
3793
  const readerHandOnIt = drop > 1 && now - this._readerInputAt < this._ownScrollWindowMs;
3553
3794
  this._lastScrollTop = top;
@@ -3788,6 +4029,10 @@ class AparteChatViewport extends HTMLElement {
3788
4029
  for (const type of ["wheel", "touchmove", "pointerdown", "keydown"]) {
3789
4030
  this._container?.removeEventListener(type, this._noteReaderInput);
3790
4031
  }
4032
+ this._container?.removeEventListener("pointerdown", this._holdPointer);
4033
+ document.removeEventListener("pointerup", this._releasePointer, true);
4034
+ document.removeEventListener("pointercancel", this._releasePointer, true);
4035
+ this._pointerHeld = false;
3791
4036
  this._scrollBtn?.removeEventListener("click", this._onScrollBtnClick);
3792
4037
  this.removeEventListener("aparte-branch-navigate", this._onBranchNavigate);
3793
4038
  this._resizeObserver?.disconnect();
@@ -3934,6 +4179,16 @@ class AparteComposer extends HTMLElement {
3934
4179
  get disabled() {
3935
4180
  return this.hasAttribute("disabled");
3936
4181
  }
4182
+ /**
4183
+ * The attribute is the state, so the property writes it. It has to exist: React 19
4184
+ * and Svelte assign the PROPERTY whenever the element has one of that name, and a
4185
+ * getter alone made `<aparte-composer disabled="">` — a spelling the wrappers type
4186
+ * as valid — throw and take the render down. `presenceOn` because `''` is what
4187
+ * those templates hand a presence property (#62).
4188
+ */
4189
+ set disabled(value) {
4190
+ this.toggleAttribute("disabled", presenceOn(value));
4191
+ }
3937
4192
  /**
3938
4193
  * When false, Shift+Enter submits and a bare Enter inserts a newline —
3939
4194
  * the inverse of the default. Driven by the `submit-on-enter` attribute.
@@ -3947,6 +4202,11 @@ class AparteComposer extends HTMLElement {
3947
4202
  get placeholder() {
3948
4203
  return this.getAttribute("placeholder") ?? "";
3949
4204
  }
4205
+ /** Writes the attribute the inputs read (see `disabled` for why the setter exists). */
4206
+ set placeholder(value) {
4207
+ if (value === null || value === void 0) this.removeAttribute("placeholder");
4208
+ else this.setAttribute("placeholder", String(value));
4209
+ }
3950
4210
  get targetId() {
3951
4211
  return this.getAttribute("target");
3952
4212
  }
@@ -4287,13 +4547,35 @@ class AparteComposer extends HTMLElement {
4287
4547
  }
4288
4548
  return void 0;
4289
4549
  }
4550
+ /**
4551
+ * The message this composer's turn is writing, when the event that started it said.
4552
+ * Kept so the END of a DIFFERENT turn cannot be read as the end of this one — see
4553
+ * `_isForThisTurn`.
4554
+ */
4555
+ _streamingMessageId = null;
4556
+ /**
4557
+ * Whether a terminal event ends the turn this composer is following.
4558
+ *
4559
+ * `_isForThisComposer` answers "is this chat's?", and both turns of a retry belong
4560
+ * to the same chat: the client aborts the superseded reply, the engine emits
4561
+ * `run-aborted` for it, and that unwind turned Stop back into Send while the new
4562
+ * reply was still streaming — and evicted the open panel, i.e. the approval question
4563
+ * a tool was still waiting on. An event with no `messageId` still ends the turn: a
4564
+ * loop of one's own saying "done" has always meant this one.
4565
+ */
4566
+ _isForThisTurn(e) {
4567
+ const id = e.detail?.messageId;
4568
+ return !id || this._streamingMessageId === null || id === this._streamingMessageId;
4569
+ }
4290
4570
  _handleMessageStart(e) {
4291
4571
  if (!this._isForThisComposer(e)) return;
4572
+ this._streamingMessageId = e.detail?.messageId ?? null;
4292
4573
  this._streaming = true;
4293
4574
  this._emit("streaming-change", { streaming: true });
4294
4575
  }
4295
4576
  _handleMessageDone(e) {
4296
- if (!this._isForThisComposer(e)) return;
4577
+ if (!this._isForThisComposer(e) || !this._isForThisTurn(e)) return;
4578
+ this._streamingMessageId = null;
4297
4579
  this._streaming = false;
4298
4580
  this._emit("streaming-change", { streaming: false });
4299
4581
  if (this._panelActive) this._evictPanel();
@@ -5080,8 +5362,10 @@ class AparteComposerToolbar extends HTMLElement {
5080
5362
  _observer = null;
5081
5363
  connectedCallback() {
5082
5364
  this._syncEmpty();
5083
- this._observer ??= new MutationObserver(() => this._syncEmpty());
5084
- this._observer.observe(this, { childList: true });
5365
+ this._observer ??= new MutationObserver((records) => {
5366
+ if (records.some((r) => !(r.type === "attributes" && r.target === this))) this._syncEmpty();
5367
+ });
5368
+ this._observer.observe(this, { childList: true, subtree: true, attributes: true, attributeFilter: ["hidden", "data-empty"] });
5085
5369
  }
5086
5370
  disconnectedCallback() {
5087
5371
  this._observer?.disconnect();
@@ -5101,7 +5385,10 @@ class AparteComposerToolbar extends HTMLElement {
5101
5385
  * not empty, and hiding it would be a twenty-minute mystery for whoever wrote it.
5102
5386
  */
5103
5387
  _syncEmpty() {
5104
- const hasContent = Boolean(this.firstElementChild) || this.textContent?.trim() !== "";
5388
+ const visibleChild = Array.from(this.children).some((c2) => !c2.hasAttribute("hidden") && !c2.hasAttribute("data-empty"));
5389
+ const ownText = Array.from(this.childNodes).some((n2) => n2.nodeType === Node.TEXT_NODE && (n2.textContent ?? "").trim() !== "");
5390
+ const hasContent = visibleChild || ownText;
5391
+ if (hasContent === !this.hasAttribute("data-empty")) return;
5105
5392
  if (hasContent) this.removeAttribute("data-empty");
5106
5393
  else this.setAttribute("data-empty", "");
5107
5394
  }
@@ -5109,6 +5396,9 @@ class AparteComposerToolbar extends HTMLElement {
5109
5396
  if (!customElements.get("aparte-composer-toolbar")) {
5110
5397
  customElements.define("aparte-composer-toolbar", AparteComposerToolbar);
5111
5398
  }
5399
+ function rowSelector(attr, id) {
5400
+ return `[${attr}="${cssEscape(id)}"]`;
5401
+ }
5112
5402
  const DAY = 864e5;
5113
5403
  const MENU_GAP = 4;
5114
5404
  const VIEWPORT_MARGIN = 8;
@@ -5117,8 +5407,37 @@ class AparteConversationList extends HTMLElement {
5117
5407
  _activeId = null;
5118
5408
  _open = null;
5119
5409
  _renaming = null;
5410
+ _warnedNoManager = false;
5120
5411
  static get observedAttributes() {
5121
- return ["active-id", "no-groups"];
5412
+ return ["active-id", "flat", "loading", "manage"];
5413
+ }
5414
+ /**
5415
+ * The list is on its way: rows of the kit's skeleton stand where the rows will be,
5416
+ * the element is `aria-busy`, and a visually hidden line names the wait. Reflected
5417
+ * as the `loading` attribute, so a site sets it while its store answers and clears
5418
+ * it when `conversations` is set. A list that is not there yet is not an empty list.
5419
+ */
5420
+ get loading() {
5421
+ return this.hasAttribute("loading");
5422
+ }
5423
+ set loading(value) {
5424
+ this.toggleAttribute("loading", presenceOn(value));
5425
+ }
5426
+ /**
5427
+ * Let the list finish the gesture through the registered conversation manager.
5428
+ * Reflected as the `manage` attribute.
5429
+ *
5430
+ * The property is not a nicety here: this is the attribute that authorises a WRITE,
5431
+ * and Vue and both Sveltes stringify a bound `false` onto an element that has no
5432
+ * property of that name — `manage="false"`, which `hasAttribute` reads as on. So
5433
+ * turning management off turned it on, and a delete the host meant to handle itself
5434
+ * was also done by the list. `presenceOn`, like `loading` above it.
5435
+ */
5436
+ get manage() {
5437
+ return this.hasAttribute("manage");
5438
+ }
5439
+ set manage(value) {
5440
+ this.toggleAttribute("manage", presenceOn(value));
5122
5441
  }
5123
5442
  // ─── Lifecycle ────────────────────────────────────────────────────────
5124
5443
  connectedCallback() {
@@ -5146,7 +5465,7 @@ class AparteConversationList extends HTMLElement {
5146
5465
  if (name === "active-id") {
5147
5466
  this._activeId = newValue;
5148
5467
  this._updateActiveState();
5149
- } else if (name === "no-groups" && this.isConnected) {
5468
+ } else if ((name === "flat" || name === "loading") && this.isConnected) {
5150
5469
  this._render();
5151
5470
  }
5152
5471
  }
@@ -5174,7 +5493,42 @@ class AparteConversationList extends HTMLElement {
5174
5493
  _render() {
5175
5494
  this._closeMenu();
5176
5495
  this._renaming = null;
5496
+ const focused = activeElementIn(this);
5497
+ const held = focused && this.contains(focused) ? focused.closest("[data-select-id], [data-more-id]") : null;
5498
+ const restore = held?.dataset["selectId"] ? rowSelector("data-select-id", held.dataset["selectId"]) : held?.dataset["moreId"] ? rowSelector("data-more-id", held.dataset["moreId"]) : null;
5499
+ const heldRow = held?.closest("[data-conv-id]") ?? null;
5500
+ const heldIndex = heldRow ? Array.from(this.querySelectorAll("[data-conv-id]")).indexOf(heldRow) : -1;
5501
+ const loading = this.hasAttribute("loading");
5502
+ this.setAttribute("aria-busy", loading ? "true" : "false");
5503
+ if (loading) {
5504
+ const row = '<span class="aparte-skeleton aparte-skeleton--text"></span>';
5505
+ 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>';
5506
+ const status = this.querySelector(".aparte-conv-list-loading-status");
5507
+ const say = () => {
5508
+ if (status?.isConnected) status.textContent = this._t("loadingConversations");
5509
+ };
5510
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(say);
5511
+ else say();
5512
+ return;
5513
+ }
5177
5514
  this.innerHTML = this._groups().map((g2) => this._renderGroup(g2)).join("");
5515
+ if (!restore) return;
5516
+ const back = this.querySelector(restore);
5517
+ if (back) {
5518
+ back.focus();
5519
+ return;
5520
+ }
5521
+ const rows = Array.from(this.querySelectorAll("[data-select-id]"));
5522
+ const next = heldIndex >= 0 ? rows[heldIndex] ?? rows[rows.length - 1] : null;
5523
+ if (next) {
5524
+ next.focus();
5525
+ return;
5526
+ }
5527
+ if (!this.hasAttribute("tabindex")) {
5528
+ this.setAttribute("tabindex", "-1");
5529
+ this.addEventListener("blur", () => this.removeAttribute("tabindex"), { once: true });
5530
+ }
5531
+ this.focus();
5178
5532
  }
5179
5533
  _t(key) {
5180
5534
  const locale = resolveConfig(this).getLocale();
@@ -5183,12 +5537,12 @@ class AparteConversationList extends HTMLElement {
5183
5537
  /**
5184
5538
  * Pinned first, then the calendar buckets every chat product uses, then a heading
5185
5539
  * per month (newest first), then whatever carries no date. Flat when nothing is
5186
- * dated or the host asked for `no-groups`: a heading over the only group would say
5540
+ * dated or the host asked for `flat`: a heading over the only group would say
5187
5541
  * nothing.
5188
5542
  */
5189
5543
  _groups() {
5190
5544
  const items = this._conversations;
5191
- if (this.hasAttribute("no-groups") || !items.some((c2) => typeof c2.updatedAt === "number")) {
5545
+ if (this.hasAttribute("flat") || !items.some((c2) => typeof c2.updatedAt === "number")) {
5192
5546
  return [{ key: "all", label: null, items }];
5193
5547
  }
5194
5548
  const now = /* @__PURE__ */ new Date();
@@ -5291,12 +5645,16 @@ class AparteConversationList extends HTMLElement {
5291
5645
  <div class="aparte-menu__separator" role="separator"></div>
5292
5646
  <button type="button" class="aparte-menu__item aparte-conv-menu__item--danger" role="menuitem" data-menu-action="delete">${trashGlyph}<span>${remove}</span></button>`;
5293
5647
  }
5648
+ /** The question the confirm step asks — the popover's accessible name while it asks it. */
5649
+ _confirmQuestion(conv) {
5650
+ return this._t("deleteConversationConfirm").replace("{title}", conv.title || this._t("newChat"));
5651
+ }
5294
5652
  _confirmMarkup(conv) {
5295
- const question = escapeHtml(this._t("deleteConversationConfirm").replace("{title}", conv.title || this._t("newChat")));
5653
+ const question = escapeHtml(this._confirmQuestion(conv));
5296
5654
  const cancel = escapeHtml(this._t("cancel"));
5297
5655
  const remove = escapeHtml(this._t("deleteConversation"));
5298
5656
  return `
5299
- <div class="aparte-conv-menu__confirm" role="group" aria-label="${escapeAttr(question)}">
5657
+ <div class="aparte-conv-menu__confirm">
5300
5658
  <p class="aparte-conv-menu__question">${question}</p>
5301
5659
  <div class="aparte-conv-menu__actions">
5302
5660
  <button type="button" class="aparte-btn aparte-btn--sm aparte-btn--ghost" data-menu-action="cancel">${cancel}</button>
@@ -5320,6 +5678,8 @@ class AparteConversationList extends HTMLElement {
5320
5678
  this._placeMenu(button, menu);
5321
5679
  menu.querySelector('[role="menuitem"]')?.focus();
5322
5680
  document.addEventListener("pointerdown", this._onDocumentPointerDown, true);
5681
+ document.addEventListener("pointerup", this._onDocumentPointerUp, true);
5682
+ document.addEventListener("pointercancel", this._onDocumentPointerUp, true);
5323
5683
  window.addEventListener("scroll", this._onWindowScroll, true);
5324
5684
  window.addEventListener("resize", this._onWindowScroll);
5325
5685
  }
@@ -5350,14 +5710,29 @@ class AparteConversationList extends HTMLElement {
5350
5710
  open.menu.remove();
5351
5711
  open.button.setAttribute("aria-expanded", "false");
5352
5712
  document.removeEventListener("pointerdown", this._onDocumentPointerDown, true);
5713
+ document.removeEventListener("pointerup", this._onDocumentPointerUp, true);
5714
+ document.removeEventListener("pointercancel", this._onDocumentPointerUp, true);
5715
+ this._pressInMenu = false;
5353
5716
  window.removeEventListener("scroll", this._onWindowScroll, true);
5354
5717
  window.removeEventListener("resize", this._onWindowScroll);
5355
5718
  if (returnFocus && open.button.isConnected) open.button.focus();
5356
5719
  }
5720
+ /**
5721
+ * A pointer is down inside the open menu. WebKit does not move the focus to a
5722
+ * button on mousedown: the element that held it is blurred instead — `focusout`
5723
+ * with no `relatedTarget` — before the click reaches the item. `_onFocusOut` used
5724
+ * to read that as the focus leaving the menu and close it on the press, so with a
5725
+ * mouse no item could be chosen at all. Held from the press to its release.
5726
+ */
5727
+ _pressInMenu = false;
5728
+ _onDocumentPointerUp = () => {
5729
+ this._pressInMenu = false;
5730
+ };
5357
5731
  _onDocumentPointerDown = (e) => {
5358
5732
  const open = this._open;
5359
5733
  if (!open) return;
5360
- const target = e.target;
5734
+ const target = eventOrigin(e);
5735
+ this._pressInMenu = !!target && open.menu.contains(target);
5361
5736
  if (open.menu.contains(target) || open.button.contains(target)) return;
5362
5737
  this._closeMenu();
5363
5738
  };
@@ -5368,9 +5743,37 @@ class AparteConversationList extends HTMLElement {
5368
5743
  const open = this._open;
5369
5744
  if (!open) return;
5370
5745
  const next = e.relatedTarget;
5746
+ if (!next && this._pressInMenu) return;
5371
5747
  if (next && (open.menu.contains(next) || open.button.contains(next))) return;
5372
5748
  if (open.menu.contains(e.target)) this._closeMenu();
5373
5749
  };
5750
+ /**
5751
+ * The gesture, finished. The event has already gone out — cancelable, so a host
5752
+ * that wants the write for itself calls `preventDefault()` and this stands down —
5753
+ * and what is left is the registered conversation manager doing it. Without
5754
+ * `manage` the element behaves exactly as it did before the attribute existed.
5755
+ *
5756
+ * A `manage` with no manager registered is a menu that does nothing, so the first
5757
+ * gesture says so and names the call that fixes it. Said here rather than at
5758
+ * `connectedCallback` because a list in the page's HTML connects before the script
5759
+ * that registers the manager runs: a warning at connect would fire on a setup that
5760
+ * is perfectly correct one tick later.
5761
+ */
5762
+ _manage(event, run) {
5763
+ if (!this.hasAttribute("manage") || event.defaultPrevented) return;
5764
+ const manager = resolveConfig(this).getConversationManager();
5765
+ if (!manager) {
5766
+ if (this._warnedNoManager) return;
5767
+ this._warnedNoManager = true;
5768
+ console.warn(
5769
+ "[aparte-conversation-list] `manage` is set but no conversation manager is registered, so the row menu writes nothing. Call setConversationManager(manager) on your config, or drop `manage` and handle the events yourself."
5770
+ );
5771
+ return;
5772
+ }
5773
+ void run(manager).catch((err) => {
5774
+ console.warn("[aparte-conversation-list] the conversation manager refused the change:", err);
5775
+ });
5776
+ }
5374
5777
  _onMenuAction(action) {
5375
5778
  const open = this._open;
5376
5779
  if (!open) return;
@@ -5392,46 +5795,64 @@ class AparteConversationList extends HTMLElement {
5392
5795
  case "pin":
5393
5796
  this._closeMenu(true);
5394
5797
  if (conv.pinnedAt) {
5395
- this.dispatchEvent(new CustomEvent(
5396
- "aparte-unpin-conversation",
5397
- { detail: { id }, bubbles: true, composed: true }
5398
- ));
5798
+ const unpin = new CustomEvent(
5799
+ "aparte-conversation-unpin",
5800
+ { detail: { id }, bubbles: true, composed: true, cancelable: true }
5801
+ );
5802
+ this.dispatchEvent(unpin);
5803
+ this._manage(unpin, (m2) => m2.unpin(id));
5399
5804
  } else {
5400
- this.dispatchEvent(new CustomEvent(
5401
- "aparte-pin-conversation",
5402
- { detail: { id }, bubbles: true, composed: true }
5403
- ));
5805
+ const pin = new CustomEvent(
5806
+ "aparte-conversation-pin",
5807
+ { detail: { id }, bubbles: true, composed: true, cancelable: true }
5808
+ );
5809
+ this.dispatchEvent(pin);
5810
+ this._manage(pin, (m2) => m2.pin(id));
5404
5811
  }
5405
5812
  return;
5406
5813
  case "archive":
5407
5814
  this._closeMenu(true);
5408
5815
  if (conv.archivedAt) {
5409
- this.dispatchEvent(new CustomEvent(
5410
- "aparte-unarchive-conversation",
5411
- { detail: { id }, bubbles: true, composed: true }
5412
- ));
5816
+ const unarchive = new CustomEvent(
5817
+ "aparte-conversation-unarchive",
5818
+ { detail: { id }, bubbles: true, composed: true, cancelable: true }
5819
+ );
5820
+ this.dispatchEvent(unarchive);
5821
+ this._manage(unarchive, (m2) => m2.unarchive(id));
5413
5822
  } else {
5414
- this.dispatchEvent(new CustomEvent(
5415
- "aparte-archive-conversation",
5416
- { detail: { id }, bubbles: true, composed: true }
5417
- ));
5823
+ const archive = new CustomEvent(
5824
+ "aparte-conversation-archive",
5825
+ { detail: { id }, bubbles: true, composed: true, cancelable: true }
5826
+ );
5827
+ this.dispatchEvent(archive);
5828
+ this._manage(archive, (m2) => m2.archive(id));
5418
5829
  }
5419
5830
  return;
5420
- case "delete":
5421
- open.menu.innerHTML = this._confirmMarkup(conv);
5422
- this._placeMenu(open.button, open.menu);
5831
+ case "delete": {
5832
+ const previous = [...open.menu.childNodes];
5833
+ const holder = document.createElement("div");
5834
+ holder.innerHTML = this._confirmMarkup(conv);
5835
+ open.menu.append(...holder.childNodes);
5836
+ open.menu.setAttribute("role", "dialog");
5837
+ open.menu.setAttribute("aria-label", this._confirmQuestion(conv));
5423
5838
  open.menu.querySelector('[data-menu-action="cancel"]')?.focus();
5839
+ for (const node of previous) node.remove();
5840
+ this._placeMenu(open.button, open.menu);
5424
5841
  return;
5842
+ }
5425
5843
  case "cancel":
5426
5844
  this._closeMenu(true);
5427
5845
  return;
5428
- case "confirm-delete":
5846
+ case "confirm-delete": {
5429
5847
  this._closeMenu(true);
5430
- this.dispatchEvent(new CustomEvent(
5431
- "aparte-delete-conversation",
5432
- { detail: { id }, bubbles: true, composed: true }
5433
- ));
5848
+ const remove = new CustomEvent(
5849
+ "aparte-conversation-delete",
5850
+ { detail: { id }, bubbles: true, composed: true, cancelable: true }
5851
+ );
5852
+ this.dispatchEvent(remove);
5853
+ this._manage(remove, (m2) => m2.delete(id));
5434
5854
  return;
5855
+ }
5435
5856
  }
5436
5857
  }
5437
5858
  _menuFocusables() {
@@ -5441,7 +5862,7 @@ class AparteConversationList extends HTMLElement {
5441
5862
  }
5442
5863
  _onMenuKeydown(e) {
5443
5864
  const items = this._menuFocusables();
5444
- const index = items.indexOf(document.activeElement);
5865
+ const index = items.indexOf(activeElementIn(this));
5445
5866
  const focusAt = (i2) => {
5446
5867
  e.preventDefault();
5447
5868
  items[(i2 + items.length) % items.length]?.focus();
@@ -5507,10 +5928,12 @@ class AparteConversationList extends HTMLElement {
5507
5928
  const title = renaming.input.value.trim();
5508
5929
  this._render();
5509
5930
  if (commit && conv && title && title !== conv.title) {
5510
- this.dispatchEvent(new CustomEvent(
5511
- "aparte-rename-conversation",
5512
- { detail: { id: conv.id, title }, bubbles: true, composed: true }
5513
- ));
5931
+ const rename = new CustomEvent(
5932
+ "aparte-conversation-rename",
5933
+ { detail: { id: conv.id, title }, bubbles: true, composed: true, cancelable: true }
5934
+ );
5935
+ this.dispatchEvent(rename);
5936
+ this._manage(rename, (m2) => m2.updateTitle(conv.id, title));
5514
5937
  }
5515
5938
  if (restoreFocus) {
5516
5939
  this.querySelector(`[data-select-id="${cssEscape(renaming.id)}"]`)?.focus();
@@ -5540,7 +5963,7 @@ class AparteConversationList extends HTMLElement {
5540
5963
  if (select) {
5541
5964
  this._closeMenu();
5542
5965
  this.dispatchEvent(new CustomEvent(
5543
- "aparte-select-conversation",
5966
+ "aparte-conversation-select",
5544
5967
  { detail: { id: select.dataset["selectId"] }, bubbles: true, composed: true }
5545
5968
  ));
5546
5969
  }
@@ -5574,16 +5997,52 @@ class AparteConversationList extends HTMLElement {
5574
5997
  }
5575
5998
  if (!customElements.get("aparte-conversation-list")) customElements.define("aparte-conversation-list", AparteConversationList);
5576
5999
  const isSuggestion = (value) => typeof value === "string" || typeof value === "object" && value !== null && typeof value.label === "string";
6000
+ const parseSuggestions = (raw) => {
6001
+ try {
6002
+ const parsed = JSON.parse(raw);
6003
+ return Array.isArray(parsed) ? parsed.filter(isSuggestion) : [];
6004
+ } catch {
6005
+ console.warn('[aparte-suggestions] `suggestions` is not a JSON array. Use ["…"] or [{"label": "…", "prompt": "…"}].');
6006
+ return [];
6007
+ }
6008
+ };
5577
6009
  const labelOf = (s2) => typeof s2 === "string" ? s2 : s2.label;
5578
6010
  const promptOf = (s2) => typeof s2 === "string" ? s2 : s2.prompt ?? s2.label;
5579
6011
  class AparteSuggestions extends HTMLElement {
5580
6012
  static get observedAttributes() {
5581
- return ["suggestions", "target"];
6013
+ return ["suggestions", "target", "empty-only"];
5582
6014
  }
5583
6015
  _suggestions = [];
5584
6016
  _composer = null;
6017
+ /** The viewport of the chat this row serves, while `empty-only` follows it. */
6018
+ _transcript = null;
6019
+ _transcriptObserver = null;
6020
+ /** The composer sent since the transcript was last seen empty. */
6021
+ _sent = false;
6022
+ _wasEmpty = true;
6023
+ /** `hidden` was written by this element, so it is this element's to lift. */
6024
+ _hidOwn = false;
6025
+ /*
6026
+ * `empty-only` used to read the send alone: hidden on the first one, and never shown
6027
+ * again. Over a restored thread (nothing sent yet) the starters sat on top of the
6028
+ * transcript, and after a new chat they never came back. The transcript is the
6029
+ * signal now; the send stays as the other half, because it comes first — the row
6030
+ * hides on the click rather than a frame later when the bubble lands — and because
6031
+ * it is all there is to read when the messages are not `<aparte-chat-bubble>`s
6032
+ * (a wrapper's `renderBubble`) or there is no transcript at all. A transcript seen
6033
+ * going from full to empty is a new conversation, which forgets the send.
6034
+ */
5585
6035
  _onSend = () => {
5586
- if (this.hasAttribute("empty-only")) this.hidden = true;
6036
+ if (!this.hasAttribute("empty-only")) return;
6037
+ if (!this._transcript) this._watchTranscript();
6038
+ this._sent = true;
6039
+ this._applyEmptyOnly();
6040
+ };
6041
+ _applyEmptyOnly = () => {
6042
+ const empty = this._transcript ? isTranscriptEmpty(this._transcript) : true;
6043
+ if (empty && !this._wasEmpty) this._sent = false;
6044
+ this._wasEmpty = empty;
6045
+ this._setHidden(!empty || this._sent);
5587
6046
  };
5588
6047
  _onConfigChange = () => {
5589
6048
  const row = this.querySelector(".aparte-suggestions");
@@ -5593,8 +6052,17 @@ class AparteSuggestions extends HTMLElement {
5593
6052
  get suggestions() {
5594
6053
  return this._suggestions;
5595
6054
  }
6055
+ /**
6056
+ * Takes the array, or the JSON string the attribute documents.
6057
+ *
6058
+ * Both, because both arrive: React 19 and Svelte assign the PROPERTY whenever the
6059
+ * element has one of that name, so `<aparte-suggestions suggestions='[…]'>` — the
6060
+ * documented spelling, typed valid by both wrappers — reached this setter as a
6061
+ * string, `Array.isArray` said no, and the row rendered nothing while the attribute
6062
+ * (and so `attributeChangedCallback`) was never written.
6063
+ */
5596
6064
  set suggestions(value) {
5597
- this._suggestions = Array.isArray(value) ? value.filter(isSuggestion) : [];
6065
+ this._suggestions = typeof value === "string" ? parseSuggestions(value) : Array.isArray(value) ? value.filter(isSuggestion) : [];
5598
6066
  if (this.isConnected) this._render();
5599
6067
  }
5600
6068
  connectedCallback() {
@@ -5606,6 +6074,9 @@ class AparteSuggestions extends HTMLElement {
5606
6074
  disconnectedCallback() {
5607
6075
  this._composer?.removeEventListener("aparte-send", this._onSend);
5608
6076
  this._composer = null;
6077
+ this._transcriptObserver?.disconnect();
6078
+ this._transcriptObserver = null;
6079
+ this._transcript = null;
5609
6080
  window.removeEventListener("aparte-config-change", this._onConfigChange);
5610
6081
  }
5611
6082
  attributeChangedCallback(name) {
@@ -5615,6 +6086,12 @@ class AparteSuggestions extends HTMLElement {
5615
6086
  this._render();
5616
6087
  } else if (name === "target") {
5617
6088
  this._watchComposer();
6089
+ } else if (name === "empty-only") {
6090
+ this._watchTranscript();
6091
+ if (!this.hasAttribute("empty-only")) {
6092
+ this._sent = false;
6093
+ this._setHidden(false);
6094
+ }
5618
6095
  }
5619
6096
  }
5620
6097
  _groupLabel() {
@@ -5630,13 +6107,7 @@ class AparteSuggestions extends HTMLElement {
5630
6107
  const raw = this.getAttribute("suggestions");
5631
6108
  if (raw === null || raw === this._parsed) return;
5632
6109
  this._parsed = raw;
5633
- try {
5634
- const parsed = JSON.parse(raw);
5635
- this._suggestions = Array.isArray(parsed) ? parsed.filter(isSuggestion) : [];
5636
- } catch {
5637
- console.warn('[aparte-suggestions] `suggestions` is not a JSON array. Use ["…"] or [{"label": "…", "prompt": "…"}].');
5638
- this._suggestions = [];
5639
- }
6110
+ this._suggestions = parseSuggestions(raw);
5640
6111
  }
5641
6112
  /**
5642
6113
  * The composer the click goes through, resolved the way the composer's own parts
@@ -5662,6 +6133,49 @@ class AparteSuggestions extends HTMLElement {
5662
6133
  this._composer?.removeEventListener("aparte-send", this._onSend);
5663
6134
  this._composer = this._resolveComposer();
5664
6135
  this._composer?.addEventListener("aparte-send", this._onSend);
6136
+ this._watchTranscript();
6137
+ }
6138
+ /**
6139
+ * The transcript of the chat the click goes to: the chat around the composer, else
6140
+ * the one around this element, else the one `target` names (here, or on that
6141
+ * composer). No last resort on the page, unlike the composer's: following another
6142
+ * chat's transcript would be worse than following none, which falls back to the send.
6143
+ */
6144
+ _resolveTranscript() {
6145
+ const id = this.getAttribute("target") ?? this._composer?.getAttribute("target") ?? null;
6146
+ const chat = chatBoundaryOf(this._composer) ?? chatBoundaryOf(this) ?? (id ? document.getElementById(id) : null);
6147
+ if (!chat) return null;
6148
+ return chat.matches("aparte-chat-viewport") ? chat : chat.querySelector("aparte-chat-viewport");
6149
+ }
6150
+ /** Follow the transcript while `empty-only` is set, and stop following it when it is not. */
6151
+ _watchTranscript() {
6152
+ const transcript = this.hasAttribute("empty-only") ? this._resolveTranscript() : null;
6153
+ if (transcript !== this._transcript) {
6154
+ this._transcriptObserver?.disconnect();
6155
+ this._transcriptObserver = null;
6156
+ this._transcript = transcript;
6157
+ if (transcript) {
6158
+ this._transcriptObserver = new MutationObserver(this._applyEmptyOnly);
6159
+ this._transcriptObserver.observe(transcript, {
6160
+ childList: true,
6161
+ subtree: true,
6162
+ attributes: true,
6163
+ attributeFilter: ["loading"]
6164
+ });
6165
+ }
6166
+ }
6167
+ if (this.hasAttribute("empty-only")) this._applyEmptyOnly();
6168
+ }
6169
+ /** Set `hidden`, or lift it only when this element set it: a `hidden` the page wrote stays. */
6170
+ _setHidden(hide) {
6171
+ if (hide) {
6172
+ if (this.hidden) return;
6173
+ this.hidden = true;
6174
+ this._hidOwn = true;
6175
+ } else if (this._hidOwn) {
6176
+ this.hidden = false;
6177
+ this._hidOwn = false;
6178
+ }
5665
6179
  }
5666
6180
  _render() {
5667
6181
  this.replaceChildren();
@@ -5778,6 +6292,17 @@ class AparteContext extends HTMLElement {
5778
6292
  get window() {
5779
6293
  return this._window();
5780
6294
  }
6295
+ /**
6296
+ * Writes the `window` attribute; `null` gives the model's own window back.
6297
+ *
6298
+ * The setter has to exist: React 19 and Svelte assign the PROPERTY whenever the
6299
+ * element has one of that name, so `<aparte-context window={8000}>` — a spelling
6300
+ * both wrappers type as valid — hit a getter alone and threw.
6301
+ */
6302
+ set window(value) {
6303
+ if (value === null || value === void 0) this.removeAttribute("window");
6304
+ else this.setAttribute("window", String(value));
6305
+ }
5781
6306
  /** The current level, or `null` while nothing is shown. */
5782
6307
  get level() {
5783
6308
  return this._level;
@@ -6038,7 +6563,8 @@ class AparteScrollRail extends HTMLElement {
6038
6563
  _rebuild() {
6039
6564
  const bubbles = this._bubbles();
6040
6565
  const locale = resolveConfig(this).getLocale();
6041
- this.setAttribute("aria-label", locale.scrollRailLabel ?? "Conversation outline");
6566
+ const railLabel = locale.scrollRailLabel ?? "Conversation outline";
6567
+ if (this.getAttribute("aria-label") !== railLabel) this.setAttribute("aria-label", railLabel);
6042
6568
  if (bubbles.length < 2) {
6043
6569
  this.setAttribute("data-empty", "");
6044
6570
  this.replaceChildren();
@@ -6081,8 +6607,11 @@ class AparteScrollRail extends HTMLElement {
6081
6607
  tick.setAttribute("aria-label", label);
6082
6608
  tick.title = label;
6083
6609
  }
6084
- if (id === this._currentId) tick.setAttribute("aria-current", "true");
6085
- else tick.removeAttribute("aria-current");
6610
+ if (id === this._currentId) {
6611
+ if (tick.getAttribute("aria-current") !== "true") tick.setAttribute("aria-current", "true");
6612
+ } else {
6613
+ tick.removeAttribute("aria-current");
6614
+ }
6086
6615
  const at = list.children[index] ?? null;
6087
6616
  if (at !== item) list.insertBefore(item, at);
6088
6617
  index++;
@@ -6264,7 +6793,7 @@ class AparteScrollRail extends HTMLElement {
6264
6793
  _onKeydown = (e) => {
6265
6794
  if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") return;
6266
6795
  const ticks = Array.from(this.querySelectorAll(".aparte-scroll-rail__tick"));
6267
- const index = ticks.indexOf(document.activeElement);
6796
+ const index = ticks.indexOf(activeElementIn(this));
6268
6797
  if (index === -1) return;
6269
6798
  e.preventDefault();
6270
6799
  const next = e.key === "Home" ? 0 : e.key === "End" ? ticks.length - 1 : e.key === "ArrowDown" ? Math.min(ticks.length - 1, index + 1) : Math.max(0, index - 1);
@@ -6521,7 +7050,10 @@ class AparteSidebar extends HTMLElement {
6521
7050
  * lies. The row's own `⋯` button already did this; the sidebar's toggle did not.
6522
7051
  */
6523
7052
  _syncToggles() {
6524
- for (const control of document.querySelectorAll("[data-aparte-sidebar-toggle]")) {
7053
+ const selector = "[data-aparte-sidebar-toggle]";
7054
+ const root = rootOf(this);
7055
+ const controls = root === document ? Array.from(document.querySelectorAll(selector)) : [...root.querySelectorAll(selector), ...document.querySelectorAll(selector)];
7056
+ for (const control of controls) {
6525
7057
  if (!this._drives(control)) continue;
6526
7058
  control.setAttribute("aria-expanded", String(!this.collapsed));
6527
7059
  control.setAttribute("aria-controls", this.id);
@@ -6594,7 +7126,7 @@ class AparteSidebar extends HTMLElement {
6594
7126
  if (focusable.length === 0) return;
6595
7127
  const first = focusable[0];
6596
7128
  const last = focusable[focusable.length - 1];
6597
- const active = document.activeElement;
7129
+ const active = activeElementIn(this);
6598
7130
  if (!this.contains(active)) {
6599
7131
  e.preventDefault();
6600
7132
  first.focus();
@@ -6609,7 +7141,7 @@ class AparteSidebar extends HTMLElement {
6609
7141
  };
6610
7142
  /** A `[data-aparte-sidebar-toggle]` anywhere toggles this sidebar — no script in the host. */
6611
7143
  _onDocumentClick = (e) => {
6612
- const control = e.target?.closest?.("[data-aparte-sidebar-toggle]");
7144
+ const control = eventOrigin(e)?.closest?.("[data-aparte-sidebar-toggle]");
6613
7145
  if (!control) return;
6614
7146
  if (!this._drives(control)) return;
6615
7147
  this.toggle(control);
@@ -6658,7 +7190,7 @@ function visibleFocusable(root) {
6658
7190
  return Array.from(root.querySelectorAll(FOCUSABLE)).filter((el2) => !el2.closest("[hidden]") && el2.checkVisibility?.() !== false);
6659
7191
  }
6660
7192
  function nearestSidebar(control) {
6661
- return control.closest("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
7193
+ return control.closest("aparte-sidebar") ?? rootOf(control).querySelector("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
6662
7194
  }
6663
7195
  function fold(text) {
6664
7196
  return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
@@ -7385,15 +7917,16 @@ class AparteSplit extends HTMLElement {
7385
7917
  * first on the page. Same shape as `[data-aparte-sidebar-toggle]`.
7386
7918
  */
7387
7919
  _onDocumentClick = (e) => {
7388
- const control = e.target?.closest?.("[data-aparte-split-pane]");
7920
+ const control = eventOrigin(e)?.closest?.("[data-aparte-split-pane]");
7389
7921
  if (!control) return;
7390
7922
  const value = (control.getAttribute("data-aparte-split-pane") ?? "").trim();
7391
7923
  const which = value === "start" || value === "end" ? value : null;
7392
7924
  const named = which ? "" : value;
7393
7925
  if (named) {
7394
7926
  if (named !== this.id) return;
7395
- } else if ((control.closest("aparte-split") ?? document.querySelector("aparte-split")) !== this) {
7396
- return;
7927
+ } else {
7928
+ const nearest = control.closest("aparte-split") ?? rootOf(control).querySelector("aparte-split") ?? document.querySelector("aparte-split");
7929
+ if (nearest !== this) return;
7397
7930
  }
7398
7931
  this.showPane(which ?? (this.pane === "start" ? "end" : "start"));
7399
7932
  };
@@ -7525,10 +8058,10 @@ export {
7525
8058
  installDialogTriggersOnce,
7526
8059
  isAwaitingReply,
7527
8060
  R as isFormatAdapter,
7528
- S as isSafeUrl,
8061
+ isSafeUrl,
7529
8062
  T as isSegmentSettled,
7530
8063
  U as parseAparteEventStream,
7531
- V as parseMarkdownToSegments,
8064
+ parseMarkdownToSegments,
7532
8065
  populateBubbleFromMessage,
7533
8066
  X as readableToAsyncIterable,
7534
8067
  registerAllComponents,