@aparte/core 0.16.11 → 0.17.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 (81) hide show
  1. package/README.md +6 -0
  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 +33 -0
  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 +10 -1
  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 +82 -13
  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 +10818 -10062
  41. package/dist/{dialog-triggers-cYGTlfZf.js → dialog-triggers-Bb-3EkT4.js} +591 -161
  42. package/dist/dialog-triggers-Bb-3EkT4.js.map +1 -0
  43. package/dist/generated/element-attributes.d.ts +10 -4
  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 +550 -98
  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/runtime/message-repository.d.ts +16 -3
  63. package/dist/runtime/message-repository.d.ts.map +1 -1
  64. package/dist/types/chat.d.ts +41 -46
  65. package/dist/types/chat.d.ts.map +1 -1
  66. package/dist/types/event-map.d.ts +7 -7
  67. package/dist/types/event-map.d.ts.map +1 -1
  68. package/dist/types/events.d.ts +7 -6
  69. package/dist/types/events.d.ts.map +1 -1
  70. package/dist/types/imperative-api.d.ts +16 -0
  71. package/dist/types/imperative-api.d.ts.map +1 -1
  72. package/dist/types/index.d.ts +2 -2
  73. package/dist/types/index.d.ts.map +1 -1
  74. package/dist/types/models.d.ts +9 -0
  75. package/dist/types/models.d.ts.map +1 -1
  76. package/dist/types/segments.d.ts +56 -13
  77. package/dist/types/segments.d.ts.map +1 -1
  78. package/dist/utils/event-target.d.ts +31 -0
  79. package/dist/utils/event-target.d.ts.map +1 -0
  80. package/package.json +2 -2
  81. 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 rootOf, P as installDialogTriggersOnce } from "./dialog-triggers-Bb-3EkT4.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-Bb-3EkT4.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();
@@ -956,6 +976,13 @@ class AparteChat extends HTMLElement {
956
976
  return ["placeholder", "disabled", "submit-on-enter", "center-empty", "attachments"];
957
977
  }
958
978
  _observer = null;
979
+ _loadingObserver = null;
980
+ /** Waits for a `<aparte-chat-viewport>` child to appear — see `_watchForViewport`. */
981
+ _childObserver = null;
982
+ /** The viewport is loading and the gate is on — see `_applyLoadingGate`. */
983
+ _gated = false;
984
+ /** The composer's `disabled` is the gate's to remove, not one the consumer set. */
985
+ _gateOwns = false;
959
986
  /**
960
987
  * True only for the composition THIS element injected. An author-provided
961
988
  * composer (or a `framework-managed` host) is never edited by the attachments
@@ -968,10 +995,16 @@ class AparteChat extends HTMLElement {
968
995
  this._forwardAttr("disabled");
969
996
  this._forwardAttr("submit-on-enter");
970
997
  this._syncEmptyWatch();
998
+ this._syncLoadingWatch();
999
+ this._watchForViewport();
971
1000
  }
972
1001
  disconnectedCallback() {
973
1002
  this._observer?.disconnect();
974
1003
  this._observer = null;
1004
+ this._loadingObserver?.disconnect();
1005
+ this._loadingObserver = null;
1006
+ this._childObserver?.disconnect();
1007
+ this._childObserver = null;
975
1008
  }
976
1009
  attributeChangedCallback(name, oldValue, newValue) {
977
1010
  if (oldValue === newValue) return;
@@ -983,6 +1016,10 @@ class AparteChat extends HTMLElement {
983
1016
  this._syncAttachments();
984
1017
  return;
985
1018
  }
1019
+ if (name === "disabled" && newValue === null && this._gated) {
1020
+ this._gateOwns = true;
1021
+ return;
1022
+ }
986
1023
  const composer = this.querySelector("aparte-composer");
987
1024
  if (!composer) return;
988
1025
  if (newValue !== null) composer.setAttribute(name, newValue);
@@ -1057,11 +1094,70 @@ class AparteChat extends HTMLElement {
1057
1094
  if (!viewport) return;
1058
1095
  this._updateEmpty();
1059
1096
  this._observer = new MutationObserver(() => this._updateEmpty());
1060
- this._observer.observe(viewport, { childList: true, subtree: true });
1097
+ this._observer.observe(viewport, { childList: true, subtree: true, attributes: true, attributeFilter: ["loading"] });
1098
+ }
1099
+ /**
1100
+ * A transcript on its way cannot take a message: while the viewport says `loading`
1101
+ * the composer is disabled, whatever else the chat is set to. A send in that window
1102
+ * raced the fetch — the controller appended to a history that `setMessages`
1103
+ * overwrote a moment later. Independent of `center-empty`: every chat gates.
1104
+ */
1105
+ _syncLoadingWatch() {
1106
+ this._loadingObserver?.disconnect();
1107
+ this._loadingObserver = null;
1108
+ const viewport = this.querySelector("aparte-chat-viewport");
1109
+ if (!viewport) return;
1110
+ this._applyLoadingGate();
1111
+ this._loadingObserver = new MutationObserver(() => this._applyLoadingGate());
1112
+ this._loadingObserver.observe(viewport, { attributes: true, attributeFilter: ["loading"] });
1113
+ }
1114
+ /**
1115
+ * Both watches above need a viewport to observe, and they were installed once, at
1116
+ * connect, when there might not be one yet: `<aparte-chat framework-managed>` IS
1117
+ * Angular's host element — upgraded on insert, filled by the template afterwards — and
1118
+ * hand-written markup upgrades before its children are parsed. Neither ever got its
1119
+ * observer, so the documented "every chat gates" was inert on exactly the chats whose
1120
+ * transcript is fetched. This waits for the child to arrive, installs them, and stops.
1121
+ */
1122
+ _watchForViewport() {
1123
+ this._childObserver?.disconnect();
1124
+ this._childObserver = null;
1125
+ if (this.querySelector("aparte-chat-viewport")) return;
1126
+ this._childObserver = new MutationObserver(() => {
1127
+ if (!this.querySelector("aparte-chat-viewport")) return;
1128
+ this._childObserver?.disconnect();
1129
+ this._childObserver = null;
1130
+ this._syncEmptyWatch();
1131
+ this._syncLoadingWatch();
1132
+ });
1133
+ this._childObserver.observe(this, { childList: true, subtree: true });
1134
+ }
1135
+ /**
1136
+ * The gate only ever ADDS `disabled` and only ever removes what it added: a `disabled`
1137
+ * the consumer set — on this element, or on a composer of their own — stays.
1138
+ */
1139
+ _applyLoadingGate() {
1140
+ const composer = this.querySelector("aparte-composer");
1141
+ const loading = this.querySelector("aparte-chat-viewport")?.hasAttribute("loading") ?? false;
1142
+ if (!composer) return;
1143
+ if (loading && !this._gated) {
1144
+ this._gated = true;
1145
+ this._gateOwns = !composer.hasAttribute("disabled");
1146
+ composer.setAttribute("disabled", "");
1147
+ } else if (!loading && this._gated) {
1148
+ this._gated = false;
1149
+ if (this._gateOwns && !this.hasAttribute("disabled")) composer.removeAttribute("disabled");
1150
+ this._gateOwns = false;
1151
+ }
1061
1152
  }
1153
+ /**
1154
+ * Empty means no message AND none on its way. A viewport that says `loading` holds
1155
+ * no bubble yet, and it used to count as empty: the welcome and the centred composer
1156
+ * showed while a conversation was being fetched.
1157
+ */
1062
1158
  _updateEmpty() {
1063
1159
  const viewport = this.querySelector("aparte-chat-viewport");
1064
- const empty = !viewport || !viewport.querySelector("aparte-chat-bubble");
1160
+ const empty = !viewport || !viewport.hasAttribute("loading") && !viewport.querySelector("aparte-chat-bubble");
1065
1161
  this.toggleAttribute("data-empty", empty);
1066
1162
  }
1067
1163
  }
@@ -1507,6 +1603,11 @@ class AparteChatBubble extends HTMLElement {
1507
1603
  </div>
1508
1604
  `;
1509
1605
  }
1606
+ const messageEl = this.querySelector(".aparte-message");
1607
+ if (messageEl && !messageEl.hasAttribute("role")) {
1608
+ messageEl.setAttribute("role", "article");
1609
+ messageEl.setAttribute("aria-label", this._getAriaLabel());
1610
+ }
1510
1611
  this._contentEl = this.querySelector(".aparte-content");
1511
1612
  this._segmentsEl = this.querySelector(".aparte-segments");
1512
1613
  this._attachmentsEl = this.querySelector(".aparte-attachments");
@@ -1578,7 +1679,9 @@ class AparteChatBubble extends HTMLElement {
1578
1679
  const nameEl = this.querySelector(".aparte-name");
1579
1680
  if (message) {
1580
1681
  message.setAttribute("data-role", this._role);
1581
- message.setAttribute("aria-label", this._getAriaLabel());
1682
+ if (message.getAttribute("role") === "article") {
1683
+ message.setAttribute("aria-label", this._getAriaLabel());
1684
+ }
1582
1685
  }
1583
1686
  if (avatar) {
1584
1687
  avatar.setAttribute("data-role", this._role);
@@ -1714,7 +1817,7 @@ class AparteChatBubble extends HTMLElement {
1714
1817
  }
1715
1818
  this._attachmentsEl.innerHTML = this._attachments.map((a4) => {
1716
1819
  const name = escapeHtml(a4.name);
1717
- if (a4.type.startsWith("image/")) {
1820
+ if (a4.type.startsWith("image/") && this._isShowableUrl(a4.url)) {
1718
1821
  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
1822
  }
1720
1823
  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 +1845,24 @@ class AparteChatBubble extends HTMLElement {
1742
1845
  });
1743
1846
  }
1744
1847
  /** Uppercased file extension (≤4 chars), or 'FILE' when there is none. */
1848
+ /**
1849
+ * The same answer `copyAttributes` gives before it writes an `src`, plus `blob:`.
1850
+ *
1851
+ * An attachment's `url` is documented as "URL or data URI" and a storage adapter
1852
+ * re-mints it when a conversation is restored, so it is the app's value, not the
1853
+ * composer's — it was escaped and written, where every other URL core emits is
1854
+ * asked whether its SCHEME is allowed at all. A picture whose URL is refused is
1855
+ * not half-rendered: it falls back to the file chip, which still names the file.
1856
+ *
1857
+ * `blob:` is added because it is the shape core itself mints — `filesToAttachments`
1858
+ * calls `URL.createObjectURL`, and `AparteMessage.attachments` documents the same
1859
+ * on hydration. `isSafeUrl` refuses it, and rightly: that list governs MODEL
1860
+ * markup, which has no way to make a blob URL resolve.
1861
+ */
1862
+ _isShowableUrl(url) {
1863
+ if (!url) return false;
1864
+ return isSafeUrl(url, "img") || /^blob:/i.test(url.trim());
1865
+ }
1745
1866
  _fileExt(filename) {
1746
1867
  const dot = filename.lastIndexOf(".");
1747
1868
  return dot > 0 ? filename.slice(dot + 1).toUpperCase().slice(0, 4) : "FILE";
@@ -2255,7 +2376,7 @@ class AparteChatBubble extends HTMLElement {
2255
2376
  }
2256
2377
  }
2257
2378
  this._updateWaiting();
2258
- if (wasStreaming && !streaming) this._highlightContentCode();
2379
+ if (wasStreaming && !streaming) this._updateContent();
2259
2380
  }
2260
2381
  }
2261
2382
  if (!customElements.get("aparte-chat-bubble")) {
@@ -2421,6 +2542,20 @@ class AparteChatStatus extends HTMLElement {
2421
2542
  if (!customElements.get("aparte-chat-status")) {
2422
2543
  customElements.define("aparte-chat-status", AparteChatStatus);
2423
2544
  }
2545
+ function withParsedSegments(message, blocks) {
2546
+ if (message.role !== "assistant" || message.segments?.length) return message;
2547
+ if (typeof message.content !== "string" || !message.content.trim()) return message;
2548
+ const segments = parseMarkdownToSegments(message.content, { blocks });
2549
+ const onlyProse = segments.length <= 1 && segments.every((s2) => s2.type === "text");
2550
+ return onlyProse ? message : markDerivedSegments({ ...message, segments });
2551
+ }
2552
+ function markDerivedSegments(message) {
2553
+ Object.defineProperty(message, APARTE_DERIVED_SEGMENTS, { value: true, enumerable: false });
2554
+ return message;
2555
+ }
2556
+ function hasDerivedSegments(message) {
2557
+ return message[APARTE_DERIVED_SEGMENTS] === true;
2558
+ }
2424
2559
  class AparteChatViewport extends HTMLElement {
2425
2560
  // The scroll surface: an internal `.aparte-viewport-container` div (core mode)
2426
2561
  // or the host element itself (framework-managed mode). HTMLElement covers both.
@@ -2443,6 +2578,11 @@ class AparteChatViewport extends HTMLElement {
2443
2578
  _lastScrollTop = 0;
2444
2579
  /** The scroll height at the last scroll event: how much it moved since bounds what churn can do. */
2445
2580
  _lastScrollHeight = 0;
2581
+ /** When the transcript last mutated under our observer — an engine's settle adjustment follows one. */
2582
+ _lastMutationAt = Number.NEGATIVE_INFINITY;
2583
+ /** How long after a mutation an engine's settle adjustment can arrive, and how far it moves. */
2584
+ _settleShadowMs = 100;
2585
+ _settleBoundPx = 100;
2446
2586
  /** When this component last moved `scrollTop` itself — see `_handleScroll`. "Never" is
2447
2587
  * -Infinity rather than 0: `performance.now()` can be under a second old. */
2448
2588
  _ownScrollAt = Number.NEGATIVE_INFINITY;
@@ -2504,7 +2644,53 @@ class AparteChatViewport extends HTMLElement {
2504
2644
  */
2505
2645
  _frameworkManagedDOM = false;
2506
2646
  static get observedAttributes() {
2507
- return ["scroll-threshold", "max-rendered-bubbles"];
2647
+ return ["scroll-threshold", "max-rendered-bubbles", "loading"];
2648
+ }
2649
+ /**
2650
+ * The transcript is on its way: a conversation was chosen and its messages are
2651
+ * being fetched. Two skeleton turns stand where they will be, the scroll surface
2652
+ * is `aria-busy`, and a visually hidden line names the wait. Reflected as the
2653
+ * `loading` attribute — `AparteConversationController` sets it through its
2654
+ * binding; a loop of your own sets it by hand. Not the same thing as `data-busy`,
2655
+ * which is a reply streaming into a transcript that is already there.
2656
+ */
2657
+ get loading() {
2658
+ return this.hasAttribute("loading");
2659
+ }
2660
+ set loading(value) {
2661
+ this.toggleAttribute("loading", presenceOn(value));
2662
+ }
2663
+ setLoading(loading) {
2664
+ this.loading = loading;
2665
+ }
2666
+ /** Draw or remove the wait, from the attribute. Safe before the structure exists. */
2667
+ _syncLoading() {
2668
+ const container = this._container;
2669
+ if (!container) return;
2670
+ const on = this.hasAttribute("loading");
2671
+ container.setAttribute("aria-busy", on ? "true" : "false");
2672
+ if (this._frameworkManagedDOM) return;
2673
+ const existing = container.querySelector(":scope > .aparte-viewport-loading");
2674
+ if (!on) {
2675
+ existing?.remove();
2676
+ container.querySelector(":scope > .aparte-viewport-loading-status")?.remove();
2677
+ return;
2678
+ }
2679
+ if (existing) return;
2680
+ const cfg = resolveConfig(this);
2681
+ const skeleton = document.createElement("div");
2682
+ skeleton.className = "aparte-viewport-loading";
2683
+ skeleton.setAttribute("aria-hidden", "true");
2684
+ 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>';
2685
+ const status = document.createElement("span");
2686
+ status.className = "aparte-viewport-loading-status aparte-sr-only";
2687
+ status.setAttribute("role", "status");
2688
+ container.prepend(skeleton, status);
2689
+ const say = () => {
2690
+ if (status.isConnected) status.textContent = cfg.t("loadingConversation");
2691
+ };
2692
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(say);
2693
+ else say();
2508
2694
  }
2509
2695
  constructor() {
2510
2696
  super();
@@ -2589,6 +2775,9 @@ class AparteChatViewport extends HTMLElement {
2589
2775
  this._maxRenderedBubbles = parseInt(newValue || "1000", 10);
2590
2776
  this._pruneRenderedBubbles();
2591
2777
  break;
2778
+ case "loading":
2779
+ this._syncLoading();
2780
+ break;
2592
2781
  }
2593
2782
  }
2594
2783
  /**
@@ -2670,12 +2859,32 @@ class AparteChatViewport extends HTMLElement {
2670
2859
  if (streaming !== null && streaming !== head) return null;
2671
2860
  return head;
2672
2861
  }
2673
- /** The id of the message currently streaming, if any. */
2862
+ /**
2863
+ * The id of the message currently streaming, if any — searched across the whole
2864
+ * TREE, and answering with an OFF-HEAD one first when there are two.
2865
+ *
2866
+ * It used to scan `getMessages()`, the active path. A retry or an edit on an
2867
+ * earlier bubble calls `addSiblingOf`, which switches the branch to the new pending
2868
+ * reply: the turn still being written leaves the path, the scan found nothing, and
2869
+ * the rest of that turn was appended to the message that had just replaced it.
2870
+ *
2871
+ * A new pending reply is itself marked streaming, so "the first one found" would
2872
+ * answer the head and let the write through — the very case this exists to refuse.
2873
+ * Answering the off-head one instead makes `_activeMessageId` refuse while ANY turn
2874
+ * elsewhere in the tree is still open. That also refuses the new turn's own first
2875
+ * deltas for as long as the superseded one has not settled, which is the trade this
2876
+ * whole path already makes: losing the tail is visible, writing it onto someone
2877
+ * else's message is not.
2878
+ */
2674
2879
  _streamingMessageId() {
2675
- for (const message of this._repo.getMessages()) {
2676
- if (message.isStreaming) return message.id;
2880
+ const head = this._repo.headId;
2881
+ let atHead = null;
2882
+ for (const message of this._repo.getAllMessages()) {
2883
+ if (!message.isStreaming) continue;
2884
+ if (message.id !== head) return message.id;
2885
+ atHead = message.id;
2677
2886
  }
2678
- return null;
2887
+ return atHead;
2679
2888
  }
2680
2889
  /**
2681
2890
  * While a reply streams, the transcript is read-only except for Stop.
@@ -2817,6 +3026,12 @@ class AparteChatViewport extends HTMLElement {
2817
3026
  /**
2818
3027
  * Atomic update for a message by ID
2819
3028
  * Supports updating content, status, segments, and other metadata
3029
+ *
3030
+ * An id the tree does not hold is a silent no-op, deliberately: the message may have
3031
+ * been truncated away by an edit while a turn was still writing to it, and inventing
3032
+ * it back would put a message the reader deleted on screen. It does mean a late error
3033
+ * card can have nowhere to land — `aparte-message-error` still fires, so a host that
3034
+ * wants to show one has the event.
2820
3035
  */
2821
3036
  updateMessage(messageId, updates) {
2822
3037
  const message = this._repo.getMessageById(messageId);
@@ -2855,6 +3070,7 @@ class AparteChatViewport extends HTMLElement {
2855
3070
  * the framework owns the DOM and will create the bubble element itself.
2856
3071
  */
2857
3072
  appendMessage(message, options) {
3073
+ message = withParsedSegments(message, resolveConfig(this).getStreamBlocks());
2858
3074
  const stored = options?.historical ? adoptMessageSegments(message) : message.segments?.length ? { ...message, segments: message.segments.reduce(
2859
3075
  (acc, segment) => {
2860
3076
  acc.push(stampSegmentOnInsert(
@@ -2868,6 +3084,7 @@ class AparteChatViewport extends HTMLElement {
2868
3084
  []
2869
3085
  ) } : { ...message };
2870
3086
  if (!options?.historical && isAwaitingReply(message)) stored.isStreaming = true;
3087
+ if (hasDerivedSegments(message)) markDerivedSegments(stored);
2871
3088
  this._repo.addOrUpdateMessage(this._repo.headId, stored);
2872
3089
  if (!this._frameworkManagedDOM) {
2873
3090
  const wrapper = this.querySelector(".aparte-messages-wrapper");
@@ -3050,12 +3267,15 @@ class AparteChatViewport extends HTMLElement {
3050
3267
  */
3051
3268
  importTree(tree) {
3052
3269
  this.clearAll({ revokeAttachments: false });
3270
+ const blocks = resolveConfig(this).getStreamBlocks();
3053
3271
  this._repo.import({
3054
3272
  ...tree,
3055
- messages: tree.messages.map((entry) => ({
3056
- ...entry,
3057
- message: adoptMessageSegments(entry.message)
3058
- }))
3273
+ messages: tree.messages.map((entry) => {
3274
+ const parsed = withParsedSegments(entry.message, blocks);
3275
+ const message = adoptMessageSegments(parsed);
3276
+ if (hasDerivedSegments(parsed)) markDerivedSegments(message);
3277
+ return { ...entry, message };
3278
+ })
3059
3279
  });
3060
3280
  this._reRenderActivePath();
3061
3281
  }
@@ -3070,7 +3290,7 @@ class AparteChatViewport extends HTMLElement {
3070
3290
  */
3071
3291
  clearAll(options) {
3072
3292
  if (options?.revokeAttachments !== false) {
3073
- for (const message of this._repo.getMessages()) {
3293
+ for (const message of this._repo.getAllMessages()) {
3074
3294
  revokeAttachmentUrls(message.attachments);
3075
3295
  }
3076
3296
  }
@@ -3292,6 +3512,7 @@ class AparteChatViewport extends HTMLElement {
3292
3512
  container.appendChild(wrapper);
3293
3513
  this.appendChild(container);
3294
3514
  this._container = container;
3515
+ this._syncLoading();
3295
3516
  this._scrollBtn = document.createElement("button");
3296
3517
  this._scrollBtn.className = "aparte-btn aparte-btn--surface aparte-btn--circle aparte-btn--lg aparte-scroll-btn aparte-scroll-btn--hidden";
3297
3518
  this._scrollBtn.setAttribute("type", "button");
@@ -3382,6 +3603,19 @@ class AparteChatViewport extends HTMLElement {
3382
3603
  * 1 run in 6: the rebuild's height churn moved scrollTop within the second after
3383
3604
  * the click, and the click made that look like the reader's).
3384
3605
  */
3606
+ /**
3607
+ * Is a pointer pressed on the surface right now? A press on the TEXT is not a scroll
3608
+ * gesture (`_noteReaderInput` counts only the gutter), but a hand that is down and
3609
+ * dragging a selection past the edge scrolls the container all the same — and that
3610
+ * decrease is the reader's. Held from the press to its release, wherever it lands.
3611
+ */
3612
+ _pointerHeld = false;
3613
+ _holdPointer = () => {
3614
+ this._pointerHeld = true;
3615
+ };
3616
+ _releasePointer = () => {
3617
+ this._pointerHeld = false;
3618
+ };
3385
3619
  _noteReaderInput = (e) => {
3386
3620
  if (e.type === "keydown" && !this._scrollKeys.has(e.key)) return;
3387
3621
  if (e.type === "pointerdown") {
@@ -3414,6 +3648,9 @@ class AparteChatViewport extends HTMLElement {
3414
3648
  for (const type of ["wheel", "touchmove", "pointerdown", "keydown"]) {
3415
3649
  this._container?.addEventListener(type, this._noteReaderInput, { passive: true });
3416
3650
  }
3651
+ this._container?.addEventListener("pointerdown", this._holdPointer, { passive: true });
3652
+ document.addEventListener("pointerup", this._releasePointer, true);
3653
+ document.addEventListener("pointercancel", this._releasePointer, true);
3417
3654
  this._scrollBtn?.addEventListener("click", this._onScrollBtnClick);
3418
3655
  this.addEventListener("aparte-branch-navigate", this._onBranchNavigate);
3419
3656
  }
@@ -3446,6 +3683,7 @@ class AparteChatViewport extends HTMLElement {
3446
3683
  this._updateOverlayInset();
3447
3684
  }
3448
3685
  this._mutationObserver = new MutationObserver((mutations) => {
3686
+ this._lastMutationAt = performance.now();
3449
3687
  if (this._frameworkManagedDOM) this._keepScrollButtonLast();
3450
3688
  if (this._isAutoScrollEnabled && !this._gliding()) {
3451
3689
  const added = mutations.flatMap((m2) => Array.from(m2.addedNodes)).filter((n2) => n2 instanceof Element && n2.tagName === "APARTE-CHAT-BUBBLE");
@@ -3547,7 +3785,8 @@ class AparteChatViewport extends HTMLElement {
3547
3785
  const drop = this._lastScrollTop - top;
3548
3786
  const churn = Math.abs(height - this._lastScrollHeight);
3549
3787
  const now = performance.now();
3550
- const settlingOurs = drop <= churn + 2 && now - this._ownScrollAt < this._ownScrollWindowMs && now - this._readerInputAt >= this._ownScrollWindowMs;
3788
+ const afterMutation = now - this._lastMutationAt < this._settleShadowMs && drop <= this._settleBoundPx;
3789
+ const settlingOurs = now - this._ownScrollAt < this._ownScrollWindowMs && now - this._readerInputAt >= this._ownScrollWindowMs && !this._pointerHeld && (drop <= churn + 2 || afterMutation);
3551
3790
  const readerWentUp = drop > 1 && !settlingOurs;
3552
3791
  const readerHandOnIt = drop > 1 && now - this._readerInputAt < this._ownScrollWindowMs;
3553
3792
  this._lastScrollTop = top;
@@ -3788,6 +4027,10 @@ class AparteChatViewport extends HTMLElement {
3788
4027
  for (const type of ["wheel", "touchmove", "pointerdown", "keydown"]) {
3789
4028
  this._container?.removeEventListener(type, this._noteReaderInput);
3790
4029
  }
4030
+ this._container?.removeEventListener("pointerdown", this._holdPointer);
4031
+ document.removeEventListener("pointerup", this._releasePointer, true);
4032
+ document.removeEventListener("pointercancel", this._releasePointer, true);
4033
+ this._pointerHeld = false;
3791
4034
  this._scrollBtn?.removeEventListener("click", this._onScrollBtnClick);
3792
4035
  this.removeEventListener("aparte-branch-navigate", this._onBranchNavigate);
3793
4036
  this._resizeObserver?.disconnect();
@@ -3934,6 +4177,16 @@ class AparteComposer extends HTMLElement {
3934
4177
  get disabled() {
3935
4178
  return this.hasAttribute("disabled");
3936
4179
  }
4180
+ /**
4181
+ * The attribute is the state, so the property writes it. It has to exist: React 19
4182
+ * and Svelte assign the PROPERTY whenever the element has one of that name, and a
4183
+ * getter alone made `<aparte-composer disabled="">` — a spelling the wrappers type
4184
+ * as valid — throw and take the render down. `presenceOn` because `''` is what
4185
+ * those templates hand a presence property (#62).
4186
+ */
4187
+ set disabled(value) {
4188
+ this.toggleAttribute("disabled", presenceOn(value));
4189
+ }
3937
4190
  /**
3938
4191
  * When false, Shift+Enter submits and a bare Enter inserts a newline —
3939
4192
  * the inverse of the default. Driven by the `submit-on-enter` attribute.
@@ -3947,6 +4200,11 @@ class AparteComposer extends HTMLElement {
3947
4200
  get placeholder() {
3948
4201
  return this.getAttribute("placeholder") ?? "";
3949
4202
  }
4203
+ /** Writes the attribute the inputs read (see `disabled` for why the setter exists). */
4204
+ set placeholder(value) {
4205
+ if (value === null || value === void 0) this.removeAttribute("placeholder");
4206
+ else this.setAttribute("placeholder", String(value));
4207
+ }
3950
4208
  get targetId() {
3951
4209
  return this.getAttribute("target");
3952
4210
  }
@@ -4287,13 +4545,35 @@ class AparteComposer extends HTMLElement {
4287
4545
  }
4288
4546
  return void 0;
4289
4547
  }
4548
+ /**
4549
+ * The message this composer's turn is writing, when the event that started it said.
4550
+ * Kept so the END of a DIFFERENT turn cannot be read as the end of this one — see
4551
+ * `_isForThisTurn`.
4552
+ */
4553
+ _streamingMessageId = null;
4554
+ /**
4555
+ * Whether a terminal event ends the turn this composer is following.
4556
+ *
4557
+ * `_isForThisComposer` answers "is this chat's?", and both turns of a retry belong
4558
+ * to the same chat: the client aborts the superseded reply, the engine emits
4559
+ * `run-aborted` for it, and that unwind turned Stop back into Send while the new
4560
+ * reply was still streaming — and evicted the open panel, i.e. the approval question
4561
+ * a tool was still waiting on. An event with no `messageId` still ends the turn: a
4562
+ * loop of one's own saying "done" has always meant this one.
4563
+ */
4564
+ _isForThisTurn(e) {
4565
+ const id = e.detail?.messageId;
4566
+ return !id || this._streamingMessageId === null || id === this._streamingMessageId;
4567
+ }
4290
4568
  _handleMessageStart(e) {
4291
4569
  if (!this._isForThisComposer(e)) return;
4570
+ this._streamingMessageId = e.detail?.messageId ?? null;
4292
4571
  this._streaming = true;
4293
4572
  this._emit("streaming-change", { streaming: true });
4294
4573
  }
4295
4574
  _handleMessageDone(e) {
4296
- if (!this._isForThisComposer(e)) return;
4575
+ if (!this._isForThisComposer(e) || !this._isForThisTurn(e)) return;
4576
+ this._streamingMessageId = null;
4297
4577
  this._streaming = false;
4298
4578
  this._emit("streaming-change", { streaming: false });
4299
4579
  if (this._panelActive) this._evictPanel();
@@ -5080,8 +5360,10 @@ class AparteComposerToolbar extends HTMLElement {
5080
5360
  _observer = null;
5081
5361
  connectedCallback() {
5082
5362
  this._syncEmpty();
5083
- this._observer ??= new MutationObserver(() => this._syncEmpty());
5084
- this._observer.observe(this, { childList: true });
5363
+ this._observer ??= new MutationObserver((records) => {
5364
+ if (records.some((r) => !(r.type === "attributes" && r.target === this))) this._syncEmpty();
5365
+ });
5366
+ this._observer.observe(this, { childList: true, subtree: true, attributes: true, attributeFilter: ["hidden", "data-empty"] });
5085
5367
  }
5086
5368
  disconnectedCallback() {
5087
5369
  this._observer?.disconnect();
@@ -5101,7 +5383,10 @@ class AparteComposerToolbar extends HTMLElement {
5101
5383
  * not empty, and hiding it would be a twenty-minute mystery for whoever wrote it.
5102
5384
  */
5103
5385
  _syncEmpty() {
5104
- const hasContent = Boolean(this.firstElementChild) || this.textContent?.trim() !== "";
5386
+ const visibleChild = Array.from(this.children).some((c2) => !c2.hasAttribute("hidden") && !c2.hasAttribute("data-empty"));
5387
+ const ownText = Array.from(this.childNodes).some((n2) => n2.nodeType === Node.TEXT_NODE && (n2.textContent ?? "").trim() !== "");
5388
+ const hasContent = visibleChild || ownText;
5389
+ if (hasContent === !this.hasAttribute("data-empty")) return;
5105
5390
  if (hasContent) this.removeAttribute("data-empty");
5106
5391
  else this.setAttribute("data-empty", "");
5107
5392
  }
@@ -5109,6 +5394,9 @@ class AparteComposerToolbar extends HTMLElement {
5109
5394
  if (!customElements.get("aparte-composer-toolbar")) {
5110
5395
  customElements.define("aparte-composer-toolbar", AparteComposerToolbar);
5111
5396
  }
5397
+ function rowSelector(attr, id) {
5398
+ return `[${attr}="${cssEscape(id)}"]`;
5399
+ }
5112
5400
  const DAY = 864e5;
5113
5401
  const MENU_GAP = 4;
5114
5402
  const VIEWPORT_MARGIN = 8;
@@ -5117,8 +5405,37 @@ class AparteConversationList extends HTMLElement {
5117
5405
  _activeId = null;
5118
5406
  _open = null;
5119
5407
  _renaming = null;
5408
+ _warnedNoManager = false;
5120
5409
  static get observedAttributes() {
5121
- return ["active-id", "no-groups"];
5410
+ return ["active-id", "flat", "loading", "manage"];
5411
+ }
5412
+ /**
5413
+ * The list is on its way: rows of the kit's skeleton stand where the rows will be,
5414
+ * the element is `aria-busy`, and a visually hidden line names the wait. Reflected
5415
+ * as the `loading` attribute, so a site sets it while its store answers and clears
5416
+ * it when `conversations` is set. A list that is not there yet is not an empty list.
5417
+ */
5418
+ get loading() {
5419
+ return this.hasAttribute("loading");
5420
+ }
5421
+ set loading(value) {
5422
+ this.toggleAttribute("loading", presenceOn(value));
5423
+ }
5424
+ /**
5425
+ * Let the list finish the gesture through the registered conversation manager.
5426
+ * Reflected as the `manage` attribute.
5427
+ *
5428
+ * The property is not a nicety here: this is the attribute that authorises a WRITE,
5429
+ * and Vue and both Sveltes stringify a bound `false` onto an element that has no
5430
+ * property of that name — `manage="false"`, which `hasAttribute` reads as on. So
5431
+ * turning management off turned it on, and a delete the host meant to handle itself
5432
+ * was also done by the list. `presenceOn`, like `loading` above it.
5433
+ */
5434
+ get manage() {
5435
+ return this.hasAttribute("manage");
5436
+ }
5437
+ set manage(value) {
5438
+ this.toggleAttribute("manage", presenceOn(value));
5122
5439
  }
5123
5440
  // ─── Lifecycle ────────────────────────────────────────────────────────
5124
5441
  connectedCallback() {
@@ -5146,7 +5463,7 @@ class AparteConversationList extends HTMLElement {
5146
5463
  if (name === "active-id") {
5147
5464
  this._activeId = newValue;
5148
5465
  this._updateActiveState();
5149
- } else if (name === "no-groups" && this.isConnected) {
5466
+ } else if ((name === "flat" || name === "loading") && this.isConnected) {
5150
5467
  this._render();
5151
5468
  }
5152
5469
  }
@@ -5174,7 +5491,42 @@ class AparteConversationList extends HTMLElement {
5174
5491
  _render() {
5175
5492
  this._closeMenu();
5176
5493
  this._renaming = null;
5494
+ const focused = activeElementIn(this);
5495
+ const held = focused && this.contains(focused) ? focused.closest("[data-select-id], [data-more-id]") : null;
5496
+ const restore = held?.dataset["selectId"] ? rowSelector("data-select-id", held.dataset["selectId"]) : held?.dataset["moreId"] ? rowSelector("data-more-id", held.dataset["moreId"]) : null;
5497
+ const heldRow = held?.closest("[data-conv-id]") ?? null;
5498
+ const heldIndex = heldRow ? Array.from(this.querySelectorAll("[data-conv-id]")).indexOf(heldRow) : -1;
5499
+ const loading = this.hasAttribute("loading");
5500
+ this.setAttribute("aria-busy", loading ? "true" : "false");
5501
+ if (loading) {
5502
+ const row = '<span class="aparte-skeleton aparte-skeleton--text"></span>';
5503
+ 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>';
5504
+ const status = this.querySelector(".aparte-conv-list-loading-status");
5505
+ const say = () => {
5506
+ if (status?.isConnected) status.textContent = this._t("loadingConversations");
5507
+ };
5508
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(say);
5509
+ else say();
5510
+ return;
5511
+ }
5177
5512
  this.innerHTML = this._groups().map((g2) => this._renderGroup(g2)).join("");
5513
+ if (!restore) return;
5514
+ const back = this.querySelector(restore);
5515
+ if (back) {
5516
+ back.focus();
5517
+ return;
5518
+ }
5519
+ const rows = Array.from(this.querySelectorAll("[data-select-id]"));
5520
+ const next = heldIndex >= 0 ? rows[heldIndex] ?? rows[rows.length - 1] : null;
5521
+ if (next) {
5522
+ next.focus();
5523
+ return;
5524
+ }
5525
+ if (!this.hasAttribute("tabindex")) {
5526
+ this.setAttribute("tabindex", "-1");
5527
+ this.addEventListener("blur", () => this.removeAttribute("tabindex"), { once: true });
5528
+ }
5529
+ this.focus();
5178
5530
  }
5179
5531
  _t(key) {
5180
5532
  const locale = resolveConfig(this).getLocale();
@@ -5183,12 +5535,12 @@ class AparteConversationList extends HTMLElement {
5183
5535
  /**
5184
5536
  * Pinned first, then the calendar buckets every chat product uses, then a heading
5185
5537
  * 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
5538
+ * dated or the host asked for `flat`: a heading over the only group would say
5187
5539
  * nothing.
5188
5540
  */
5189
5541
  _groups() {
5190
5542
  const items = this._conversations;
5191
- if (this.hasAttribute("no-groups") || !items.some((c2) => typeof c2.updatedAt === "number")) {
5543
+ if (this.hasAttribute("flat") || !items.some((c2) => typeof c2.updatedAt === "number")) {
5192
5544
  return [{ key: "all", label: null, items }];
5193
5545
  }
5194
5546
  const now = /* @__PURE__ */ new Date();
@@ -5291,12 +5643,16 @@ class AparteConversationList extends HTMLElement {
5291
5643
  <div class="aparte-menu__separator" role="separator"></div>
5292
5644
  <button type="button" class="aparte-menu__item aparte-conv-menu__item--danger" role="menuitem" data-menu-action="delete">${trashGlyph}<span>${remove}</span></button>`;
5293
5645
  }
5646
+ /** The question the confirm step asks — the popover's accessible name while it asks it. */
5647
+ _confirmQuestion(conv) {
5648
+ return this._t("deleteConversationConfirm").replace("{title}", conv.title || this._t("newChat"));
5649
+ }
5294
5650
  _confirmMarkup(conv) {
5295
- const question = escapeHtml(this._t("deleteConversationConfirm").replace("{title}", conv.title || this._t("newChat")));
5651
+ const question = escapeHtml(this._confirmQuestion(conv));
5296
5652
  const cancel = escapeHtml(this._t("cancel"));
5297
5653
  const remove = escapeHtml(this._t("deleteConversation"));
5298
5654
  return `
5299
- <div class="aparte-conv-menu__confirm" role="group" aria-label="${escapeAttr(question)}">
5655
+ <div class="aparte-conv-menu__confirm">
5300
5656
  <p class="aparte-conv-menu__question">${question}</p>
5301
5657
  <div class="aparte-conv-menu__actions">
5302
5658
  <button type="button" class="aparte-btn aparte-btn--sm aparte-btn--ghost" data-menu-action="cancel">${cancel}</button>
@@ -5320,6 +5676,8 @@ class AparteConversationList extends HTMLElement {
5320
5676
  this._placeMenu(button, menu);
5321
5677
  menu.querySelector('[role="menuitem"]')?.focus();
5322
5678
  document.addEventListener("pointerdown", this._onDocumentPointerDown, true);
5679
+ document.addEventListener("pointerup", this._onDocumentPointerUp, true);
5680
+ document.addEventListener("pointercancel", this._onDocumentPointerUp, true);
5323
5681
  window.addEventListener("scroll", this._onWindowScroll, true);
5324
5682
  window.addEventListener("resize", this._onWindowScroll);
5325
5683
  }
@@ -5350,14 +5708,29 @@ class AparteConversationList extends HTMLElement {
5350
5708
  open.menu.remove();
5351
5709
  open.button.setAttribute("aria-expanded", "false");
5352
5710
  document.removeEventListener("pointerdown", this._onDocumentPointerDown, true);
5711
+ document.removeEventListener("pointerup", this._onDocumentPointerUp, true);
5712
+ document.removeEventListener("pointercancel", this._onDocumentPointerUp, true);
5713
+ this._pressInMenu = false;
5353
5714
  window.removeEventListener("scroll", this._onWindowScroll, true);
5354
5715
  window.removeEventListener("resize", this._onWindowScroll);
5355
5716
  if (returnFocus && open.button.isConnected) open.button.focus();
5356
5717
  }
5718
+ /**
5719
+ * A pointer is down inside the open menu. WebKit does not move the focus to a
5720
+ * button on mousedown: the element that held it is blurred instead — `focusout`
5721
+ * with no `relatedTarget` — before the click reaches the item. `_onFocusOut` used
5722
+ * to read that as the focus leaving the menu and close it on the press, so with a
5723
+ * mouse no item could be chosen at all. Held from the press to its release.
5724
+ */
5725
+ _pressInMenu = false;
5726
+ _onDocumentPointerUp = () => {
5727
+ this._pressInMenu = false;
5728
+ };
5357
5729
  _onDocumentPointerDown = (e) => {
5358
5730
  const open = this._open;
5359
5731
  if (!open) return;
5360
- const target = e.target;
5732
+ const target = eventOrigin(e);
5733
+ this._pressInMenu = !!target && open.menu.contains(target);
5361
5734
  if (open.menu.contains(target) || open.button.contains(target)) return;
5362
5735
  this._closeMenu();
5363
5736
  };
@@ -5368,9 +5741,37 @@ class AparteConversationList extends HTMLElement {
5368
5741
  const open = this._open;
5369
5742
  if (!open) return;
5370
5743
  const next = e.relatedTarget;
5744
+ if (!next && this._pressInMenu) return;
5371
5745
  if (next && (open.menu.contains(next) || open.button.contains(next))) return;
5372
5746
  if (open.menu.contains(e.target)) this._closeMenu();
5373
5747
  };
5748
+ /**
5749
+ * The gesture, finished. The event has already gone out — cancelable, so a host
5750
+ * that wants the write for itself calls `preventDefault()` and this stands down —
5751
+ * and what is left is the registered conversation manager doing it. Without
5752
+ * `manage` the element behaves exactly as it did before the attribute existed.
5753
+ *
5754
+ * A `manage` with no manager registered is a menu that does nothing, so the first
5755
+ * gesture says so and names the call that fixes it. Said here rather than at
5756
+ * `connectedCallback` because a list in the page's HTML connects before the script
5757
+ * that registers the manager runs: a warning at connect would fire on a setup that
5758
+ * is perfectly correct one tick later.
5759
+ */
5760
+ _manage(event, run) {
5761
+ if (!this.hasAttribute("manage") || event.defaultPrevented) return;
5762
+ const manager = resolveConfig(this).getConversationManager();
5763
+ if (!manager) {
5764
+ if (this._warnedNoManager) return;
5765
+ this._warnedNoManager = true;
5766
+ console.warn(
5767
+ "[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."
5768
+ );
5769
+ return;
5770
+ }
5771
+ void run(manager).catch((err) => {
5772
+ console.warn("[aparte-conversation-list] the conversation manager refused the change:", err);
5773
+ });
5774
+ }
5374
5775
  _onMenuAction(action) {
5375
5776
  const open = this._open;
5376
5777
  if (!open) return;
@@ -5392,46 +5793,64 @@ class AparteConversationList extends HTMLElement {
5392
5793
  case "pin":
5393
5794
  this._closeMenu(true);
5394
5795
  if (conv.pinnedAt) {
5395
- this.dispatchEvent(new CustomEvent(
5396
- "aparte-unpin-conversation",
5397
- { detail: { id }, bubbles: true, composed: true }
5398
- ));
5796
+ const unpin = new CustomEvent(
5797
+ "aparte-conversation-unpin",
5798
+ { detail: { id }, bubbles: true, composed: true, cancelable: true }
5799
+ );
5800
+ this.dispatchEvent(unpin);
5801
+ this._manage(unpin, (m2) => m2.unpin(id));
5399
5802
  } else {
5400
- this.dispatchEvent(new CustomEvent(
5401
- "aparte-pin-conversation",
5402
- { detail: { id }, bubbles: true, composed: true }
5403
- ));
5803
+ const pin = new CustomEvent(
5804
+ "aparte-conversation-pin",
5805
+ { detail: { id }, bubbles: true, composed: true, cancelable: true }
5806
+ );
5807
+ this.dispatchEvent(pin);
5808
+ this._manage(pin, (m2) => m2.pin(id));
5404
5809
  }
5405
5810
  return;
5406
5811
  case "archive":
5407
5812
  this._closeMenu(true);
5408
5813
  if (conv.archivedAt) {
5409
- this.dispatchEvent(new CustomEvent(
5410
- "aparte-unarchive-conversation",
5411
- { detail: { id }, bubbles: true, composed: true }
5412
- ));
5814
+ const unarchive = new CustomEvent(
5815
+ "aparte-conversation-unarchive",
5816
+ { detail: { id }, bubbles: true, composed: true, cancelable: true }
5817
+ );
5818
+ this.dispatchEvent(unarchive);
5819
+ this._manage(unarchive, (m2) => m2.unarchive(id));
5413
5820
  } else {
5414
- this.dispatchEvent(new CustomEvent(
5415
- "aparte-archive-conversation",
5416
- { detail: { id }, bubbles: true, composed: true }
5417
- ));
5821
+ const archive = new CustomEvent(
5822
+ "aparte-conversation-archive",
5823
+ { detail: { id }, bubbles: true, composed: true, cancelable: true }
5824
+ );
5825
+ this.dispatchEvent(archive);
5826
+ this._manage(archive, (m2) => m2.archive(id));
5418
5827
  }
5419
5828
  return;
5420
- case "delete":
5421
- open.menu.innerHTML = this._confirmMarkup(conv);
5422
- this._placeMenu(open.button, open.menu);
5829
+ case "delete": {
5830
+ const previous = [...open.menu.childNodes];
5831
+ const holder = document.createElement("div");
5832
+ holder.innerHTML = this._confirmMarkup(conv);
5833
+ open.menu.append(...holder.childNodes);
5834
+ open.menu.setAttribute("role", "dialog");
5835
+ open.menu.setAttribute("aria-label", this._confirmQuestion(conv));
5423
5836
  open.menu.querySelector('[data-menu-action="cancel"]')?.focus();
5837
+ for (const node of previous) node.remove();
5838
+ this._placeMenu(open.button, open.menu);
5424
5839
  return;
5840
+ }
5425
5841
  case "cancel":
5426
5842
  this._closeMenu(true);
5427
5843
  return;
5428
- case "confirm-delete":
5844
+ case "confirm-delete": {
5429
5845
  this._closeMenu(true);
5430
- this.dispatchEvent(new CustomEvent(
5431
- "aparte-delete-conversation",
5432
- { detail: { id }, bubbles: true, composed: true }
5433
- ));
5846
+ const remove = new CustomEvent(
5847
+ "aparte-conversation-delete",
5848
+ { detail: { id }, bubbles: true, composed: true, cancelable: true }
5849
+ );
5850
+ this.dispatchEvent(remove);
5851
+ this._manage(remove, (m2) => m2.delete(id));
5434
5852
  return;
5853
+ }
5435
5854
  }
5436
5855
  }
5437
5856
  _menuFocusables() {
@@ -5441,7 +5860,7 @@ class AparteConversationList extends HTMLElement {
5441
5860
  }
5442
5861
  _onMenuKeydown(e) {
5443
5862
  const items = this._menuFocusables();
5444
- const index = items.indexOf(document.activeElement);
5863
+ const index = items.indexOf(activeElementIn(this));
5445
5864
  const focusAt = (i2) => {
5446
5865
  e.preventDefault();
5447
5866
  items[(i2 + items.length) % items.length]?.focus();
@@ -5507,10 +5926,12 @@ class AparteConversationList extends HTMLElement {
5507
5926
  const title = renaming.input.value.trim();
5508
5927
  this._render();
5509
5928
  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
- ));
5929
+ const rename = new CustomEvent(
5930
+ "aparte-conversation-rename",
5931
+ { detail: { id: conv.id, title }, bubbles: true, composed: true, cancelable: true }
5932
+ );
5933
+ this.dispatchEvent(rename);
5934
+ this._manage(rename, (m2) => m2.updateTitle(conv.id, title));
5514
5935
  }
5515
5936
  if (restoreFocus) {
5516
5937
  this.querySelector(`[data-select-id="${cssEscape(renaming.id)}"]`)?.focus();
@@ -5540,7 +5961,7 @@ class AparteConversationList extends HTMLElement {
5540
5961
  if (select) {
5541
5962
  this._closeMenu();
5542
5963
  this.dispatchEvent(new CustomEvent(
5543
- "aparte-select-conversation",
5964
+ "aparte-conversation-select",
5544
5965
  { detail: { id: select.dataset["selectId"] }, bubbles: true, composed: true }
5545
5966
  ));
5546
5967
  }
@@ -5574,6 +5995,15 @@ class AparteConversationList extends HTMLElement {
5574
5995
  }
5575
5996
  if (!customElements.get("aparte-conversation-list")) customElements.define("aparte-conversation-list", AparteConversationList);
5576
5997
  const isSuggestion = (value) => typeof value === "string" || typeof value === "object" && value !== null && typeof value.label === "string";
5998
+ const parseSuggestions = (raw) => {
5999
+ try {
6000
+ const parsed = JSON.parse(raw);
6001
+ return Array.isArray(parsed) ? parsed.filter(isSuggestion) : [];
6002
+ } catch {
6003
+ console.warn('[aparte-suggestions] `suggestions` is not a JSON array. Use ["…"] or [{"label": "…", "prompt": "…"}].');
6004
+ return [];
6005
+ }
6006
+ };
5577
6007
  const labelOf = (s2) => typeof s2 === "string" ? s2 : s2.label;
5578
6008
  const promptOf = (s2) => typeof s2 === "string" ? s2 : s2.prompt ?? s2.label;
5579
6009
  class AparteSuggestions extends HTMLElement {
@@ -5593,8 +6023,17 @@ class AparteSuggestions extends HTMLElement {
5593
6023
  get suggestions() {
5594
6024
  return this._suggestions;
5595
6025
  }
6026
+ /**
6027
+ * Takes the array, or the JSON string the attribute documents.
6028
+ *
6029
+ * Both, because both arrive: React 19 and Svelte assign the PROPERTY whenever the
6030
+ * element has one of that name, so `<aparte-suggestions suggestions='[…]'>` — the
6031
+ * documented spelling, typed valid by both wrappers — reached this setter as a
6032
+ * string, `Array.isArray` said no, and the row rendered nothing while the attribute
6033
+ * (and so `attributeChangedCallback`) was never written.
6034
+ */
5596
6035
  set suggestions(value) {
5597
- this._suggestions = Array.isArray(value) ? value.filter(isSuggestion) : [];
6036
+ this._suggestions = typeof value === "string" ? parseSuggestions(value) : Array.isArray(value) ? value.filter(isSuggestion) : [];
5598
6037
  if (this.isConnected) this._render();
5599
6038
  }
5600
6039
  connectedCallback() {
@@ -5630,13 +6069,7 @@ class AparteSuggestions extends HTMLElement {
5630
6069
  const raw = this.getAttribute("suggestions");
5631
6070
  if (raw === null || raw === this._parsed) return;
5632
6071
  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
- }
6072
+ this._suggestions = parseSuggestions(raw);
5640
6073
  }
5641
6074
  /**
5642
6075
  * The composer the click goes through, resolved the way the composer's own parts
@@ -5778,6 +6211,17 @@ class AparteContext extends HTMLElement {
5778
6211
  get window() {
5779
6212
  return this._window();
5780
6213
  }
6214
+ /**
6215
+ * Writes the `window` attribute; `null` gives the model's own window back.
6216
+ *
6217
+ * The setter has to exist: React 19 and Svelte assign the PROPERTY whenever the
6218
+ * element has one of that name, so `<aparte-context window={8000}>` — a spelling
6219
+ * both wrappers type as valid — hit a getter alone and threw.
6220
+ */
6221
+ set window(value) {
6222
+ if (value === null || value === void 0) this.removeAttribute("window");
6223
+ else this.setAttribute("window", String(value));
6224
+ }
5781
6225
  /** The current level, or `null` while nothing is shown. */
5782
6226
  get level() {
5783
6227
  return this._level;
@@ -6038,7 +6482,8 @@ class AparteScrollRail extends HTMLElement {
6038
6482
  _rebuild() {
6039
6483
  const bubbles = this._bubbles();
6040
6484
  const locale = resolveConfig(this).getLocale();
6041
- this.setAttribute("aria-label", locale.scrollRailLabel ?? "Conversation outline");
6485
+ const railLabel = locale.scrollRailLabel ?? "Conversation outline";
6486
+ if (this.getAttribute("aria-label") !== railLabel) this.setAttribute("aria-label", railLabel);
6042
6487
  if (bubbles.length < 2) {
6043
6488
  this.setAttribute("data-empty", "");
6044
6489
  this.replaceChildren();
@@ -6081,8 +6526,11 @@ class AparteScrollRail extends HTMLElement {
6081
6526
  tick.setAttribute("aria-label", label);
6082
6527
  tick.title = label;
6083
6528
  }
6084
- if (id === this._currentId) tick.setAttribute("aria-current", "true");
6085
- else tick.removeAttribute("aria-current");
6529
+ if (id === this._currentId) {
6530
+ if (tick.getAttribute("aria-current") !== "true") tick.setAttribute("aria-current", "true");
6531
+ } else {
6532
+ tick.removeAttribute("aria-current");
6533
+ }
6086
6534
  const at = list.children[index] ?? null;
6087
6535
  if (at !== item) list.insertBefore(item, at);
6088
6536
  index++;
@@ -6264,7 +6712,7 @@ class AparteScrollRail extends HTMLElement {
6264
6712
  _onKeydown = (e) => {
6265
6713
  if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") return;
6266
6714
  const ticks = Array.from(this.querySelectorAll(".aparte-scroll-rail__tick"));
6267
- const index = ticks.indexOf(document.activeElement);
6715
+ const index = ticks.indexOf(activeElementIn(this));
6268
6716
  if (index === -1) return;
6269
6717
  e.preventDefault();
6270
6718
  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 +6969,10 @@ class AparteSidebar extends HTMLElement {
6521
6969
  * lies. The row's own `⋯` button already did this; the sidebar's toggle did not.
6522
6970
  */
6523
6971
  _syncToggles() {
6524
- for (const control of document.querySelectorAll("[data-aparte-sidebar-toggle]")) {
6972
+ const selector = "[data-aparte-sidebar-toggle]";
6973
+ const root = rootOf(this);
6974
+ const controls = root === document ? Array.from(document.querySelectorAll(selector)) : [...root.querySelectorAll(selector), ...document.querySelectorAll(selector)];
6975
+ for (const control of controls) {
6525
6976
  if (!this._drives(control)) continue;
6526
6977
  control.setAttribute("aria-expanded", String(!this.collapsed));
6527
6978
  control.setAttribute("aria-controls", this.id);
@@ -6594,7 +7045,7 @@ class AparteSidebar extends HTMLElement {
6594
7045
  if (focusable.length === 0) return;
6595
7046
  const first = focusable[0];
6596
7047
  const last = focusable[focusable.length - 1];
6597
- const active = document.activeElement;
7048
+ const active = activeElementIn(this);
6598
7049
  if (!this.contains(active)) {
6599
7050
  e.preventDefault();
6600
7051
  first.focus();
@@ -6609,7 +7060,7 @@ class AparteSidebar extends HTMLElement {
6609
7060
  };
6610
7061
  /** A `[data-aparte-sidebar-toggle]` anywhere toggles this sidebar — no script in the host. */
6611
7062
  _onDocumentClick = (e) => {
6612
- const control = e.target?.closest?.("[data-aparte-sidebar-toggle]");
7063
+ const control = eventOrigin(e)?.closest?.("[data-aparte-sidebar-toggle]");
6613
7064
  if (!control) return;
6614
7065
  if (!this._drives(control)) return;
6615
7066
  this.toggle(control);
@@ -6658,7 +7109,7 @@ function visibleFocusable(root) {
6658
7109
  return Array.from(root.querySelectorAll(FOCUSABLE)).filter((el2) => !el2.closest("[hidden]") && el2.checkVisibility?.() !== false);
6659
7110
  }
6660
7111
  function nearestSidebar(control) {
6661
- return control.closest("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
7112
+ return control.closest("aparte-sidebar") ?? rootOf(control).querySelector("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
6662
7113
  }
6663
7114
  function fold(text) {
6664
7115
  return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
@@ -7385,15 +7836,16 @@ class AparteSplit extends HTMLElement {
7385
7836
  * first on the page. Same shape as `[data-aparte-sidebar-toggle]`.
7386
7837
  */
7387
7838
  _onDocumentClick = (e) => {
7388
- const control = e.target?.closest?.("[data-aparte-split-pane]");
7839
+ const control = eventOrigin(e)?.closest?.("[data-aparte-split-pane]");
7389
7840
  if (!control) return;
7390
7841
  const value = (control.getAttribute("data-aparte-split-pane") ?? "").trim();
7391
7842
  const which = value === "start" || value === "end" ? value : null;
7392
7843
  const named = which ? "" : value;
7393
7844
  if (named) {
7394
7845
  if (named !== this.id) return;
7395
- } else if ((control.closest("aparte-split") ?? document.querySelector("aparte-split")) !== this) {
7396
- return;
7846
+ } else {
7847
+ const nearest = control.closest("aparte-split") ?? rootOf(control).querySelector("aparte-split") ?? document.querySelector("aparte-split");
7848
+ if (nearest !== this) return;
7397
7849
  }
7398
7850
  this.showPane(which ?? (this.pane === "start" ? "end" : "start"));
7399
7851
  };
@@ -7525,10 +7977,10 @@ export {
7525
7977
  installDialogTriggersOnce,
7526
7978
  isAwaitingReply,
7527
7979
  R as isFormatAdapter,
7528
- S as isSafeUrl,
7980
+ isSafeUrl,
7529
7981
  T as isSegmentSettled,
7530
7982
  U as parseAparteEventStream,
7531
- V as parseMarkdownToSegments,
7983
+ parseMarkdownToSegments,
7532
7984
  populateBubbleFromMessage,
7533
7985
  X as readableToAsyncIterable,
7534
7986
  registerAllComponents,