@aparte/core 0.15.1 → 0.16.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 (143) hide show
  1. package/dist/client/aparte-client.d.ts +53 -51
  2. package/dist/client/aparte-client.d.ts.map +1 -1
  3. package/dist/client/lifecycle-events.d.ts +0 -12
  4. package/dist/client/lifecycle-events.d.ts.map +1 -1
  5. package/dist/client/stream-adapter.d.ts +2 -12
  6. package/dist/client/stream-adapter.d.ts.map +1 -1
  7. package/dist/components/bubble/aparte-chat-bubble.d.ts +45 -1
  8. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  9. package/dist/components/chat/aparte-chat.d.ts +1 -1
  10. package/dist/components/composer/aparte-composer-action.d.ts +1 -1
  11. package/dist/components/composer/aparte-composer-add-attachment.d.ts +1 -1
  12. package/dist/components/composer/aparte-composer-attachments.d.ts +1 -2
  13. package/dist/components/composer/aparte-composer-attachments.d.ts.map +1 -1
  14. package/dist/components/composer/aparte-composer-cancel.d.ts +1 -1
  15. package/dist/components/composer/aparte-composer-input.d.ts +1 -1
  16. package/dist/components/composer/aparte-composer-send.d.ts +12 -12
  17. package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
  18. package/dist/components/composer/aparte-composer.d.ts +29 -12
  19. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  20. package/dist/components/context/aparte-context.d.ts +31 -8
  21. package/dist/components/context/aparte-context.d.ts.map +1 -1
  22. package/dist/components/conversation-list/aparte-conversation-list.d.ts +93 -36
  23. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  24. package/dist/components/conversation-list/index.d.ts +1 -1
  25. package/dist/components/conversation-list/index.d.ts.map +1 -1
  26. package/dist/components/elicitation/aparte-elicitation.d.ts +20 -2
  27. package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
  28. package/dist/components/index.d.ts +7 -1
  29. package/dist/components/index.d.ts.map +1 -1
  30. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts +105 -0
  31. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -0
  32. package/dist/components/scroll-rail/index.d.ts +3 -0
  33. package/dist/components/scroll-rail/index.d.ts.map +1 -0
  34. package/dist/components/sidebar/aparte-sidebar.d.ts +186 -0
  35. package/dist/components/sidebar/aparte-sidebar.d.ts.map +1 -0
  36. package/dist/components/sidebar/index.d.ts +3 -0
  37. package/dist/components/sidebar/index.d.ts.map +1 -0
  38. package/dist/components/split/aparte-split.d.ts +354 -0
  39. package/dist/components/split/aparte-split.d.ts.map +1 -0
  40. package/dist/components/split/geometry.d.ts +65 -0
  41. package/dist/components/split/geometry.d.ts.map +1 -0
  42. package/dist/components/split/index.d.ts +3 -0
  43. package/dist/components/split/index.d.ts.map +1 -0
  44. package/dist/components/suggestions/aparte-suggestions.d.ts +7 -1
  45. package/dist/components/suggestions/aparte-suggestions.d.ts.map +1 -1
  46. package/dist/components/viewport/aparte-chat-viewport.d.ts +52 -2
  47. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  48. package/dist/config/aparte-config.d.ts +95 -33
  49. package/dist/config/aparte-config.d.ts.map +1 -1
  50. package/dist/config/icon-provider.d.ts +6 -0
  51. package/dist/config/icon-provider.d.ts.map +1 -1
  52. package/dist/config/index.d.ts +2 -2
  53. package/dist/config/index.d.ts.map +1 -1
  54. package/dist/config/locale.d.ts +140 -27
  55. package/dist/config/locale.d.ts.map +1 -1
  56. package/dist/config/sanitize.d.ts +9 -0
  57. package/dist/config/sanitize.d.ts.map +1 -1
  58. package/dist/conversations/conversation-controller.d.ts +13 -1
  59. package/dist/conversations/conversation-controller.d.ts.map +1 -1
  60. package/dist/conversations/conversation-manager.d.ts +9 -0
  61. package/dist/conversations/conversation-manager.d.ts.map +1 -1
  62. package/dist/custom-elements.json +10278 -7784
  63. package/dist/{is-awaiting-reply-BZ_iJ3s3.js → dialog-triggers-CVCxqKg3.js} +718 -1182
  64. package/dist/dialog-triggers-CVCxqKg3.js.map +1 -0
  65. package/dist/elicitation/approval-panel.d.ts +1 -1
  66. package/dist/elicitation/approval-panel.d.ts.map +1 -1
  67. package/dist/elicitation/panel.d.ts +24 -16
  68. package/dist/elicitation/panel.d.ts.map +1 -1
  69. package/dist/elicitation/types.d.ts +31 -0
  70. package/dist/elicitation/types.d.ts.map +1 -1
  71. package/dist/generated/element-attributes.d.ts +47 -1
  72. package/dist/generated/element-attributes.d.ts.map +1 -1
  73. package/dist/{glyphs-B3BZ2TIb.js → glyphs-DQTeOj4w.js} +22 -13
  74. package/dist/{glyphs-B3BZ2TIb.js.map → glyphs-DQTeOj4w.js.map} +1 -1
  75. package/dist/host/aparte-chat-host.d.ts +10 -2
  76. package/dist/host/aparte-chat-host.d.ts.map +1 -1
  77. package/dist/icons/extended.d.ts +4 -2
  78. package/dist/icons/extended.d.ts.map +1 -1
  79. package/dist/icons/glyphs.d.ts +9 -0
  80. package/dist/icons/glyphs.d.ts.map +1 -1
  81. package/dist/icons.js +16 -15
  82. package/dist/icons.js.map +1 -1
  83. package/dist/index.css +1284 -397
  84. package/dist/index.d.ts +26 -15
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +2099 -296
  87. package/dist/index.js.map +1 -1
  88. package/dist/index.node.d.ts +21 -5
  89. package/dist/index.node.d.ts.map +1 -1
  90. package/dist/index.node.js +39 -34
  91. package/dist/index.node.js.map +1 -1
  92. package/dist/interop/dialog-triggers.d.ts +27 -0
  93. package/dist/interop/dialog-triggers.d.ts.map +1 -0
  94. package/dist/interop/element-props.d.ts +16 -8
  95. package/dist/interop/element-props.d.ts.map +1 -1
  96. package/dist/parsers/aparte-stream-parser.d.ts +52 -22
  97. package/dist/parsers/aparte-stream-parser.d.ts.map +1 -1
  98. package/dist/parsers/index.d.ts +1 -1
  99. package/dist/parsers/index.d.ts.map +1 -1
  100. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  101. package/dist/renderers/segment-renderers.d.ts.map +1 -1
  102. package/dist/renderers/segments/code.d.ts.map +1 -1
  103. package/dist/renderers/segments/tool-call.d.ts.map +1 -1
  104. package/dist/runtime/message-repository.d.ts.map +1 -1
  105. package/dist/transport/backend-handler.d.ts.map +1 -1
  106. package/dist/transport/backend-transport.d.ts +6 -1
  107. package/dist/transport/backend-transport.d.ts.map +1 -1
  108. package/dist/types/chat.d.ts +8 -14
  109. package/dist/types/chat.d.ts.map +1 -1
  110. package/dist/types/element-map.d.ts +54 -4
  111. package/dist/types/element-map.d.ts.map +1 -1
  112. package/dist/types/event-map.d.ts +16 -10
  113. package/dist/types/event-map.d.ts.map +1 -1
  114. package/dist/types/events.d.ts +59 -117
  115. package/dist/types/events.d.ts.map +1 -1
  116. package/dist/types/imperative-api.d.ts +3 -1
  117. package/dist/types/imperative-api.d.ts.map +1 -1
  118. package/dist/types/index.d.ts +5 -4
  119. package/dist/types/index.d.ts.map +1 -1
  120. package/dist/types/models.d.ts +11 -31
  121. package/dist/types/models.d.ts.map +1 -1
  122. package/dist/types/segments.d.ts +11 -47
  123. package/dist/types/segments.d.ts.map +1 -1
  124. package/dist/types/stream-blocks.d.ts +57 -0
  125. package/dist/types/stream-blocks.d.ts.map +1 -0
  126. package/dist/types/tools.d.ts +57 -0
  127. package/dist/types/tools.d.ts.map +1 -1
  128. package/dist/utils/copy-text.d.ts +24 -0
  129. package/dist/utils/copy-text.d.ts.map +1 -0
  130. package/dist/utils/css-escape.d.ts +5 -0
  131. package/dist/utils/css-escape.d.ts.map +1 -1
  132. package/dist/utils/tool-input.d.ts +20 -0
  133. package/dist/utils/tool-input.d.ts.map +1 -0
  134. package/package.json +10 -3
  135. package/dist/is-awaiting-reply-BZ_iJ3s3.js.map +0 -1
  136. package/dist/renderers/segments/artifact/binary-file.d.ts +0 -45
  137. package/dist/renderers/segments/artifact/binary-file.d.ts.map +0 -1
  138. package/dist/renderers/segments/artifact/card.d.ts +0 -3
  139. package/dist/renderers/segments/artifact/card.d.ts.map +0 -1
  140. package/dist/renderers/segments/artifact/preview-document.d.ts +0 -27
  141. package/dist/renderers/segments/artifact/preview-document.d.ts.map +0 -1
  142. package/dist/renderers/segments/artifact/shared.d.ts +0 -29
  143. package/dist/renderers/segments/artifact/shared.d.ts.map +0 -1
package/dist/index.js CHANGED
@@ -1,6 +1,5 @@
1
- import { Z as resolveConfig, a2 as subscribeConfigChange, $ as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, I as escapeAttr, E as cssEscape, a5 as mergeSegmentUpdate, J as escapeHtml, a6 as writeStreamedMarkdown, M as getSegmentRenderer, N as installDefaultRenderersOnce, r as AparteMessageRepository, a7 as stampSegmentActivity, a8 as stampSegmentOnInsert, a9 as stampSegmentOnUpdate, aa as renumberSegments, ab as openSegmentIds, ac as isTerminalStatus, ad as adoptMessageSegments, O as isAwaitingReply, U as populateBubbleFromMessage, a4 as uuid, _ as revokeAttachmentUrls } from "./is-awaiting-reply-BZ_iJ3s3.js";
2
- import { A, a, b, c, d, e, f, g, h, i, j, k, l, m, n, p, q, s, t, u, v, y, z, B, C, D, F, G, H, K, L, P, Q, R, S, T, V, W, X, Y, a0, a1, a3 } from "./is-awaiting-reply-BZ_iJ3s3.js";
3
- import { deriveArtifactKind } from "@aparte/engine";
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-CVCxqKg3.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-CVCxqKg3.js";
4
3
  class AparteOption extends HTMLElement {
5
4
  static get observedAttributes() {
6
5
  return ["value", "disabled", "selected", "data-status"];
@@ -136,8 +135,8 @@ class AparteOptgroup extends HTMLElement {
136
135
  chevron.innerHTML = resolveConfig(this).getIcon("expand");
137
136
  header.appendChild(chevron);
138
137
  header.style.cursor = "pointer";
139
- header.addEventListener("click", (e2) => {
140
- e2.stopPropagation();
138
+ header.addEventListener("click", (e) => {
139
+ e.stopPropagation();
141
140
  this._toggleCollapse();
142
141
  });
143
142
  }
@@ -278,6 +277,7 @@ class AparteSelect extends HTMLElement {
278
277
  return;
279
278
  }
280
279
  const slottedChildren = Array.from(this.children);
280
+ const cfg = resolveConfig(this);
281
281
  const trigger = document.createElement("div");
282
282
  trigger.className = "aparte-select-trigger";
283
283
  trigger.setAttribute("tabindex", "0");
@@ -296,7 +296,7 @@ class AparteSelect extends HTMLElement {
296
296
  labelSpan.append(labelText, sizer);
297
297
  const chevronSpan = document.createElement("span");
298
298
  chevronSpan.className = "aparte-select-chevron";
299
- chevronSpan.innerHTML = resolveConfig(this).getIcon("expand");
299
+ chevronSpan.innerHTML = cfg.getIcon("expand");
300
300
  trigger.append(labelSpan, chevronSpan);
301
301
  const dropdown = document.createElement("div");
302
302
  dropdown.className = "aparte-select-dropdown";
@@ -305,8 +305,8 @@ class AparteSelect extends HTMLElement {
305
305
  const searchInput = document.createElement("input");
306
306
  searchInput.type = "text";
307
307
  searchInput.className = "aparte-select-search";
308
- searchInput.placeholder = "Search...";
309
- searchInput.setAttribute("aria-label", "Search options");
308
+ searchInput.placeholder = cfg.t("selectSearchPlaceholder") || "Search";
309
+ searchInput.setAttribute("aria-label", cfg.t("selectSearchLabel") || "Search options");
310
310
  dropdown.appendChild(searchInput);
311
311
  }
312
312
  const optionsContainer = document.createElement("div");
@@ -382,68 +382,68 @@ class AparteSelect extends HTMLElement {
382
382
  }
383
383
  _setupEventListeners() {
384
384
  this._trigger?.addEventListener("click", () => this._toggle());
385
- this._trigger?.addEventListener("keydown", (e2) => {
386
- if (e2.key === "Enter" || e2.key === " ") {
385
+ this._trigger?.addEventListener("keydown", (e) => {
386
+ if (e.key === "Enter" || e.key === " ") {
387
387
  if (this._isOpen) return;
388
- e2.preventDefault();
388
+ e.preventDefault();
389
389
  this._toggle();
390
390
  }
391
- if (e2.key === "ArrowDown" && !this._isOpen) {
392
- e2.preventDefault();
391
+ if (e.key === "ArrowDown" && !this._isOpen) {
392
+ e.preventDefault();
393
393
  this._openDropdown();
394
394
  }
395
395
  });
396
396
  this.removeEventListener("click", this._boundHandleOptionClick);
397
397
  this.addEventListener("click", this._boundHandleOptionClick);
398
- this._searchInput?.addEventListener("input", (e2) => {
399
- const query = e2.target.value.toLowerCase();
398
+ this._searchInput?.addEventListener("input", (e) => {
399
+ const query = e.target.value.toLowerCase();
400
400
  this._filterOptions(query);
401
401
  });
402
402
  document.addEventListener("click", this._boundHandleDocumentClick);
403
403
  document.addEventListener("keydown", this._boundHandleKeydown);
404
404
  }
405
- _handleOptionClick(e2) {
406
- const option = e2.target.closest("aparte-option");
405
+ _handleOptionClick(e) {
406
+ const option = e.target.closest("aparte-option");
407
407
  if (option && !option.hasAttribute("disabled")) {
408
408
  this._selectOption(option);
409
409
  }
410
410
  }
411
- _handleDocumentClick(e2) {
412
- if (!this.contains(e2.target)) {
411
+ _handleDocumentClick(e) {
412
+ if (!this.contains(e.target)) {
413
413
  this._closeDropdown();
414
414
  }
415
415
  }
416
- _handleKeydown(e2) {
416
+ _handleKeydown(e) {
417
417
  if (!this._isOpen) return;
418
418
  const inSearch = document.activeElement === this._searchInput;
419
- switch (e2.key) {
419
+ switch (e.key) {
420
420
  case "Escape":
421
- e2.preventDefault();
421
+ e.preventDefault();
422
422
  this._closeDropdown();
423
423
  this._trigger?.focus();
424
424
  break;
425
425
  case "ArrowDown":
426
- e2.preventDefault();
426
+ e.preventDefault();
427
427
  this._moveActive(1);
428
428
  break;
429
429
  case "ArrowUp":
430
- e2.preventDefault();
430
+ e.preventDefault();
431
431
  this._moveActive(-1);
432
432
  break;
433
433
  case "Home":
434
434
  if (inSearch) break;
435
- e2.preventDefault();
435
+ e.preventDefault();
436
436
  this._setActive(0);
437
437
  break;
438
438
  case "End":
439
439
  if (inSearch) break;
440
- e2.preventDefault();
440
+ e.preventDefault();
441
441
  this._setActive(this._visibleOptions().length - 1);
442
442
  break;
443
443
  case "Enter": {
444
444
  const active = this._visibleOptions()[this._activeIndex];
445
445
  if (active) {
446
- e2.preventDefault();
446
+ e.preventDefault();
447
447
  this._selectOption(active);
448
448
  }
449
449
  break;
@@ -695,8 +695,8 @@ class AparteElicitation extends HTMLElement {
695
695
  * cannot identify accepts everything rather than becoming deaf. Only a
696
696
  * MISMATCH is ignored.
697
697
  */
698
- _onTurnEnd = (e2) => {
699
- const evtTargetId = e2.detail?.targetId;
698
+ _onTurnEnd = (e) => {
699
+ const evtTargetId = e.detail?.targetId;
700
700
  const own = this._pendingTargetId();
701
701
  if (evtTargetId && own && evtTargetId !== own) return;
702
702
  this._cancelPending();
@@ -783,7 +783,7 @@ class AparteElicitation extends HTMLElement {
783
783
  const approvalMode = () => panel2.isComplete() ? "submit" : "none";
784
784
  const panel2 = runWithConfig(cfg, () => buildApprovalPanel(request.message, request.options ?? [], () => {
785
785
  composer.setPanelSubmitEnabled(panel2.isComplete(), approvalMode());
786
- }));
786
+ }, request.details));
787
787
  panel2.onSettle((answer) => settle({ action: "accept", content: answer }));
788
788
  this._pending = { abort: () => fail(), composer, relabel: () => panel2.relabel() };
789
789
  slot.token = composer.showPanel(panel2.el, {
@@ -829,11 +829,6 @@ class AparteElicitation extends HTMLElement {
829
829
  */
830
830
  onEvict: () => fail(),
831
831
  onSubmit: () => {
832
- if (panel.mode() === "advance") {
833
- panel.proceed();
834
- composer.setPanelSubmitEnabled(panel.canProceed(), panel.mode());
835
- return;
836
- }
837
832
  if (panel.isComplete()) settle({ action: "accept", content: panel.getContent() });
838
833
  }
839
834
  });
@@ -1034,11 +1029,17 @@ function resolveSegmentRenderer(type, config) {
1034
1029
  installDefaultRenderersOnce(config);
1035
1030
  return getSegmentRenderer(type, config);
1036
1031
  }
1037
- function segmentRenderResultToElement(result) {
1038
- if (result instanceof HTMLElement) return result;
1039
- const wrapper = document.createElement("div");
1040
- wrapper.innerHTML = result;
1041
- return wrapper.firstElementChild;
1032
+ function segmentRenderResultToElement(result, segment) {
1033
+ let el2;
1034
+ if (result instanceof HTMLElement) {
1035
+ el2 = result;
1036
+ } else {
1037
+ const wrapper = document.createElement("div");
1038
+ wrapper.innerHTML = result;
1039
+ el2 = wrapper.firstElementChild;
1040
+ }
1041
+ if (el2 && segment && !el2.hasAttribute("data-segment-id")) el2.setAttribute("data-segment-id", segment.id);
1042
+ return el2;
1042
1043
  }
1043
1044
  class AparteChatBubble extends HTMLElement {
1044
1045
  _contentEl = null;
@@ -1072,8 +1073,8 @@ class AparteChatBubble extends HTMLElement {
1072
1073
  // Rebuild the action bar when the global config changes (e.g. a live skin
1073
1074
  // switch calling setBubbleActions / setIconProvider) so already-rendered
1074
1075
  // bubbles pick up the new per-role actions + icons without being re-created.
1075
- _onConfigChange = (e2) => {
1076
- const detail = e2.detail;
1076
+ _onConfigChange = (e) => {
1077
+ const detail = e.detail;
1077
1078
  if (detail?.config && detail.config !== this._cfg) return;
1078
1079
  this._updateActionBar();
1079
1080
  this._updateName();
@@ -1140,10 +1141,12 @@ class AparteChatBubble extends HTMLElement {
1140
1141
  this._updateTimestamp(this.getAttribute("timestamp"));
1141
1142
  window.addEventListener("aparte-config-change", this._onConfigChange);
1142
1143
  this.addEventListener("click", this._onBranchPickerClick);
1144
+ this.addEventListener("click", this._onLinkClick);
1143
1145
  }
1144
1146
  disconnectedCallback() {
1145
1147
  window.removeEventListener("aparte-config-change", this._onConfigChange);
1146
1148
  this.removeEventListener("click", this._onBranchPickerClick);
1149
+ this.removeEventListener("click", this._onLinkClick);
1147
1150
  if (this._avatarCleanup) {
1148
1151
  try {
1149
1152
  this._avatarCleanup();
@@ -1307,7 +1310,7 @@ class AparteChatBubble extends HTMLElement {
1307
1310
  this._updateContent();
1308
1311
  }
1309
1312
  if ("segments" in updates) {
1310
- this._segments = updates.segments;
1313
+ this._segments = [...updates.segments];
1311
1314
  this._renderSegments();
1312
1315
  }
1313
1316
  if ("timestamp" in updates) {
@@ -1333,7 +1336,7 @@ class AparteChatBubble extends HTMLElement {
1333
1336
  }
1334
1337
  const renderer = resolveSegmentRenderer(segment.type, this._cfg);
1335
1338
  if (renderer) {
1336
- const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)));
1339
+ const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)), segment);
1337
1340
  if (el2) {
1338
1341
  this._segmentsEl.appendChild(el2);
1339
1342
  runWithConfig(this._cfg, () => renderer.setup?.(el2, segment));
@@ -1355,7 +1358,7 @@ class AparteChatBubble extends HTMLElement {
1355
1358
  if (renderer.update) {
1356
1359
  runWithConfig(this._cfg, () => renderer.update(el2, segment));
1357
1360
  } else {
1358
- const newEl = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)));
1361
+ const newEl = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)), segment);
1359
1362
  if (newEl) {
1360
1363
  el2.replaceWith(newEl);
1361
1364
  runWithConfig(this._cfg, () => renderer.setup?.(newEl, segment));
@@ -1438,6 +1441,7 @@ class AparteChatBubble extends HTMLElement {
1438
1441
  this._segmentsEl = this.querySelector(".aparte-segments");
1439
1442
  this._attachmentsEl = this.querySelector(".aparte-attachments");
1440
1443
  this._actionBarEl = this.querySelector(".aparte-action-bar");
1444
+ this._actionBarEl?.addEventListener("keydown", this._onActionBarKeydown);
1441
1445
  this._branchPickerEl = this.querySelector(".aparte-branch-picker");
1442
1446
  this._footerEl = this.querySelector(".aparte-footer");
1443
1447
  this._transcriptBusy = !!this.closest("aparte-chat-viewport")?.hasAttribute("data-busy");
@@ -1572,7 +1576,7 @@ class AparteChatBubble extends HTMLElement {
1572
1576
  for (const segment of this._segments) {
1573
1577
  const renderer = resolveSegmentRenderer(segment.type, this._cfg);
1574
1578
  if (renderer) {
1575
- const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)));
1579
+ const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)), segment);
1576
1580
  if (el2) {
1577
1581
  this._segmentsEl.appendChild(el2);
1578
1582
  runWithConfig(this._cfg, () => renderer.setup?.(el2, segment));
@@ -1631,18 +1635,18 @@ class AparteChatBubble extends HTMLElement {
1631
1635
  const customAttachment = this._cfg.getAttachmentRenderer?.();
1632
1636
  if (customAttachment) {
1633
1637
  this._attachmentsEl.replaceChildren();
1634
- for (const a2 of this._attachments) {
1635
- const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => customAttachment(a2)));
1638
+ for (const a4 of this._attachments) {
1639
+ const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => customAttachment(a4)));
1636
1640
  if (el2) this._attachmentsEl.appendChild(el2);
1637
1641
  }
1638
1642
  return;
1639
1643
  }
1640
- this._attachmentsEl.innerHTML = this._attachments.map((a2) => {
1641
- const name = escapeHtml(a2.name);
1642
- if (a2.type.startsWith("image/")) {
1643
- return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${escapeHtml(a2.url)}" alt="${name}" loading="lazy" /><span class="aparte-thumb__name">${name}</span></div>`;
1644
+ this._attachmentsEl.innerHTML = this._attachments.map((a4) => {
1645
+ const name = escapeHtml(a4.name);
1646
+ if (a4.type.startsWith("image/")) {
1647
+ return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${escapeHtml(a4.url)}" alt="${name}" loading="lazy" /><span class="aparte-thumb__name">${name}</span></div>`;
1644
1648
  }
1645
- return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${escapeHtml(this._fileExt(a2.name))}</span><span class="aparte-thumb__name">${name}</span></div>`;
1649
+ return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${escapeHtml(this._fileExt(a4.name))}</span><span class="aparte-thumb__name">${name}</span></div>`;
1646
1650
  }).join("");
1647
1651
  if (!this._cfg.getHostHandlers().attachmentPreview) return;
1648
1652
  this._attachmentsEl.querySelectorAll(".aparte-thumb--image").forEach((tile) => {
@@ -1658,10 +1662,10 @@ class AparteChatBubble extends HTMLElement {
1658
1662
  }));
1659
1663
  };
1660
1664
  tile.addEventListener("click", open);
1661
- tile.addEventListener("keydown", (e2) => {
1662
- const key = e2.key;
1665
+ tile.addEventListener("keydown", (e) => {
1666
+ const key = e.key;
1663
1667
  if (key !== "Enter" && key !== " ") return;
1664
- e2.preventDefault();
1668
+ e.preventDefault();
1665
1669
  open();
1666
1670
  });
1667
1671
  });
@@ -1707,6 +1711,33 @@ class AparteChatBubble extends HTMLElement {
1707
1711
  detail
1708
1712
  }));
1709
1713
  };
1714
+ /**
1715
+ * A link the MODEL wrote is about to be followed. Announced as a cancelable event
1716
+ * before the browser acts, so a host can route it — an external browser, a
1717
+ * confirmation, an embedded view — without intercepting the DOM (issue #38). With
1718
+ * no listener the browser follows it, which the sanitizer has already made open in
1719
+ * a new tab for an external URL; `preventDefault()` on the event cancels the click.
1720
+ *
1721
+ * Only anchors inside the message body: the action bar and the branch picker are
1722
+ * buttons, and an attachment tile is its own event.
1723
+ */
1724
+ _onLinkClick = (event) => {
1725
+ const target = event.target;
1726
+ const anchor = target?.closest?.("a[href]");
1727
+ if (!anchor || !this.contains(anchor)) return;
1728
+ const detail = {
1729
+ href: anchor.getAttribute("href") ?? "",
1730
+ anchor,
1731
+ messageId: this.getAttribute("message-id")
1732
+ };
1733
+ const announced = this.dispatchEvent(new CustomEvent("aparte-link-click", {
1734
+ bubbles: true,
1735
+ composed: true,
1736
+ cancelable: true,
1737
+ detail
1738
+ }));
1739
+ if (!announced) event.preventDefault();
1740
+ };
1710
1741
  /**
1711
1742
  * Whether a reply is streaming somewhere in this bubble's transcript. Set by the
1712
1743
  * viewport (`data-busy` on it, pushed here), read once on connect for a bubble
@@ -1771,14 +1802,14 @@ class AparteChatBubble extends HTMLElement {
1771
1802
  const buttons = [];
1772
1803
  if (this._role === "user") {
1773
1804
  if (config.user) {
1774
- for (const a2 of config.user) buttons.push(this._actionButtonHtml(a2, icons, locale));
1805
+ for (const a4 of config.user) buttons.push(this._actionButtonHtml(a4, icons, locale));
1775
1806
  } else {
1776
1807
  if (config.copy) buttons.push(this._actionButtonHtml("copy", icons, locale));
1777
1808
  if (config.edit) buttons.push(this._actionButtonHtml("edit", icons, locale));
1778
1809
  }
1779
1810
  } else if (this._role === "assistant") {
1780
1811
  if (config.assistant) {
1781
- for (const a2 of config.assistant) buttons.push(this._actionButtonHtml(a2, icons, locale));
1812
+ for (const a4 of config.assistant) buttons.push(this._actionButtonHtml(a4, icons, locale));
1782
1813
  } else {
1783
1814
  if (config.copy) buttons.push(this._actionButtonHtml("copy", icons, locale));
1784
1815
  if (config.retry) buttons.push(this._actionButtonHtml("retry", icons, locale));
@@ -1793,10 +1824,71 @@ class AparteChatBubble extends HTMLElement {
1793
1824
  this._appendCustomActions(icons);
1794
1825
  this._applyTranscriptBusy();
1795
1826
  this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
1796
- btn.addEventListener("click", (e2) => this._handleActionClick(e2));
1827
+ btn.addEventListener("click", (e) => this._handleActionClick(e));
1797
1828
  });
1798
1829
  this._syncFooterVisibility();
1799
1830
  }
1831
+ /**
1832
+ * `role="toolbar"` promises a keyboard model. This is it.
1833
+ *
1834
+ * The bar has announced itself as a toolbar since it existed, and a toolbar is ONE
1835
+ * tab stop whose members are reached with the arrow keys — that is the whole of what
1836
+ * the role means to a screen-reader user. What shipped was five independent tab
1837
+ * stops per message, so the role described a behaviour that did not exist: a reader
1838
+ * told to press Right heard nothing move, and tabbing through a long transcript
1839
+ * walked every button of every message.
1840
+ *
1841
+ * The roving index is re-applied from `_syncFooterVisibility`, which is the single
1842
+ * funnel all three build paths already run through (the flag-driven bar, the edit
1843
+ * bar, and the branch picker's own visibility pass). Re-deriving it there rather
1844
+ * than in each builder is what keeps the invariant true after a `setBubbleActions`
1845
+ * rebuild — the drift this pattern usually dies of.
1846
+ *
1847
+ * DISABLED buttons are skipped, not merely un-tabbable: while the transcript is busy
1848
+ * retry and edit are disabled, and a toolbar whose arrows stop on a dead control
1849
+ * reads as broken. If every button is disabled the bar keeps one tab stop anyway, so
1850
+ * focus sitting there when the turn ends is not thrown to the top of the page.
1851
+ */
1852
+ _rovingButtons() {
1853
+ if (!this._actionBarEl) return [];
1854
+ return [...this._actionBarEl.querySelectorAll("button")];
1855
+ }
1856
+ _setRovingIndex() {
1857
+ const buttons = this._rovingButtons();
1858
+ if (!buttons.length) return;
1859
+ const usable = buttons.filter((b2) => !b2.disabled);
1860
+ const current = buttons.find((b2) => b2.tabIndex === 0 && !b2.disabled);
1861
+ const stop = current ?? usable[0] ?? buttons[0];
1862
+ for (const button of buttons) button.tabIndex = button === stop ? 0 : -1;
1863
+ }
1864
+ /**
1865
+ * Left/Right walk the bar, Home/End jump to its ends, and both wrap.
1866
+ *
1867
+ * Arrow keys follow the READING direction, per the ARIA practices: in an RTL
1868
+ * transcript Left is "next". `dir` is resolved from the nearest ancestor that sets
1869
+ * it — the viewport writes it from `locale.direction` — because a bubble has none of
1870
+ * its own and `getComputedStyle` is not something jsdom can answer.
1871
+ */
1872
+ _onActionBarKeydown = (e) => {
1873
+ const event = e;
1874
+ const keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
1875
+ if (!keys.includes(event.key)) return;
1876
+ const buttons = this._rovingButtons().filter((b2) => !b2.disabled);
1877
+ if (buttons.length === 0) return;
1878
+ const active = document.activeElement;
1879
+ const from = buttons.findIndex((b2) => b2 === active);
1880
+ if (from === -1 && event.key !== "Home" && event.key !== "End") return;
1881
+ const rtl = (this.closest("[dir]")?.getAttribute("dir") ?? document.documentElement.dir) === "rtl";
1882
+ const forward = rtl ? "ArrowLeft" : "ArrowRight";
1883
+ let next;
1884
+ if (event.key === "Home") next = 0;
1885
+ else if (event.key === "End") next = buttons.length - 1;
1886
+ else if (event.key === forward) next = (from + 1) % buttons.length;
1887
+ else next = (from - 1 + buttons.length) % buttons.length;
1888
+ event.preventDefault();
1889
+ for (const button of this._rovingButtons()) button.tabIndex = button === buttons[next] ? 0 : -1;
1890
+ buttons[next]?.focus();
1891
+ };
1800
1892
  /**
1801
1893
  * An empty action bar is not a bar: with every action off it was still a
1802
1894
  * `role="toolbar"` with nothing in it (announced as such), and it still reserved
@@ -1806,6 +1898,7 @@ class AparteChatBubble extends HTMLElement {
1806
1898
  */
1807
1899
  _syncFooterVisibility() {
1808
1900
  if (this._actionBarEl) this._actionBarEl.hidden = this._actionBarEl.children.length === 0;
1901
+ this._setRovingIndex();
1809
1902
  if (!this._footerEl) return;
1810
1903
  const barEmpty = !this._actionBarEl || this._actionBarEl.hidden;
1811
1904
  const pickerHidden = !this._branchPickerEl || this._branchPickerEl.hidden;
@@ -1815,16 +1908,16 @@ class AparteChatBubble extends HTMLElement {
1815
1908
  /** Append the registered custom action buttons for this bubble's role. */
1816
1909
  _appendCustomActions(icons) {
1817
1910
  if (!this._actionBarEl) return;
1818
- for (const a2 of this._cfg.getActions("bubble")) {
1819
- const roles = a2.bubble?.roles ?? ["user", "assistant"];
1911
+ for (const a4 of this._cfg.getActions("bubble")) {
1912
+ const roles = a4.bubble?.roles ?? ["user", "assistant"];
1820
1913
  if (!roles.includes(this._role)) continue;
1821
1914
  const btn = document.createElement("button");
1822
1915
  btn.className = "aparte-btn aparte-btn--icon aparte-action-btn aparte-action-custom";
1823
- btn.dataset["action"] = `custom:${a2.id}`;
1824
- btn.setAttribute("aria-label", a2.label);
1825
- btn.setAttribute("title", a2.label);
1826
- const fromProvider = icons[a2.icon];
1827
- btn.innerHTML = a2.icon.startsWith("<") ? a2.icon : typeof fromProvider === "function" ? fromProvider() : a2.iconFallback ?? "";
1916
+ btn.dataset["action"] = `custom:${a4.id}`;
1917
+ btn.setAttribute("aria-label", a4.label);
1918
+ btn.setAttribute("title", a4.label);
1919
+ const fromProvider = icons[a4.icon];
1920
+ btn.innerHTML = a4.icon.startsWith("<") ? a4.icon : typeof fromProvider === "function" ? fromProvider() : a4.iconFallback ?? "";
1828
1921
  this._actionBarEl.appendChild(btn);
1829
1922
  }
1830
1923
  }
@@ -1868,12 +1961,12 @@ class AparteChatBubble extends HTMLElement {
1868
1961
  const cancelLabel = locale.editCancel ?? "Cancel";
1869
1962
  this._actionBarEl.innerHTML = `<button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-btn--success aparte-action-btn aparte-action-edit-save" data-action="edit-save" aria-label="${escapeAttr(saveLabel)}" title="${escapeAttr(saveLabel)}">${this._cfg.getIcon("check")}</button><button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-btn--danger aparte-action-btn aparte-action-edit-cancel" data-action="edit-cancel" aria-label="${escapeAttr(cancelLabel)}" title="${escapeAttr(cancelLabel)}">${this._cfg.getIcon("close")}</button>`;
1870
1963
  this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
1871
- btn.addEventListener("click", (e2) => this._handleActionClick(e2));
1964
+ btn.addEventListener("click", (e) => this._handleActionClick(e));
1872
1965
  });
1873
1966
  this._syncFooterVisibility();
1874
1967
  }
1875
- _handleActionClick(e2) {
1876
- const btn = e2.currentTarget;
1968
+ _handleActionClick(e) {
1969
+ const btn = e.currentTarget;
1877
1970
  const action = btn.dataset["action"];
1878
1971
  const messageId = this.getAttribute("message-id");
1879
1972
  if (action?.startsWith("custom:") && messageId) {
@@ -1890,7 +1983,7 @@ class AparteChatBubble extends HTMLElement {
1890
1983
  composed: true,
1891
1984
  detail
1892
1985
  }));
1893
- this._cfg.getActions("bubble").find((x) => x.id === actionId)?.onClick?.(e2);
1986
+ this._cfg.getActions("bubble").find((x) => x.id === actionId)?.onClick?.(e);
1894
1987
  return;
1895
1988
  }
1896
1989
  switch (action) {
@@ -1898,7 +1991,7 @@ class AparteChatBubble extends HTMLElement {
1898
1991
  const text = this._content || this._segments.filter((s2) => s2.type !== "thinking").map((s2) => s2.content ?? "").join("\n");
1899
1992
  const icons = this._cfg.getIconProvider();
1900
1993
  const locale = this._cfg.getLocale();
1901
- navigator.clipboard.writeText(text).then(() => {
1994
+ copyText(text).then(() => {
1902
1995
  btn.innerHTML = icons.check();
1903
1996
  btn.setAttribute("data-copied", "");
1904
1997
  const copiedLabel = locale.copied ?? locale.copy ?? "Copied";
@@ -1988,9 +2081,9 @@ class AparteChatBubble extends HTMLElement {
1988
2081
  this._contentEl.insertAdjacentElement("afterend", input);
1989
2082
  input.setValue(this._content);
1990
2083
  input.addEventListener("aparte-composer-submit", () => this._exitEditMode(true));
1991
- input.addEventListener("keydown", (e2) => {
1992
- if (e2.key === "Escape" && !e2.isComposing) {
1993
- e2.preventDefault();
2084
+ input.addEventListener("keydown", (e) => {
2085
+ if (e.key === "Escape" && !e.isComposing) {
2086
+ e.preventDefault();
1994
2087
  this._exitEditMode(false);
1995
2088
  }
1996
2089
  });
@@ -2086,8 +2179,8 @@ class AparteChatStatus extends HTMLElement {
2086
2179
  disconnectedCallback() {
2087
2180
  window.removeEventListener("aparte-config-change", this._onConfigChange);
2088
2181
  }
2089
- _onConfigChange = (e2) => {
2090
- const detail = e2.detail;
2182
+ _onConfigChange = (e) => {
2183
+ const detail = e.detail;
2091
2184
  if (detail?.config && detail.config !== this._cfg) return;
2092
2185
  this.innerHTML = "";
2093
2186
  this._render();
@@ -2215,6 +2308,12 @@ class AparteChatViewport extends HTMLElement {
2215
2308
  _readerInputAt = Number.NEGATIVE_INFINITY;
2216
2309
  /** A decrease inside this window after our own scroll, with no gesture, is the browser's. */
2217
2310
  _ownScrollWindowMs = 1e3;
2311
+ /** How long after a scroll of ours we keep re-anchoring while the layout churns.
2312
+ * Not `readonly`: a test shortens it to run past the window's end in a few frames. */
2313
+ _settleWindowMs = 400;
2314
+ /** Deadline of the settle in flight (0 = none). Pushed forward, never stacked. */
2315
+ _settleUntil = 0;
2316
+ _settleRafId = null;
2218
2317
  /** The keys that scroll a focused scroll container — the only keydowns that are a gesture. */
2219
2318
  _scrollKeys = /* @__PURE__ */ new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "]);
2220
2319
  _scrollThreshold = 50;
@@ -2257,12 +2356,43 @@ class AparteChatViewport extends HTMLElement {
2257
2356
  * render — so a chat already on screen never flipped to RTL until a reload.
2258
2357
  * Only OUR config: an instance-scoped change elsewhere must not touch us.
2259
2358
  */
2260
- _onConfigChange = (e2) => {
2261
- const detail = e2.detail;
2359
+ _onConfigChange = (e) => {
2360
+ const detail = e.detail;
2262
2361
  if (detail?.config && detail.config !== resolveConfig(this)) return;
2263
2362
  this._applyDirection();
2264
2363
  this._scrollBtn?.setAttribute("aria-label", resolveConfig(this).t("scrollToBottom"));
2364
+ const surface = this._frameworkManagedDOM ? this : this.querySelector(".aparte-viewport-container");
2365
+ surface?.setAttribute("aria-label", resolveConfig(this).t("transcript"));
2265
2366
  };
2367
+ /**
2368
+ * The transcript is a TAB STOP, with a name.
2369
+ *
2370
+ * A scrollable region that no element can hold focus in cannot be scrolled from
2371
+ * the keyboard in WebKit: Chromium and Firefox hand an unfocusable overflow box a
2372
+ * caret or a "scroller" tab stop of their own, Safari does not. So a plain-text
2373
+ * transcript — no links, no code blocks, nothing focusable inside — was unreadable
2374
+ * past the first screen for a Safari keyboard user, with no error and nothing on
2375
+ * screen to say so.
2376
+ *
2377
+ * `tabindex="0"` on the surface itself, in BOTH DOM modes. The framework mode
2378
+ * appeared to work already, and only by accident: the scroll button is inside the
2379
+ * host and stays tabbable while it is visually hidden, so Tab happened to land
2380
+ * somewhere that scrolled. A tab stop that exists because a hidden button happens
2381
+ * to sit there is not an affordance, it is a coincidence.
2382
+ *
2383
+ * The name ships WITH the tab stop rather than after it: a focusable `role="log"`
2384
+ * with no accessible name is announced as an unnamed region, which is a worse
2385
+ * answer than no tab stop at all. And the ROLE ships with both: a name on a
2386
+ * generic element is the same defect mirrored — `aria-label` is prohibited on an
2387
+ * element whose role resolves to none, which is what a bare custom element is. The
2388
+ * default mode already writes `role="log"` on the container before this runs, so
2389
+ * the guard here is what gives the framework-mode host the same declaration.
2390
+ */
2391
+ _nameScrollSurface(el2) {
2392
+ el2.setAttribute("tabindex", "0");
2393
+ if (!el2.hasAttribute("role")) el2.setAttribute("role", "log");
2394
+ el2.setAttribute("aria-label", resolveConfig(this).t("transcript"));
2395
+ }
2266
2396
  /** Mirror `locale.direction` onto the scroll container. */
2267
2397
  _applyDirection() {
2268
2398
  const container = this.querySelector(".aparte-viewport-container");
@@ -2389,7 +2519,18 @@ class AparteChatViewport extends HTMLElement {
2389
2519
  * framework's DOM, reads the attribute itself when it connects.
2390
2520
  */
2391
2521
  _syncBusy() {
2392
- const busy = this._streamingMessageId() !== null;
2522
+ if (this._frameworkManagedDOM) return;
2523
+ this.setTranscriptBusy(this._streamingMessageId() !== null);
2524
+ }
2525
+ _busy = false;
2526
+ /**
2527
+ * The transcript's read-only-while-streaming flag — `data-busy` on this element and
2528
+ * fanned out to the bubbles it holds. The vanilla path derives it from the
2529
+ * repository (`_syncBusy`); a framework host, whose messages live outside the
2530
+ * repository during a turn, writes it directly from its own streaming id.
2531
+ */
2532
+ setTranscriptBusy(busy) {
2533
+ this._busy = busy;
2393
2534
  this.toggleAttribute("data-busy", busy);
2394
2535
  for (const bubble of this.querySelectorAll("aparte-chat-bubble")) {
2395
2536
  bubble.setTranscriptBusy?.(busy);
@@ -2415,10 +2556,10 @@ class AparteChatViewport extends HTMLElement {
2415
2556
  this._notifyBubble(messageId, "addSegment", stamped);
2416
2557
  this._autoScroll();
2417
2558
  }
2418
- updateSegment(a2, b2, c2) {
2559
+ updateSegment(a4, b2, c2) {
2419
2560
  const clientForm = c2 === void 0 && typeof b2 === "object";
2420
- const messageId = clientForm ? this._activeMessageId() : a2;
2421
- const segmentId = clientForm ? a2 : b2;
2561
+ const messageId = clientForm ? this._activeMessageId() : a4;
2562
+ const segmentId = clientForm ? a4 : b2;
2422
2563
  const updates = clientForm ? b2 : c2;
2423
2564
  if (!messageId) return;
2424
2565
  const message = this._repo.getMessageById(messageId);
@@ -2431,10 +2572,10 @@ class AparteChatViewport extends HTMLElement {
2431
2572
  this._notifyBubble(messageId, "updateSegment", { segmentId, updates: stamped });
2432
2573
  }
2433
2574
  }
2434
- removeSegment(a2, b2) {
2575
+ removeSegment(a4, b2) {
2435
2576
  const clientForm = b2 === void 0;
2436
- const messageId = clientForm ? this._activeMessageId() : a2;
2437
- const segmentId = clientForm ? a2 : b2;
2577
+ const messageId = clientForm ? this._activeMessageId() : a4;
2578
+ const segmentId = clientForm ? a4 : b2;
2438
2579
  if (!messageId || !segmentId) return;
2439
2580
  const message = this._repo.getMessageById(messageId);
2440
2581
  if (message?.segments) {
@@ -2529,7 +2670,9 @@ class AparteChatViewport extends HTMLElement {
2529
2670
  * which both records the message and creates its bubble element.
2530
2671
  */
2531
2672
  addMessage(message) {
2532
- this._repo.addOrUpdateMessage(this._repo.headId, adoptMessageSegments({ ...message }));
2673
+ const stored = adoptMessageSegments({ ...message });
2674
+ if (isAwaitingReply(message)) stored.isStreaming = true;
2675
+ this._repo.addOrUpdateMessage(this._repo.headId, stored);
2533
2676
  this._pruneRenderedBubbles();
2534
2677
  this._syncBusy();
2535
2678
  this._autoScroll();
@@ -2562,6 +2705,7 @@ class AparteChatViewport extends HTMLElement {
2562
2705
  const bubble = document.createElement("aparte-chat-bubble");
2563
2706
  bubble.setAttribute("message-id", message.id);
2564
2707
  bubble.setAttribute("data-role", message.role);
2708
+ if (message.compaction) bubble.setAttribute("data-kind", "compaction");
2565
2709
  if (message.timestamp) bubble.setAttribute("timestamp", String(message.timestamp));
2566
2710
  if (message.content) bubble.setAttribute("content", message.content);
2567
2711
  if (isAwaitingReply(message)) {
@@ -2653,7 +2797,7 @@ class AparteChatViewport extends HTMLElement {
2653
2797
  * Triggers a full re-render of the active path.
2654
2798
  */
2655
2799
  navigateBranch(messageId, direction) {
2656
- if (this._streamingMessageId() !== null) return;
2800
+ if (this._busy) return;
2657
2801
  const siblings = this._repo.getBranches(messageId);
2658
2802
  const currentIdx = siblings.indexOf(messageId);
2659
2803
  if (currentIdx === -1) return;
@@ -2909,6 +3053,7 @@ class AparteChatViewport extends HTMLElement {
2909
3053
  const bubble = document.createElement("aparte-chat-bubble");
2910
3054
  bubble.setAttribute("message-id", message.id);
2911
3055
  bubble.setAttribute("data-role", message.role);
3056
+ if (message.compaction) bubble.setAttribute("data-kind", "compaction");
2912
3057
  if (message.timestamp) bubble.setAttribute("timestamp", String(message.timestamp));
2913
3058
  if (isAwaitingReply(message)) {
2914
3059
  bubble.setAttribute("streaming", "");
@@ -2968,6 +3113,7 @@ class AparteChatViewport extends HTMLElement {
2968
3113
  container.setAttribute("aria-live", "polite");
2969
3114
  container.setAttribute("aria-atomic", "false");
2970
3115
  container.setAttribute("aria-relevant", "additions");
3116
+ this._nameScrollSurface(container);
2971
3117
  const wrapper = document.createElement("div");
2972
3118
  wrapper.className = "aparte-messages-wrapper";
2973
3119
  while (this.firstChild) {
@@ -3013,6 +3159,7 @@ class AparteChatViewport extends HTMLElement {
3013
3159
  return;
3014
3160
  }
3015
3161
  this.classList.add("aparte-viewport--framework");
3162
+ this._nameScrollSurface(this);
3016
3163
  const scrollBtn = document.createElement("button");
3017
3164
  scrollBtn.className = "aparte-btn aparte-btn--surface aparte-btn--circle aparte-btn--lg aparte-scroll-btn aparte-scroll-btn--hidden";
3018
3165
  scrollBtn.setAttribute("type", "button");
@@ -3069,12 +3216,12 @@ class AparteChatViewport extends HTMLElement {
3069
3216
  * 1 run in 6: the rebuild's height churn moved scrollTop within the second after
3070
3217
  * the click, and the click made that look like the reader's).
3071
3218
  */
3072
- _noteReaderInput = (e2) => {
3073
- if (e2.type === "keydown" && !this._scrollKeys.has(e2.key)) return;
3074
- if (e2.type === "pointerdown") {
3219
+ _noteReaderInput = (e) => {
3220
+ if (e.type === "keydown" && !this._scrollKeys.has(e.key)) return;
3221
+ if (e.type === "pointerdown") {
3075
3222
  const container = this._container;
3076
3223
  if (!container) return;
3077
- const { clientX } = e2;
3224
+ const { clientX } = e;
3078
3225
  const rect = container.getBoundingClientRect();
3079
3226
  const rtl = getComputedStyle(container).direction === "rtl";
3080
3227
  const inGutter = rtl ? clientX <= rect.right - container.clientWidth : clientX >= rect.left + container.clientWidth;
@@ -3082,8 +3229,8 @@ class AparteChatViewport extends HTMLElement {
3082
3229
  }
3083
3230
  this._readerInputAt = performance.now();
3084
3231
  };
3085
- _onBranchNavigate = (e2) => {
3086
- const evt = e2;
3232
+ _onBranchNavigate = (e) => {
3233
+ const evt = e;
3087
3234
  evt.stopPropagation();
3088
3235
  this.navigateBranch(evt.detail.messageId, evt.detail.direction);
3089
3236
  };
@@ -3143,12 +3290,15 @@ class AparteChatViewport extends HTMLElement {
3143
3290
  const now = performance.now();
3144
3291
  const settlingOurs = drop <= churn + 2 && now - this._ownScrollAt < this._ownScrollWindowMs && now - this._readerInputAt >= this._ownScrollWindowMs;
3145
3292
  const readerWentUp = drop > 1 && !settlingOurs;
3293
+ const readerHandOnIt = drop > 1 && now - this._readerInputAt < this._ownScrollWindowMs;
3146
3294
  this._lastScrollTop = top;
3147
3295
  this._lastScrollHeight = height;
3148
- if (this._isAtBottom()) {
3296
+ if (this._isAtBottom() && !readerHandOnIt) {
3149
3297
  this._isAutoScrollEnabled = true;
3150
3298
  } else if (readerWentUp) {
3151
3299
  this._isAutoScrollEnabled = false;
3300
+ } else if (drop > 1 && settlingOurs && this._isAutoScrollEnabled) {
3301
+ this._settleAtBottom();
3152
3302
  }
3153
3303
  this._updateScrollButton();
3154
3304
  }
@@ -3156,7 +3306,7 @@ class AparteChatViewport extends HTMLElement {
3156
3306
  if (!this._container) return;
3157
3307
  this._ownScrollAt = performance.now();
3158
3308
  this._container.scrollTop = this._container.scrollHeight;
3159
- this._settleAtBottom(4);
3309
+ this._settleAtBottom();
3160
3310
  }
3161
3311
  /**
3162
3312
  * Confirm over the next few frames that we actually reached the bottom.
@@ -3179,19 +3329,38 @@ class AparteChatViewport extends HTMLElement {
3179
3329
  * A BOUNDED retry, not one corrective frame: a single frame lands on the same
3180
3330
  * stale layout and was measured leaving a wider gap than doing nothing. Bounded
3181
3331
  * so it always terminates; re-reads `_isAutoScrollEnabled` every frame so a
3182
- * reader who scrolls away mid-settle is left alone; stops as soon as the gap is
3183
- * closed, so the common case costs one frame that does nothing.
3332
+ * reader who scrolls away mid-settle is left alone.
3333
+ *
3334
+ * Bounded by TIME, and it does not stop at the first closed gap — both of those
3335
+ * were the second half of the same bug. It used to be four frames (~64ms on an
3336
+ * idle 60Hz machine) and to return permanently the frame the gap first closed.
3337
+ * A branch swap at the bottom of a long transcript on react-webkit falsified
3338
+ * both at once: the rebuild's scrollable max churned 891 -> 1091 -> 891, the gap
3339
+ * WAS closed for a frame against the tall layout, the chain returned, the height
3340
+ * fell back with WebKit holding `scrollTop` at 720 — and the transcript stood
3341
+ * 171px short with auto-follow still armed. A frame count is a proxy for time
3342
+ * that fails exactly on the slow engine, so the budget is real milliseconds now,
3343
+ * and a closed gap only ends the chain when the window is over. One chain, not
3344
+ * one per call: during a stream every token used to start its own.
3184
3345
  */
3185
- _settleAtBottom(framesLeft) {
3186
- if (framesLeft <= 0) return;
3187
- requestAnimationFrame(() => {
3188
- if (!this._container || !this._isAutoScrollEnabled) return;
3346
+ _settleAtBottom() {
3347
+ this._settleUntil = performance.now() + this._settleWindowMs;
3348
+ if (this._settleRafId !== null) return;
3349
+ const step = () => {
3350
+ this._settleRafId = null;
3351
+ if (!this.isConnected || !this._container || !this._isAutoScrollEnabled) {
3352
+ this._settleUntil = 0;
3353
+ return;
3354
+ }
3189
3355
  const max = this._container.scrollHeight - this._container.clientHeight;
3190
- if (max - this._container.scrollTop <= 1) return;
3191
- this._ownScrollAt = performance.now();
3192
- this._container.scrollTop = max;
3193
- this._settleAtBottom(framesLeft - 1);
3194
- });
3356
+ if (max - this._container.scrollTop > 1) {
3357
+ this._ownScrollAt = performance.now();
3358
+ this._container.scrollTop = max;
3359
+ }
3360
+ if (performance.now() < this._settleUntil) this._settleRafId = requestAnimationFrame(step);
3361
+ else this._settleUntil = 0;
3362
+ };
3363
+ this._settleRafId = requestAnimationFrame(step);
3195
3364
  }
3196
3365
  _smoothScrollToBottom() {
3197
3366
  if (!this._container) return;
@@ -3219,7 +3388,18 @@ class AparteChatViewport extends HTMLElement {
3219
3388
  * whatever a framework's render timing does in between.
3220
3389
  */
3221
3390
  _updateScrollButton() {
3222
- this._scrollBtn?.classList.toggle("aparte-scroll-btn--hidden", this._isAtBottom());
3391
+ const btn = this._scrollBtn;
3392
+ if (!btn) return;
3393
+ const hidden = this._isAtBottom();
3394
+ if (btn.classList.contains("aparte-scroll-btn--hidden") === hidden && btn.hasAttribute("tabindex") === hidden) return;
3395
+ btn.classList.toggle("aparte-scroll-btn--hidden", hidden);
3396
+ if (hidden) {
3397
+ btn.setAttribute("tabindex", "-1");
3398
+ btn.setAttribute("aria-hidden", "true");
3399
+ } else {
3400
+ btn.removeAttribute("tabindex");
3401
+ btn.removeAttribute("aria-hidden");
3402
+ }
3223
3403
  }
3224
3404
  /**
3225
3405
  * Recalculate the bottom spacer height so the last user message is always
@@ -3315,6 +3495,11 @@ class AparteChatViewport extends HTMLElement {
3315
3495
  cancelAnimationFrame(this._spacerRafId);
3316
3496
  this._spacerRafId = null;
3317
3497
  }
3498
+ if (this._settleRafId !== null) {
3499
+ cancelAnimationFrame(this._settleRafId);
3500
+ this._settleRafId = null;
3501
+ }
3502
+ this._settleUntil = 0;
3318
3503
  }
3319
3504
  }
3320
3505
  if (!customElements.get("aparte-chat-viewport")) {
@@ -3372,8 +3557,8 @@ class AparteComposer extends HTMLElement {
3372
3557
  return resolveConfig(this);
3373
3558
  }
3374
3559
  /** Only OUR config: a change on another chat's instance must not touch us. */
3375
- _onConfigChangeEvent = (e2) => {
3376
- const detail = e2.detail;
3560
+ _onConfigChangeEvent = (e) => {
3561
+ const detail = e.detail;
3377
3562
  if (detail?.config && detail.config !== this._cfg) return;
3378
3563
  this._onConfigChange();
3379
3564
  };
@@ -3526,9 +3711,8 @@ class AparteComposer extends HTMLElement {
3526
3711
  * are the user's state and not an action to offer — hiding them would look like
3527
3712
  * losing them; and the toolbar, because switching model still does something.
3528
3713
  *
3529
- * The send button is the part the PANEL decides, through `mode`. `'submit'` and
3530
- * `'advance'` keep it; `'none'` says this panel has no act for it and it is not
3531
- * drawn. That third value is what a panel whose options settle on the first click
3714
+ * The send button is the part the PANEL decides, through `mode`. `'submit'`
3715
+ * keeps it; `'none'` says this panel has no act for it and it is not drawn. That third value is what a panel whose options settle on the first click
3532
3716
  * needs — a single-choice question, an approval — and until it existed such a
3533
3717
  * panel left a permanently disabled button beside options that never routed
3534
3718
  * through it. Flip between them at any time with {@link setPanelSubmitEnabled},
@@ -3631,10 +3815,10 @@ class AparteComposer extends HTMLElement {
3631
3815
  /**
3632
3816
  * Update the send button's state while a panel is active.
3633
3817
  *
3634
- * `mode` moves with it because both change on the same event — answering the
3635
- * question you are on can enable the button AND turn it from "advance" into
3636
- * "submit" (when it was the last one), and two separate calls would flash a
3637
- * wrong icon between them.
3818
+ * `mode` moves with it because both can change on the same event — opening the
3819
+ * "Other…" field of a panel whose options settle on the click gives the button
3820
+ * an act it did not have and two separate calls would flash a wrong state
3821
+ * between them.
3638
3822
  */
3639
3823
  setPanelSubmitEnabled(enabled, mode) {
3640
3824
  if (!this._panelActive) return;
@@ -3742,8 +3926,8 @@ class AparteComposer extends HTMLElement {
3742
3926
  /** A window lifecycle event is for THIS composer when neither side is scoped
3743
3927
  * (single-instance broadcast) or the target ids match (multi-chat page).
3744
3928
  * Without this filter, streaming in one chat flips every composer's state. */
3745
- _isForThisComposer(e2) {
3746
- const evtTargetId = e2.detail?.targetId;
3929
+ _isForThisComposer(e) {
3930
+ const evtTargetId = e.detail?.targetId;
3747
3931
  return !evtTargetId || !this._ownTargetId() || evtTargetId === this._ownTargetId();
3748
3932
  }
3749
3933
  /**
@@ -3786,13 +3970,13 @@ class AparteComposer extends HTMLElement {
3786
3970
  }
3787
3971
  return void 0;
3788
3972
  }
3789
- _handleMessageStart(e2) {
3790
- if (!this._isForThisComposer(e2)) return;
3973
+ _handleMessageStart(e) {
3974
+ if (!this._isForThisComposer(e)) return;
3791
3975
  this._streaming = true;
3792
3976
  this._emit("streaming-change", { streaming: true });
3793
3977
  }
3794
- _handleMessageDone(e2) {
3795
- if (!this._isForThisComposer(e2)) return;
3978
+ _handleMessageDone(e) {
3979
+ if (!this._isForThisComposer(e)) return;
3796
3980
  this._streaming = false;
3797
3981
  this._emit("streaming-change", { streaming: false });
3798
3982
  if (this._panelActive) this._evictPanel();
@@ -3950,13 +4134,13 @@ class AparteComposerInput extends HTMLElement {
3950
4134
  const value = this.getValue();
3951
4135
  this._getRoot()?.setValue(value);
3952
4136
  }
3953
- _handleKeydown(e2) {
3954
- if (e2.isComposing || e2.keyCode === 229) return;
3955
- if (e2.key !== "Enter") return;
4137
+ _handleKeydown(e) {
4138
+ if (e.isComposing || e.keyCode === 229) return;
4139
+ if (e.key !== "Enter") return;
3956
4140
  const submitOnEnter = this._getRoot()?.submitOnEnter ?? true;
3957
- const submits = submitOnEnter ? !e2.shiftKey : e2.shiftKey;
4141
+ const submits = submitOnEnter ? !e.shiftKey : e.shiftKey;
3958
4142
  if (submits) {
3959
- e2.preventDefault();
4143
+ e.preventDefault();
3960
4144
  const root = this._getRoot();
3961
4145
  if (root) {
3962
4146
  if (root.streaming) return;
@@ -3965,7 +4149,7 @@ class AparteComposerInput extends HTMLElement {
3965
4149
  this.dispatchEvent(new CustomEvent("aparte-composer-submit", { bubbles: true }));
3966
4150
  }
3967
4151
  } else {
3968
- e2.preventDefault();
4152
+ e.preventDefault();
3969
4153
  if (!this._editor?.textContent) return;
3970
4154
  this.ownerDocument?.execCommand("insertLineBreak");
3971
4155
  }
@@ -3976,9 +4160,9 @@ class AparteComposerInput extends HTMLElement {
3976
4160
  _handleBlur() {
3977
4161
  this.classList.remove("aparte-is-focused");
3978
4162
  }
3979
- _handlePaste(e2) {
3980
- e2.preventDefault();
3981
- const cd = e2.clipboardData;
4163
+ _handlePaste(e) {
4164
+ e.preventDefault();
4165
+ const cd = e.clipboardData;
3982
4166
  if (!cd) return;
3983
4167
  const imageFile = Array.from(cd.items).find((i2) => i2.type.startsWith("image/"))?.getAsFile();
3984
4168
  if (imageFile) {
@@ -4128,14 +4312,14 @@ class AparteComposerSend extends HTMLElement {
4128
4312
  this._syncState();
4129
4313
  }
4130
4314
  /**
4131
- * Panel open: this one button now means "answer", and WHICH answer depends on
4132
- * where you are in the form.
4315
+ * Panel open: this one button now means "submit the answer".
4133
4316
  *
4134
4317
  * The icon has to move with the meaning: it drew a paper plane while the label
4135
4318
  * already said "Submit", so it read as "send a message" while it meant "answer
4136
- * this question". And a check on a form with three questions left was just as
4137
- * wrong hence a chevron while there is more ahead. The visual is what a user
4138
- * reads.
4319
+ * this question". It used to draw a chevron too, while a form had questions
4320
+ * ahead, and turn into a "Next" a second way to do what the panel's chips do,
4321
+ * and a button whose meaning changed under the pointer. It is disabled instead
4322
+ * until every question has an answer, which is what the chips' marks explain.
4139
4323
  */
4140
4324
  _syncPanelState() {
4141
4325
  const panel = this._panel;
@@ -4145,16 +4329,15 @@ class AparteComposerSend extends HTMLElement {
4145
4329
  this._button.disabled = true;
4146
4330
  return;
4147
4331
  }
4148
- const advancing = panel.mode === "advance";
4149
4332
  this._button.disabled = !panel.submitEnabled;
4150
- this._button.innerHTML = advancing ? cfg.getIcon("nextBranch") : this._getSubmitIcon();
4151
- const label = advancing ? cfg.t("elicitationNext") || "Next" : cfg.t("submitButton") || "Submit";
4333
+ this._button.innerHTML = this._getSubmitIcon();
4334
+ const label = cfg.t("submitButton") || "Submit";
4152
4335
  this._button.setAttribute("aria-label", label);
4153
4336
  this._button.setAttribute("title", label);
4154
4337
  this._button.classList.remove("aparte-is-streaming");
4155
4338
  }
4156
- _handleClick(e2) {
4157
- e2.preventDefault();
4339
+ _handleClick(e) {
4340
+ e.preventDefault();
4158
4341
  this._getRoot()?.submit();
4159
4342
  }
4160
4343
  _syncState() {
@@ -4241,8 +4424,8 @@ class AparteComposerCancel extends HTMLElement {
4241
4424
  );
4242
4425
  if (root.streaming && this._button) this._button.hidden = false;
4243
4426
  }
4244
- _handleClick(e2) {
4245
- e2.preventDefault();
4427
+ _handleClick(e) {
4428
+ e.preventDefault();
4246
4429
  this._getRoot()?.cancel();
4247
4430
  }
4248
4431
  /**
@@ -4299,19 +4482,21 @@ class AparteComposerAttachments extends HTMLElement {
4299
4482
  _render(files) {
4300
4483
  this.hidden = files.length === 0;
4301
4484
  this._revokeUrls();
4485
+ const cfg = resolveConfig(this);
4486
+ const removeLabel = (file) => (cfg.t("removeAttachment") || "Remove {name}").replace("{name}", file.name);
4302
4487
  this.innerHTML = files.map((file) => {
4303
- const name = this._escape(file.name);
4304
- const remove = `<button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-thumb__remove" type="button" aria-label="Remove ${name}">${resolveConfig(this).getIcon("close")}</button>`;
4488
+ const name = escapeHtml(file.name);
4489
+ const remove = `<button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-thumb__remove" type="button" aria-label="${escapeAttr(removeLabel(file))}">${cfg.getIcon("close")}</button>`;
4305
4490
  if (file.type.startsWith("image/")) {
4306
4491
  const url = URL.createObjectURL(file);
4307
4492
  this._objectUrls.push(url);
4308
- return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${escapeAttr(name)}"><img class="aparte-thumb__img" src="${escapeAttr(url)}" alt="${escapeAttr(name)}" /><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
4493
+ return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${escapeAttr(url)}" alt="${name}" /><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
4309
4494
  }
4310
- return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${escapeAttr(name)}"><span class="aparte-thumb__ext">${this._escape(this._ext(file.name))}</span><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
4495
+ return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${escapeHtml(this._ext(file.name))}</span><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
4311
4496
  }).join("");
4312
4497
  this.querySelectorAll(".aparte-thumb__remove").forEach((btn, i2) => {
4313
- btn.addEventListener("click", (e2) => {
4314
- e2.stopPropagation();
4498
+ btn.addEventListener("click", (e) => {
4499
+ e.stopPropagation();
4315
4500
  const root = this._getRoot();
4316
4501
  if (root) root.removeAttachment(root.attachments[i2]);
4317
4502
  });
@@ -4330,10 +4515,10 @@ class AparteComposerAttachments extends HTMLElement {
4330
4515
  }));
4331
4516
  };
4332
4517
  tile.addEventListener("click", open);
4333
- tile.addEventListener("keydown", (e2) => {
4334
- const key = e2.key;
4518
+ tile.addEventListener("keydown", (e) => {
4519
+ const key = e.key;
4335
4520
  if (key !== "Enter" && key !== " ") return;
4336
- e2.preventDefault();
4521
+ e.preventDefault();
4337
4522
  open();
4338
4523
  });
4339
4524
  });
@@ -4343,9 +4528,6 @@ class AparteComposerAttachments extends HTMLElement {
4343
4528
  const dot = filename.lastIndexOf(".");
4344
4529
  return dot > 0 ? filename.slice(dot + 1).toUpperCase().slice(0, 4) : "FILE";
4345
4530
  }
4346
- _escape(str) {
4347
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
4348
- }
4349
4531
  }
4350
4532
  if (!customElements.get("aparte-composer-attachments")) {
4351
4533
  customElements.define("aparte-composer-attachments", AparteComposerAttachments);
@@ -4443,24 +4625,24 @@ class AparteComposerAddAttachment extends HTMLElement {
4443
4625
  _setupDragDrop() {
4444
4626
  const root = this._getRoot();
4445
4627
  if (!root) return;
4446
- const prevent = (e2) => {
4447
- e2.preventDefault();
4448
- e2.stopPropagation();
4628
+ const prevent = (e) => {
4629
+ e.preventDefault();
4630
+ e.stopPropagation();
4449
4631
  };
4450
- const onDragOver = (e2) => {
4632
+ const onDragOver = (e) => {
4451
4633
  if (root.disabled) return;
4452
- prevent(e2);
4634
+ prevent(e);
4453
4635
  root.classList.add("aparte-is-dragover");
4454
4636
  };
4455
- const onDragLeave = (e2) => {
4456
- prevent(e2);
4637
+ const onDragLeave = (e) => {
4638
+ prevent(e);
4457
4639
  root.classList.remove("aparte-is-dragover");
4458
4640
  };
4459
- const onDrop = (e2) => {
4460
- prevent(e2);
4641
+ const onDrop = (e) => {
4642
+ prevent(e);
4461
4643
  root.classList.remove("aparte-is-dragover");
4462
4644
  if (root.disabled) return;
4463
- const files = e2.dataTransfer?.files;
4645
+ const files = e.dataTransfer?.files;
4464
4646
  if (files?.length) this._getRoot()?.addAttachments(files);
4465
4647
  };
4466
4648
  root.addEventListener("dragover", onDragOver);
@@ -4591,11 +4773,16 @@ class AparteComposerToolbar extends HTMLElement {
4591
4773
  if (!customElements.get("aparte-composer-toolbar")) {
4592
4774
  customElements.define("aparte-composer-toolbar", AparteComposerToolbar);
4593
4775
  }
4776
+ const DAY = 864e5;
4777
+ const MENU_GAP = 4;
4778
+ const VIEWPORT_MARGIN = 8;
4594
4779
  class AparteConversationList extends HTMLElement {
4595
4780
  _conversations = [];
4596
4781
  _activeId = null;
4782
+ _open = null;
4783
+ _renaming = null;
4597
4784
  static get observedAttributes() {
4598
- return ["active-id"];
4785
+ return ["active-id", "no-groups"];
4599
4786
  }
4600
4787
  // ─── Lifecycle ────────────────────────────────────────────────────────
4601
4788
  connectedCallback() {
@@ -4605,10 +4792,17 @@ class AparteConversationList extends HTMLElement {
4605
4792
  if (!this.getAttribute("role")) {
4606
4793
  this.setAttribute("role", "navigation");
4607
4794
  }
4795
+ this.addEventListener("click", this._onClick);
4796
+ this.addEventListener("keydown", this._onKeydown);
4797
+ this.addEventListener("focusout", this._onFocusOut);
4608
4798
  this._render();
4609
4799
  window.addEventListener("aparte-config-change", this._onConfigChange);
4610
4800
  }
4611
4801
  disconnectedCallback() {
4802
+ this._closeMenu();
4803
+ this.removeEventListener("click", this._onClick);
4804
+ this.removeEventListener("keydown", this._onKeydown);
4805
+ this.removeEventListener("focusout", this._onFocusOut);
4612
4806
  window.removeEventListener("aparte-config-change", this._onConfigChange);
4613
4807
  }
4614
4808
  attributeChangedCallback(name, oldValue, newValue) {
@@ -4616,15 +4810,18 @@ class AparteConversationList extends HTMLElement {
4616
4810
  if (name === "active-id") {
4617
4811
  this._activeId = newValue;
4618
4812
  this._updateActiveState();
4813
+ } else if (name === "no-groups" && this.isConnected) {
4814
+ this._render();
4619
4815
  }
4620
4816
  }
4621
4817
  /**
4622
- * Re-render on a locale switch: every row's title fallback and both button
4623
- * labels come from the locale, so without this the list stayed in the previous
4624
- * language until something else happened to re-render it. Only OUR config.
4818
+ * Re-render on a locale switch: every row's title fallback, the `⋯` button's name
4819
+ * and the group headings come from the locale, so without this the list stayed in
4820
+ * the previous language until something else happened to re-render it. Only OUR
4821
+ * config.
4625
4822
  */
4626
- _onConfigChange = (e2) => {
4627
- const detail = e2.detail;
4823
+ _onConfigChange = (e) => {
4824
+ const detail = e.detail;
4628
4825
  if (detail?.config && detail.config !== resolveConfig(this)) return;
4629
4826
  this._render();
4630
4827
  };
@@ -4639,94 +4836,387 @@ class AparteConversationList extends HTMLElement {
4639
4836
  }
4640
4837
  // ─── Rendering ────────────────────────────────────────────────────────
4641
4838
  _render() {
4642
- this.innerHTML = this._conversations.map((conv) => this._renderItem(conv)).join("");
4643
- this._bindEvents();
4839
+ this._closeMenu();
4840
+ this._renaming = null;
4841
+ this.innerHTML = this._groups().map((g2) => this._renderGroup(g2)).join("");
4644
4842
  }
4645
- _renderItem(conv) {
4843
+ _t(key) {
4646
4844
  const locale = resolveConfig(this).getLocale();
4845
+ return locale[key] ?? APARTE_DEFAULT_LOCALE[key] ?? "";
4846
+ }
4847
+ /**
4848
+ * Pinned first, then the calendar buckets every chat product uses, then a heading
4849
+ * per month (newest first), then whatever carries no date. Flat when nothing is
4850
+ * dated or the host asked for `no-groups`: a heading over the only group would say
4851
+ * nothing.
4852
+ */
4853
+ _groups() {
4854
+ const items = this._conversations;
4855
+ if (this.hasAttribute("no-groups") || !items.some((c2) => typeof c2.updatedAt === "number")) {
4856
+ return [{ key: "all", label: null, items }];
4857
+ }
4858
+ const now = /* @__PURE__ */ new Date();
4859
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
4860
+ const tag = resolveConfig(this).getLocale().tag;
4861
+ const buckets = /* @__PURE__ */ new Map();
4862
+ const push = (key, label, item) => {
4863
+ let g2 = buckets.get(key);
4864
+ if (!g2) {
4865
+ g2 = { key, label, items: [] };
4866
+ buckets.set(key, g2);
4867
+ }
4868
+ g2.items.push(item);
4869
+ };
4870
+ for (const c2 of items) {
4871
+ if (c2.pinnedAt) {
4872
+ push("pinned", this._t("conversationGroupPinned"), c2);
4873
+ continue;
4874
+ }
4875
+ const at = c2.updatedAt;
4876
+ if (typeof at !== "number") {
4877
+ push("undated", null, c2);
4878
+ continue;
4879
+ }
4880
+ if (at >= today) push("today", this._t("conversationGroupToday"), c2);
4881
+ else if (at >= today - DAY) push("yesterday", this._t("conversationGroupYesterday"), c2);
4882
+ else if (at >= today - 7 * DAY) push("week", this._t("conversationGroupWeek"), c2);
4883
+ else if (at >= today - 30 * DAY) push("month", this._t("conversationGroupMonth"), c2);
4884
+ else {
4885
+ const d2 = new Date(at);
4886
+ const sameYear = d2.getFullYear() === now.getFullYear();
4887
+ const raw = new Intl.DateTimeFormat(tag, sameYear ? { month: "long" } : { month: "long", year: "numeric" }).format(d2);
4888
+ const label = raw.charAt(0).toLocaleUpperCase(tag) + raw.slice(1);
4889
+ push(`m-${d2.getFullYear()}-${String(d2.getMonth()).padStart(2, "0")}`, label, c2);
4890
+ }
4891
+ }
4892
+ const fixed = ["pinned", "today", "yesterday", "week", "month"].map((k2) => buckets.get(k2)).filter((g2) => !!g2);
4893
+ const months = [...buckets.keys()].filter((k2) => k2.startsWith("m-")).sort((a4, b2) => b2.localeCompare(a4)).map((k2) => buckets.get(k2));
4894
+ const undated = buckets.get("undated");
4895
+ return [...fixed, ...months, ...undated ? [undated] : []];
4896
+ }
4897
+ _renderGroup(group) {
4898
+ const rows = group.items.map((conv) => this._renderItem(conv)).join("");
4899
+ if (group.label === null) {
4900
+ return `<div class="aparte-conv-group">${rows}</div>`;
4901
+ }
4902
+ const label = escapeHtml(group.label);
4903
+ return `
4904
+ <div class="aparte-conv-group" role="group" aria-label="${escapeAttr(group.label)}">
4905
+ <div class="aparte-menu__label aparte-conv-group__label" aria-hidden="true">${label}</div>
4906
+ ${rows}
4907
+ </div>`;
4908
+ }
4909
+ _renderItem(conv) {
4647
4910
  const isActive = conv.id === this._activeId;
4648
- const isArchived = !!conv.archivedAt;
4649
- const activeClass = isActive ? " aparte-conv-item--active" : "";
4650
- const archivedClass = isArchived ? " aparte-conv-item--archived" : "";
4651
- const escapedId = this._esc(conv.id);
4652
- const escapedTitle = this._esc(conv.title || locale.newChat);
4653
- const deleteLabel = this._esc(locale.deleteConversation);
4654
- const archiveLabel = this._esc(locale["archiveConversation"] ?? "Archive conversation");
4655
- const unarchiveLabel = this._esc(locale["unarchiveConversation"] ?? "Unarchive conversation");
4656
- const archiveAction = isArchived ? "unarchive" : "archive";
4657
- const archiveAriaLabel = isArchived ? unarchiveLabel : archiveLabel;
4658
- const archiveGlyph = resolveConfig(this).getIcon(isArchived ? "unarchive" : "archive");
4911
+ const classes = [
4912
+ "aparte-menu__item aparte-conv-item",
4913
+ isActive ? "aparte-conv-item--active" : "",
4914
+ conv.archivedAt ? "aparte-conv-item--archived" : "",
4915
+ conv.pinnedAt ? "aparte-conv-item--pinned" : ""
4916
+ ].filter(Boolean).join(" ");
4917
+ const escapedId = escapeAttr(conv.id);
4918
+ const title = escapeHtml(conv.title || this._t("newChat"));
4919
+ const actionsLabel = escapeAttr(this._t("conversationActions"));
4920
+ const moreGlyph = resolveConfig(this).getIcon("more");
4659
4921
  return `
4660
- <div
4661
- class="aparte-menu__item aparte-conv-item${activeClass}${archivedClass}"
4662
- role="button"
4663
- tabindex="0"
4664
- data-conv-id="${escapedId}"
4665
- aria-current="${isActive ? "page" : "false"}"
4666
- >
4667
- <span class="aparte-conv-item__title">${escapedTitle}</span>
4922
+ <div class="${escapeAttr(classes)}" data-conv-id="${escapedId}">
4668
4923
  <button
4669
- class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-conv-item__archive"
4924
+ class="aparte-conv-item__select"
4670
4925
  type="button"
4671
- data-archive-id="${escapedId}"
4672
- data-archive-action="${escapeAttr(archiveAction)}"
4673
- aria-label="${escapeAttr(archiveAriaLabel)}"
4674
- tabindex="0"
4675
- >${archiveGlyph}</button>
4926
+ data-select-id="${escapedId}"
4927
+ aria-current="${isActive ? "page" : "false"}"
4928
+ ><span class="aparte-conv-item__title">${title}</span></button>
4676
4929
  <button
4677
- class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-conv-item__delete"
4930
+ class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-conv-item__more"
4678
4931
  type="button"
4679
- data-delete-id="${escapedId}"
4680
- aria-label="${deleteLabel}"
4681
- tabindex="0"
4682
- >
4683
- ${resolveConfig(this).getIcon("close")}
4684
- </button>
4932
+ data-more-id="${escapedId}"
4933
+ aria-label="${actionsLabel}"
4934
+ title="${actionsLabel}"
4935
+ aria-haspopup="menu"
4936
+ aria-expanded="false"
4937
+ >${moreGlyph}</button>
4685
4938
  </div>`;
4686
4939
  }
4687
- _bindEvents() {
4688
- this.addEventListener("click", this._onClick);
4689
- this.addEventListener("keydown", this._onKeydown);
4940
+ // ─── The row's menu ───────────────────────────────────────────────────
4941
+ _menuMarkup(conv) {
4942
+ const config = resolveConfig(this);
4943
+ const editGlyph = config.getIcon("edit");
4944
+ const pinGlyph = config.getIcon("pin");
4945
+ const archiveGlyph = config.getIcon(conv.archivedAt ? "unarchive" : "archive");
4946
+ const trashGlyph = config.getIcon("trash");
4947
+ const rename = escapeHtml(this._t("renameConversation"));
4948
+ const pin = escapeHtml(this._t(conv.pinnedAt ? "unpinConversation" : "pinConversation"));
4949
+ const archive = escapeHtml(this._t(conv.archivedAt ? "unarchiveConversation" : "archiveConversation"));
4950
+ const remove = escapeHtml(this._t("deleteConversation"));
4951
+ return `
4952
+ <button type="button" class="aparte-menu__item" role="menuitem" data-menu-action="rename">${editGlyph}<span>${rename}</span></button>
4953
+ <button type="button" class="aparte-menu__item" role="menuitem" data-menu-action="pin">${pinGlyph}<span>${pin}</span></button>
4954
+ <button type="button" class="aparte-menu__item" role="menuitem" data-menu-action="archive">${archiveGlyph}<span>${archive}</span></button>
4955
+ <div class="aparte-menu__separator" role="separator"></div>
4956
+ <button type="button" class="aparte-menu__item aparte-conv-menu__item--danger" role="menuitem" data-menu-action="delete">${trashGlyph}<span>${remove}</span></button>`;
4957
+ }
4958
+ _confirmMarkup(conv) {
4959
+ const question = escapeHtml(this._t("deleteConversationConfirm").replace("{title}", conv.title || this._t("newChat")));
4960
+ const cancel = escapeHtml(this._t("cancel"));
4961
+ const remove = escapeHtml(this._t("deleteConversation"));
4962
+ return `
4963
+ <div class="aparte-conv-menu__confirm" role="group" aria-label="${escapeAttr(question)}">
4964
+ <p class="aparte-conv-menu__question">${question}</p>
4965
+ <div class="aparte-conv-menu__actions">
4966
+ <button type="button" class="aparte-btn aparte-btn--sm aparte-btn--ghost" data-menu-action="cancel">${cancel}</button>
4967
+ <button type="button" class="aparte-btn aparte-btn--sm aparte-btn--danger aparte-btn--solid" data-menu-action="confirm-delete">${remove}</button>
4968
+ </div>
4969
+ </div>`;
4690
4970
  }
4691
- _onClick = (e2) => {
4692
- const target = e2.target;
4693
- const archiveBtn = target.closest("[data-archive-id]");
4694
- if (archiveBtn) {
4695
- e2.stopPropagation();
4696
- const id = archiveBtn.dataset["archiveId"];
4697
- const action = archiveBtn.dataset["archiveAction"];
4698
- const eventName = action === "unarchive" ? "aparte-unarchive-conversation" : "aparte-archive-conversation";
4699
- this.dispatchEvent(new CustomEvent(
4700
- eventName,
4701
- { detail: { id }, bubbles: true, composed: true }
4702
- ));
4971
+ _openMenu(row, button) {
4972
+ this._closeMenu();
4973
+ const id = row.dataset["convId"];
4974
+ const conv = this._conversations.find((c2) => c2.id === id);
4975
+ if (!conv) return;
4976
+ const menu = document.createElement("div");
4977
+ menu.className = "aparte-menu aparte-conv-menu";
4978
+ menu.setAttribute("role", "menu");
4979
+ menu.setAttribute("aria-label", conv.title || this._t("newChat"));
4980
+ menu.innerHTML = this._menuMarkup(conv);
4981
+ row.appendChild(menu);
4982
+ button.setAttribute("aria-expanded", "true");
4983
+ this._open = { id, row, button, menu };
4984
+ this._placeMenu(button, menu);
4985
+ menu.querySelector('[role="menuitem"]')?.focus();
4986
+ document.addEventListener("pointerdown", this._onDocumentPointerDown, true);
4987
+ window.addEventListener("scroll", this._onWindowScroll, true);
4988
+ window.addEventListener("resize", this._onWindowScroll);
4989
+ }
4990
+ /**
4991
+ * Fixed, below the button's end edge, flipped above it when the viewport ends
4992
+ * first. Fixed rather than absolute because a sidebar scrolls: an absolute menu on
4993
+ * the last rows would be clipped by the list's own overflow. No anchoring library —
4994
+ * the menu closes on any scroll or resize, which is what makes the arithmetic
4995
+ * safe to do once.
4996
+ */
4997
+ _placeMenu(button, menu) {
4998
+ const r = button.getBoundingClientRect();
4999
+ const m2 = menu.getBoundingClientRect();
5000
+ let top = r.bottom + MENU_GAP;
5001
+ if (top + m2.height > window.innerHeight - VIEWPORT_MARGIN && r.top - m2.height - MENU_GAP >= VIEWPORT_MARGIN) {
5002
+ top = r.top - m2.height - MENU_GAP;
5003
+ }
5004
+ const rtl = getComputedStyle(this).direction === "rtl";
5005
+ let left = rtl ? r.left : r.right - m2.width;
5006
+ left = Math.max(VIEWPORT_MARGIN, Math.min(left, window.innerWidth - m2.width - VIEWPORT_MARGIN));
5007
+ menu.style.top = `${top}px`;
5008
+ menu.style.left = `${left}px`;
5009
+ }
5010
+ _closeMenu(returnFocus = false) {
5011
+ const open = this._open;
5012
+ if (!open) return;
5013
+ this._open = null;
5014
+ open.menu.remove();
5015
+ open.button.setAttribute("aria-expanded", "false");
5016
+ document.removeEventListener("pointerdown", this._onDocumentPointerDown, true);
5017
+ window.removeEventListener("scroll", this._onWindowScroll, true);
5018
+ window.removeEventListener("resize", this._onWindowScroll);
5019
+ if (returnFocus && open.button.isConnected) open.button.focus();
5020
+ }
5021
+ _onDocumentPointerDown = (e) => {
5022
+ const open = this._open;
5023
+ if (!open) return;
5024
+ const target = e.target;
5025
+ if (open.menu.contains(target) || open.button.contains(target)) return;
5026
+ this._closeMenu();
5027
+ };
5028
+ _onWindowScroll = () => {
5029
+ this._closeMenu();
5030
+ };
5031
+ _onFocusOut = (e) => {
5032
+ const open = this._open;
5033
+ if (!open) return;
5034
+ const next = e.relatedTarget;
5035
+ if (next && (open.menu.contains(next) || open.button.contains(next))) return;
5036
+ if (open.menu.contains(e.target)) this._closeMenu();
5037
+ };
5038
+ _onMenuAction(action) {
5039
+ const open = this._open;
5040
+ if (!open) return;
5041
+ const conv = this._conversations.find((c2) => c2.id === open.id);
5042
+ if (!conv) {
5043
+ this._closeMenu();
4703
5044
  return;
4704
5045
  }
4705
- const deleteBtn = target.closest("[data-delete-id]");
4706
- if (deleteBtn) {
4707
- e2.stopPropagation();
4708
- const id = deleteBtn.dataset["deleteId"];
5046
+ const id = conv.id;
5047
+ switch (action) {
5048
+ case "rename":
5049
+ this._closeMenu();
5050
+ this._startRename(open.row, conv);
5051
+ return;
5052
+ // The two toggles dispatch each name as a LITERAL, in its own branch: the
5053
+ // manifest analyzer records a dispatch whose name is a ternary as an event
5054
+ // with no name (the Angular generator then crashes on it), and one whose
5055
+ // name is a local as an event called after the local.
5056
+ case "pin":
5057
+ this._closeMenu(true);
5058
+ if (conv.pinnedAt) {
5059
+ this.dispatchEvent(new CustomEvent(
5060
+ "aparte-unpin-conversation",
5061
+ { detail: { id }, bubbles: true, composed: true }
5062
+ ));
5063
+ } else {
5064
+ this.dispatchEvent(new CustomEvent(
5065
+ "aparte-pin-conversation",
5066
+ { detail: { id }, bubbles: true, composed: true }
5067
+ ));
5068
+ }
5069
+ return;
5070
+ case "archive":
5071
+ this._closeMenu(true);
5072
+ if (conv.archivedAt) {
5073
+ this.dispatchEvent(new CustomEvent(
5074
+ "aparte-unarchive-conversation",
5075
+ { detail: { id }, bubbles: true, composed: true }
5076
+ ));
5077
+ } else {
5078
+ this.dispatchEvent(new CustomEvent(
5079
+ "aparte-archive-conversation",
5080
+ { detail: { id }, bubbles: true, composed: true }
5081
+ ));
5082
+ }
5083
+ return;
5084
+ case "delete":
5085
+ open.menu.innerHTML = this._confirmMarkup(conv);
5086
+ this._placeMenu(open.button, open.menu);
5087
+ open.menu.querySelector('[data-menu-action="cancel"]')?.focus();
5088
+ return;
5089
+ case "cancel":
5090
+ this._closeMenu(true);
5091
+ return;
5092
+ case "confirm-delete":
5093
+ this._closeMenu(true);
5094
+ this.dispatchEvent(new CustomEvent(
5095
+ "aparte-delete-conversation",
5096
+ { detail: { id }, bubbles: true, composed: true }
5097
+ ));
5098
+ return;
5099
+ }
5100
+ }
5101
+ _menuFocusables() {
5102
+ const open = this._open;
5103
+ if (!open) return [];
5104
+ return Array.from(open.menu.querySelectorAll('[role="menuitem"], [data-menu-action]'));
5105
+ }
5106
+ _onMenuKeydown(e) {
5107
+ const items = this._menuFocusables();
5108
+ const index = items.indexOf(document.activeElement);
5109
+ const focusAt = (i2) => {
5110
+ e.preventDefault();
5111
+ items[(i2 + items.length) % items.length]?.focus();
5112
+ };
5113
+ switch (e.key) {
5114
+ case "Escape":
5115
+ e.preventDefault();
5116
+ this._closeMenu(true);
5117
+ return;
5118
+ case "Tab":
5119
+ e.preventDefault();
5120
+ this._closeMenu(true);
5121
+ return;
5122
+ case "ArrowDown":
5123
+ focusAt(index + 1);
5124
+ return;
5125
+ case "ArrowUp":
5126
+ focusAt(index - 1);
5127
+ return;
5128
+ case "Home":
5129
+ focusAt(0);
5130
+ return;
5131
+ case "End":
5132
+ focusAt(items.length - 1);
5133
+ return;
5134
+ }
5135
+ }
5136
+ // ─── Rename ───────────────────────────────────────────────────────────
5137
+ _startRename(row, conv) {
5138
+ const select = row.querySelector(".aparte-conv-item__select");
5139
+ if (!select) return;
5140
+ const input = document.createElement("input");
5141
+ input.type = "text";
5142
+ input.className = "aparte-field aparte-field--sm aparte-conv-item__input";
5143
+ input.value = conv.title;
5144
+ input.setAttribute("aria-label", this._t("conversationTitle"));
5145
+ input.dataset["renameId"] = conv.id;
5146
+ select.replaceWith(input);
5147
+ this._renaming = { id: conv.id, input, done: false };
5148
+ input.addEventListener("blur", () => this._finishRename(true, false));
5149
+ input.focus();
5150
+ input.select();
5151
+ }
5152
+ /**
5153
+ * One exit for the three ways out (Enter, Escape, leaving the field), guarded so
5154
+ * the blur a removal causes cannot commit a second time. The row is re-rendered
5155
+ * from the array either way: the list shows data, and the host's re-assignment is
5156
+ * what makes the new title appear.
5157
+ */
5158
+ _finishRename(commit, viaKeyboard) {
5159
+ const renaming = this._renaming;
5160
+ if (!renaming || renaming.done) return;
5161
+ renaming.done = true;
5162
+ const conv = this._conversations.find((c2) => c2.id === renaming.id);
5163
+ const title = renaming.input.value.trim();
5164
+ this._render();
5165
+ if (viaKeyboard) {
5166
+ this.querySelector(`[data-select-id="${cssEscape(renaming.id)}"]`)?.focus();
5167
+ }
5168
+ if (commit && conv && title && title !== conv.title) {
4709
5169
  this.dispatchEvent(new CustomEvent(
4710
- "aparte-delete-conversation",
4711
- { detail: { id }, bubbles: true, composed: true }
5170
+ "aparte-rename-conversation",
5171
+ { detail: { id: conv.id, title }, bubbles: true, composed: true }
4712
5172
  ));
5173
+ }
5174
+ }
5175
+ // ─── Events ───────────────────────────────────────────────────────────
5176
+ _onClick = (e) => {
5177
+ const target = e.target;
5178
+ const menuAction = target.closest("[data-menu-action]");
5179
+ if (menuAction && this._open?.menu.contains(menuAction)) {
5180
+ e.stopPropagation();
5181
+ this._onMenuAction(menuAction.dataset["menuAction"]);
5182
+ return;
5183
+ }
5184
+ const more = target.closest("[data-more-id]");
5185
+ if (more) {
5186
+ e.stopPropagation();
5187
+ if (this._open?.button === more) {
5188
+ this._closeMenu(true);
5189
+ } else {
5190
+ this._openMenu(more.closest("[data-conv-id]"), more);
5191
+ }
4713
5192
  return;
4714
5193
  }
4715
- const item = target.closest("[data-conv-id]");
4716
- if (item) {
4717
- const id = item.dataset["convId"];
5194
+ if (target.closest("[data-rename-id]")) return;
5195
+ const select = target.closest("[data-select-id]");
5196
+ if (select) {
5197
+ this._closeMenu();
4718
5198
  this.dispatchEvent(new CustomEvent(
4719
5199
  "aparte-select-conversation",
4720
- { detail: { id }, bubbles: true, composed: true }
5200
+ { detail: { id: select.dataset["selectId"] }, bubbles: true, composed: true }
4721
5201
  ));
4722
5202
  }
4723
5203
  };
4724
- _onKeydown = (e2) => {
4725
- if (e2.key !== "Enter" && e2.key !== " ") return;
4726
- const target = e2.target;
4727
- if (!target.matches("[data-conv-id]")) return;
4728
- e2.preventDefault();
4729
- target.click();
5204
+ _onKeydown = (e) => {
5205
+ const target = e.target;
5206
+ if (this._open?.menu.contains(target)) {
5207
+ this._onMenuKeydown(e);
5208
+ return;
5209
+ }
5210
+ if (target.matches("[data-rename-id]")) {
5211
+ if (e.key === "Enter") {
5212
+ e.preventDefault();
5213
+ this._finishRename(true, true);
5214
+ } else if (e.key === "Escape") {
5215
+ e.preventDefault();
5216
+ this._finishRename(false, true);
5217
+ }
5218
+ return;
5219
+ }
4730
5220
  };
4731
5221
  /** Update active class without full re-render (perf optimisation). */
4732
5222
  _updateActiveState() {
@@ -4734,13 +5224,9 @@ class AparteConversationList extends HTMLElement {
4734
5224
  items.forEach((el2) => {
4735
5225
  const isActive = el2.dataset["convId"] === this._activeId;
4736
5226
  el2.classList.toggle("aparte-conv-item--active", isActive);
4737
- el2.setAttribute("aria-current", isActive ? "page" : "false");
5227
+ el2.querySelector("[data-select-id]")?.setAttribute("aria-current", isActive ? "page" : "false");
4738
5228
  });
4739
5229
  }
4740
- // ─── Helpers ──────────────────────────────────────────────────────────
4741
- _esc(str) {
4742
- return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/'/g, "&#039;");
4743
- }
4744
5230
  }
4745
5231
  if (!customElements.get("aparte-conversation-list")) customElements.define("aparte-conversation-list", AparteConversationList);
4746
5232
  const isSuggestion = (value) => typeof value === "string" || typeof value === "object" && value !== null && typeof value.label === "string";
@@ -4888,15 +5374,31 @@ const el = (tag, className) => {
4888
5374
  node.className = className;
4889
5375
  return node;
4890
5376
  };
5377
+ const SVG_NS = "http://www.w3.org/2000/svg";
5378
+ const ringSvg = () => {
5379
+ const svg = document.createElementNS(SVG_NS, "svg");
5380
+ svg.setAttribute("class", "aparte-context__ring");
5381
+ svg.setAttribute("viewBox", "0 0 36 36");
5382
+ for (const part of ["track", "value"]) {
5383
+ const circle = document.createElementNS(SVG_NS, "circle");
5384
+ circle.setAttribute("class", `aparte-context__${part}`);
5385
+ circle.setAttribute("cx", "18");
5386
+ circle.setAttribute("cy", "18");
5387
+ circle.setAttribute("r", "15.5");
5388
+ if (part === "value") circle.setAttribute("pathLength", "100");
5389
+ svg.appendChild(circle);
5390
+ }
5391
+ return svg;
5392
+ };
4891
5393
  class AparteContext extends HTMLElement {
4892
5394
  static get observedAttributes() {
4893
- return ["window", "warn", "danger", "target"];
5395
+ return ["window", "warn", "danger", "target", "variant"];
4894
5396
  }
4895
5397
  _used = null;
4896
5398
  _level = null;
4897
5399
  _compactRequested = false;
4898
- _onDone = (e2) => {
4899
- const detail = e2.detail;
5400
+ _onDone = (e) => {
5401
+ const detail = e.detail;
4900
5402
  if (!detail?.usage || !this._isMine(detail.targetId)) return;
4901
5403
  const { usage } = detail;
4902
5404
  this._used = usage.totalTokens ?? usage.inputTokens + usage.outputTokens;
@@ -4905,16 +5407,24 @@ class AparteContext extends HTMLElement {
4905
5407
  /**
4906
5408
  * After a real compaction the context is smaller by an amount nobody has measured
4907
5409
  * yet — the next turn's usage will say. Until then the gauge shows nothing rather
4908
- * than a number it knows to be wrong. (`aparte-compact-done` names no chat: on a
4909
- * multi-chat page every gauge resets, and each is right again one turn later.)
5410
+ * than a number it knows to be wrong. The event names the chat it compacted, so on a
5411
+ * multi-chat page only that chat's gauge resets; an unnamed one resets every gauge,
5412
+ * and each is right again one turn later.
4910
5413
  */
4911
- _onCompacted = (e2) => {
4912
- const detail = e2.detail;
5414
+ _onCompacted = (e) => {
5415
+ const detail = e.detail;
5416
+ if (!this._isMine(detail?.targetId)) return;
5417
+ this._compactRequested = false;
4913
5418
  if (detail?.skipped) return;
4914
5419
  this._used = null;
4915
- this._compactRequested = false;
4916
5420
  this._render();
4917
5421
  };
5422
+ /** A failed compaction spends the request too; the next `danger` asks again. */
5423
+ _onCompactError = (e) => {
5424
+ const detail = e.detail;
5425
+ if (!this._isMine(detail?.targetId)) return;
5426
+ this._compactRequested = false;
5427
+ };
4918
5428
  _onRerender = () => this._render();
4919
5429
  /** Tokens the last turn reported, or `null` before the first turn. */
4920
5430
  get used() {
@@ -4931,12 +5441,14 @@ class AparteContext extends HTMLElement {
4931
5441
  connectedCallback() {
4932
5442
  window.addEventListener("aparte-message-done", this._onDone);
4933
5443
  window.addEventListener("aparte-compact-done", this._onCompacted);
5444
+ window.addEventListener("aparte-compact-error", this._onCompactError);
4934
5445
  window.addEventListener("aparte-config-change", this._onRerender);
4935
5446
  this._render();
4936
5447
  }
4937
5448
  disconnectedCallback() {
4938
5449
  window.removeEventListener("aparte-message-done", this._onDone);
4939
5450
  window.removeEventListener("aparte-compact-done", this._onCompacted);
5451
+ window.removeEventListener("aparte-compact-error", this._onCompactError);
4940
5452
  window.removeEventListener("aparte-config-change", this._onRerender);
4941
5453
  }
4942
5454
  attributeChangedCallback() {
@@ -4980,58 +5492,1345 @@ class AparteContext extends HTMLElement {
4980
5492
  const danger = fraction(this.getAttribute("danger"), 0.9);
4981
5493
  const level = ratio >= danger ? "danger" : ratio >= warn ? "warn" : "ok";
4982
5494
  this.setAttribute("data-level", level);
4983
- let bar = this.querySelector(".aparte-progress");
5495
+ const ring = this.getAttribute("variant") === "ring";
5496
+ let root = this.querySelector(".aparte-context");
5497
+ if (root && root.classList.contains("aparte-context--ring") !== ring) {
5498
+ this.replaceChildren();
5499
+ root = null;
5500
+ }
5501
+ let meter = this.querySelector('[role="meter"]');
4984
5502
  let text = this.querySelector(".aparte-context__text");
4985
- if (!bar || !text) {
4986
- const root = el("div", "aparte-context");
4987
- bar = el("div", "aparte-progress");
4988
- bar.setAttribute("role", "meter");
4989
- bar.appendChild(el("div", "aparte-progress__bar"));
5503
+ if (!root || !meter || !text) {
5504
+ root = el("div", ring ? "aparte-context aparte-context--ring" : "aparte-context");
5505
+ meter = ring ? ringSvg() : el("div", "aparte-progress");
5506
+ meter.setAttribute("role", "meter");
5507
+ if (!ring) meter.appendChild(el("div", "aparte-progress__bar"));
4990
5508
  text = el("span", "aparte-context__text");
4991
- root.append(bar, text);
5509
+ root.append(meter, text);
4992
5510
  this.replaceChildren(root);
4993
5511
  }
4994
- const format = new Intl.NumberFormat(void 0, { notation: "compact", maximumFractionDigits: 1 });
5512
+ const cfg = resolveConfig(this);
5513
+ const tag = cfg.getLocale().tag || void 0;
5514
+ const format = new Intl.NumberFormat(tag, { notation: "compact", maximumFractionDigits: 1 });
4995
5515
  const reading = `${format.format(used)} / ${format.format(capacity)}`;
4996
- const label = resolveConfig(this).t("contextLabel") || "Context window";
4997
- bar.style.setProperty("--aparte-progress-value", String(Math.round(ratio * 100)));
4998
- bar.setAttribute("aria-valuemin", "0");
4999
- bar.setAttribute("aria-valuemax", String(capacity));
5000
- bar.setAttribute("aria-valuenow", String(used));
5001
- bar.setAttribute("aria-label", `${label}: ${reading}`);
5002
- text.textContent = reading;
5516
+ const percent = Math.round(ratio * 100);
5517
+ const label = cfg.t("contextLabel") || "Context window";
5518
+ if (ring) {
5519
+ const value = meter.querySelector(".aparte-context__value");
5520
+ value?.style.setProperty("--aparte-context-ratio", String(percent));
5521
+ value?.classList.toggle("aparte-context__value--empty", percent === 0);
5522
+ root.title = reading;
5523
+ } else {
5524
+ meter.style.setProperty("--aparte-progress-value", String(percent));
5525
+ }
5526
+ meter.setAttribute("aria-valuemin", "0");
5527
+ meter.setAttribute("aria-valuemax", String(capacity));
5528
+ meter.setAttribute("aria-valuenow", String(used));
5529
+ meter.setAttribute("aria-label", `${label}: ${reading}`);
5530
+ text.textContent = ring ? new Intl.NumberFormat(tag, { style: "percent", maximumFractionDigits: 0 }).format(percent / 100) : reading;
5003
5531
  if (level !== this._level) {
5004
5532
  this._level = level;
5005
5533
  const targetId = this._ownTargetId();
5006
5534
  const detail = { level, used, window: capacity, ratio, targetId };
5007
5535
  this.dispatchEvent(new CustomEvent("aparte-context-threshold", { detail, bubbles: true, composed: true }));
5008
- if (level === "danger" && this.hasAttribute("auto-compact") && !this._compactRequested) {
5009
- this._compactRequested = true;
5010
- window.dispatchEvent(new CustomEvent("aparte-compact", { detail: { targetId } }));
5011
- }
5012
5536
  if (level !== "danger") this._compactRequested = false;
5013
5537
  }
5538
+ if (level === "danger" && this.hasAttribute("auto-compact") && !this._compactRequested) {
5539
+ this._compactRequested = true;
5540
+ window.dispatchEvent(new CustomEvent("aparte-compact", { detail: { targetId: this._ownTargetId() } }));
5541
+ }
5014
5542
  }
5015
5543
  }
5016
5544
  if (!customElements.get("aparte-context")) {
5017
5545
  customElements.define("aparte-context", AparteContext);
5018
5546
  }
5547
+ const EXCERPT_LENGTH = 60;
5548
+ class AparteScrollRail extends HTMLElement {
5549
+ static get observedAttributes() {
5550
+ return ["target", "every"];
5551
+ }
5552
+ _host = null;
5553
+ _mutations = null;
5554
+ _intersections = null;
5555
+ _rebuildQueued = false;
5556
+ _currentId = null;
5557
+ /** Visible ratio per message id, from the intersection observer. */
5558
+ _visible = /* @__PURE__ */ new Map();
5559
+ _onConfigChange = () => this._rebuild();
5560
+ /** The id of the message the rail currently marks, or `null`. */
5561
+ get currentMessageId() {
5562
+ return this._currentId;
5563
+ }
5564
+ connectedCallback() {
5565
+ if (!this.classList.contains("aparte-scroll-rail")) this.classList.add("aparte-scroll-rail");
5566
+ if (!this.getAttribute("role")) this.setAttribute("role", "navigation");
5567
+ this.addEventListener("click", this._onClick);
5568
+ this.addEventListener("keydown", this._onKeydown);
5569
+ window.addEventListener("aparte-config-change", this._onConfigChange);
5570
+ this._attach();
5571
+ }
5572
+ disconnectedCallback() {
5573
+ this._detach();
5574
+ this.removeEventListener("click", this._onClick);
5575
+ this.removeEventListener("keydown", this._onKeydown);
5576
+ window.removeEventListener("aparte-config-change", this._onConfigChange);
5577
+ }
5578
+ attributeChangedCallback(name) {
5579
+ if (!this.isConnected) return;
5580
+ if (name === "target") this._attach();
5581
+ else this._rebuild();
5582
+ }
5583
+ // ─── Wiring ───────────────────────────────────────────────────────────
5584
+ /** The chat this rail follows: `target`, else the host above it — the gauge's rule. */
5585
+ _findHost() {
5586
+ const target = this.getAttribute("target");
5587
+ if (target) return document.getElementById(target);
5588
+ let node = this.parentElement;
5589
+ while (node) {
5590
+ if (node.tagName.toLowerCase() === "aparte-chat" || node.hasAttribute("data-aparte-chat")) return node;
5591
+ node = node.parentElement;
5592
+ }
5593
+ return null;
5594
+ }
5595
+ _attach() {
5596
+ this._detach();
5597
+ this._host = this._findHost();
5598
+ if (!this._host) {
5599
+ this._rebuild();
5600
+ return;
5601
+ }
5602
+ if (typeof MutationObserver !== "undefined") {
5603
+ this._mutations = new MutationObserver(() => this._queueRebuild());
5604
+ this._mutations.observe(this._host, { childList: true, subtree: true, characterData: true });
5605
+ }
5606
+ this._rebuild();
5607
+ }
5608
+ _detach() {
5609
+ this._mutations?.disconnect();
5610
+ this._mutations = null;
5611
+ this._intersections?.disconnect();
5612
+ this._intersections = null;
5613
+ this._visible.clear();
5614
+ this._host = null;
5615
+ }
5616
+ /** Coalesce a burst of mutations (a streaming reply is one per token) into one rebuild. */
5617
+ _queueRebuild() {
5618
+ if (this._rebuildQueued) return;
5619
+ this._rebuildQueued = true;
5620
+ requestAnimationFrame(() => {
5621
+ this._rebuildQueued = false;
5622
+ this._rebuild();
5623
+ });
5624
+ }
5625
+ _viewport() {
5626
+ return this._host?.querySelector("aparte-chat-viewport") ?? null;
5627
+ }
5628
+ /** The element that scrolls: the vanilla container, or the viewport itself under a wrapper. */
5629
+ _scrollSurface() {
5630
+ const viewport = this._viewport();
5631
+ if (!viewport) return null;
5632
+ return viewport.querySelector(".aparte-viewport-container") ?? viewport;
5633
+ }
5634
+ _bubbles() {
5635
+ const viewport = this._viewport();
5636
+ if (!viewport) return [];
5637
+ const every = this.getAttribute("every") === "message" ? "message" : "user";
5638
+ const all = Array.from(viewport.querySelectorAll("aparte-chat-bubble[message-id]"));
5639
+ return every === "message" ? all : all.filter((b2) => b2.getAttribute("data-role") === "user");
5640
+ }
5641
+ // ─── Rendering ────────────────────────────────────────────────────────
5642
+ /** The first words of a message, for the tick's name. An excerpt, never a summary. */
5643
+ _excerpt(bubble) {
5644
+ const body = bubble.querySelector(".aparte-message-content") ?? bubble;
5645
+ const text = (body.textContent ?? "").replace(/\s+/g, " ").trim();
5646
+ return text.length > EXCERPT_LENGTH ? `${text.slice(0, EXCERPT_LENGTH - 1).trimEnd()}…` : text;
5647
+ }
5648
+ _tickLabel(bubble) {
5649
+ const locale = resolveConfig(this).getLocale();
5650
+ const role = bubble.getAttribute("data-role") === "user" ? locale.roleNameUser ?? "You" : locale.roleNameAssistant ?? "Assistant";
5651
+ const excerpt = this._excerpt(bubble);
5652
+ return excerpt ? `${role}: ${excerpt}` : role;
5653
+ }
5654
+ _rebuild() {
5655
+ const bubbles = this._bubbles();
5656
+ const locale = resolveConfig(this).getLocale();
5657
+ this.setAttribute("aria-label", locale.scrollRailLabel ?? "Conversation outline");
5658
+ if (bubbles.length < 2) {
5659
+ this.setAttribute("data-empty", "");
5660
+ this.replaceChildren();
5661
+ this._intersections?.disconnect();
5662
+ this._intersections = null;
5663
+ this._visible.clear();
5664
+ this._currentId = null;
5665
+ return;
5666
+ }
5667
+ this.removeAttribute("data-empty");
5668
+ const list = document.createElement("ol");
5669
+ list.className = "aparte-scroll-rail__list";
5670
+ const known = /* @__PURE__ */ new Set();
5671
+ for (const bubble of bubbles) {
5672
+ const id = bubble.getAttribute("message-id");
5673
+ known.add(id);
5674
+ const item = document.createElement("li");
5675
+ const tick = document.createElement("button");
5676
+ tick.type = "button";
5677
+ tick.className = "aparte-scroll-rail__tick";
5678
+ tick.dataset["messageId"] = id;
5679
+ const label = this._tickLabel(bubble);
5680
+ tick.setAttribute("aria-label", label);
5681
+ tick.title = label;
5682
+ if (id === this._currentId) tick.setAttribute("aria-current", "true");
5683
+ item.appendChild(tick);
5684
+ list.appendChild(item);
5685
+ }
5686
+ this.replaceChildren(list);
5687
+ if (this._currentId && !known.has(this._currentId)) this._currentId = null;
5688
+ this._observe(bubbles);
5689
+ }
5690
+ /**
5691
+ * Which tick is current is read, not computed: the scroll surface is the root, and
5692
+ * the bubble with the largest visible share of a reading band near the top wins.
5693
+ * Scroll arithmetic was the alternative, and every scroll-position rule in the
5694
+ * viewport carries a regression note — this one has none to carry.
5695
+ */
5696
+ _observe(bubbles) {
5697
+ this._intersections?.disconnect();
5698
+ this._intersections = null;
5699
+ this._visible.clear();
5700
+ if (typeof IntersectionObserver === "undefined") return;
5701
+ const root = this._scrollSurface();
5702
+ if (!root) return;
5703
+ this._intersections = new IntersectionObserver((entries) => {
5704
+ for (const entry of entries) {
5705
+ const id = entry.target.getAttribute("message-id");
5706
+ if (id) this._visible.set(id, entry.isIntersecting ? entry.intersectionRatio : 0);
5707
+ }
5708
+ this._pickCurrent(bubbles);
5709
+ }, { root, rootMargin: "-15% 0px -55% 0px", threshold: [0, 0.1, 0.25, 0.5, 0.75, 1] });
5710
+ for (const bubble of bubbles) this._intersections.observe(bubble);
5711
+ }
5712
+ _pickCurrent(bubbles) {
5713
+ let best = null;
5714
+ let bestRatio = 0;
5715
+ for (const bubble of bubbles) {
5716
+ const id = bubble.getAttribute("message-id");
5717
+ const ratio = this._visible.get(id) ?? 0;
5718
+ if (ratio > bestRatio) {
5719
+ bestRatio = ratio;
5720
+ best = id;
5721
+ }
5722
+ }
5723
+ if (best !== null) this._setCurrent(best);
5724
+ }
5725
+ _setCurrent(id) {
5726
+ if (this._currentId === id) return;
5727
+ this._currentId = id;
5728
+ for (const tick of this.querySelectorAll(".aparte-scroll-rail__tick")) {
5729
+ if (tick.dataset["messageId"] === id) tick.setAttribute("aria-current", "true");
5730
+ else tick.removeAttribute("aria-current");
5731
+ }
5732
+ }
5733
+ // ─── Interaction ──────────────────────────────────────────────────────
5734
+ _onClick = (e) => {
5735
+ const tick = e.target.closest(".aparte-scroll-rail__tick");
5736
+ if (!tick || !this.contains(tick)) return;
5737
+ const messageId = tick.dataset["messageId"];
5738
+ if (!messageId) return;
5739
+ this.jumpTo(messageId);
5740
+ };
5741
+ /** The ticks are buttons; the arrows walk them, as in any toolbar. */
5742
+ _onKeydown = (e) => {
5743
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") return;
5744
+ const ticks = Array.from(this.querySelectorAll(".aparte-scroll-rail__tick"));
5745
+ const index = ticks.indexOf(document.activeElement);
5746
+ if (index === -1) return;
5747
+ e.preventDefault();
5748
+ 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);
5749
+ ticks[next]?.focus();
5750
+ };
5751
+ /**
5752
+ * Scroll the transcript to a message. Announced first, cancelable; then a
5753
+ * `scrollIntoView` on the bubble, smooth unless the reader prefers reduced motion.
5754
+ */
5755
+ jumpTo(messageId) {
5756
+ const announced = this.dispatchEvent(new CustomEvent("aparte-scroll-rail-jump", {
5757
+ detail: { messageId },
5758
+ bubbles: true,
5759
+ composed: true,
5760
+ cancelable: true
5761
+ }));
5762
+ if (!announced) return;
5763
+ const viewport = this._viewport();
5764
+ const bubble = viewport?.querySelector(`aparte-chat-bubble[message-id="${cssEscape(messageId)}"]`);
5765
+ if (!bubble) return;
5766
+ const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
5767
+ bubble.scrollIntoView({ block: "start", behavior: reduced ? "auto" : "smooth" });
5768
+ this._setCurrent(messageId);
5769
+ }
5770
+ }
5771
+ if (!customElements.get("aparte-scroll-rail")) {
5772
+ customElements.define("aparte-scroll-rail", AparteScrollRail);
5773
+ }
5774
+ const DRAWER_QUERY = "(max-width: 48rem)";
5775
+ class AparteSidebar extends HTMLElement {
5776
+ static get observedAttributes() {
5777
+ return ["collapsed", "breakpoint"];
5778
+ }
5779
+ _media = null;
5780
+ _scrim = null;
5781
+ /** The control that last opened the drawer, to hand the focus back to. */
5782
+ _opener = null;
5783
+ _lastCollapsed = null;
5784
+ /**
5785
+ * `connectedCallback` has run and the element knows what its markup asked for.
5786
+ *
5787
+ * Not `isConnected`: during an UPGRADE the element is already in the document, so
5788
+ * `attributeChangedCallback` fires for every authored attribute — connected, and
5789
+ * before `connectedCallback` — which is the ordinary case for a server-rendered
5790
+ * `<aparte-sidebar collapsed>` upgraded when the module loads. Announcing there
5791
+ * tells the host its sidebar just closed when all that happened is that the markup
5792
+ * was read, and says `drawer: false` because the media query has not run yet.
5793
+ */
5794
+ _ready = false;
5795
+ /**
5796
+ * The host (or the markup) asked for the column to be folded, so widening the
5797
+ * window must not unfold it. Only a collapse taken OUTSIDE the drawer state counts:
5798
+ * dismissing an overlay says nothing about what a wide window should show.
5799
+ */
5800
+ _closedByHost = false;
5801
+ /**
5802
+ * `_closedByHost` has been taken from the markup once. A re-parent runs
5803
+ * `connectedCallback` again, and by then `collapsed` may be the BREAKPOINT's own
5804
+ * write — a drawer closes itself on the way in — so reading it back a second time
5805
+ * records the element's own doing as the host's word and the column never reopens.
5806
+ * Like `_initialCaptured` on `<aparte-split>`, it must survive the move.
5807
+ */
5808
+ _hostIntentSeeded = false;
5809
+ /** Raised around `_applyDrawer`'s own writes, so the breakpoint is never read as a host's intent. */
5810
+ _auto = false;
5811
+ /**
5812
+ * The element wrote `inert`/`aria-hidden` and may take them back. A host that inerts
5813
+ * the sidebar behind its own modal wrote them itself, and `_syncHidden` runs on every
5814
+ * breakpoint re-evaluation: without this it would un-inert the page behind the overlay.
5815
+ * Same convention as `_relabel`, which leaves a host-authored `aria-label` alone.
5816
+ */
5817
+ _ownHidden = false;
5818
+ /** Whether the sidebar is collapsed (hidden in the flow, or closed as a drawer). */
5819
+ get collapsed() {
5820
+ return this.hasAttribute("collapsed");
5821
+ }
5822
+ set collapsed(value) {
5823
+ this.toggleAttribute("collapsed", value);
5824
+ }
5825
+ /** True while the window is under the breakpoint and the sidebar is a drawer. */
5826
+ get drawer() {
5827
+ return this.hasAttribute("data-drawer");
5828
+ }
5829
+ connectedCallback() {
5830
+ if (!this.classList.contains("aparte-sidebar")) this.classList.add("aparte-sidebar");
5831
+ if (!this.getAttribute("role")) this.setAttribute("role", "complementary");
5832
+ this._relabel();
5833
+ if (!this._hostIntentSeeded) {
5834
+ this._closedByHost = this.collapsed;
5835
+ this._hostIntentSeeded = true;
5836
+ }
5837
+ this.addEventListener("input", this._onInput);
5838
+ document.addEventListener("keydown", this._onKeydown);
5839
+ document.addEventListener("click", this._onDocumentClick);
5840
+ window.addEventListener("aparte-config-change", this._onConfigChange);
5841
+ this._watchBreakpoint();
5842
+ this._syncHidden();
5843
+ this._lastCollapsed = this.collapsed;
5844
+ this._ready = true;
5845
+ }
5846
+ /**
5847
+ * The drawer's media query, from the `breakpoint` attribute: a length (`48rem`, the
5848
+ * default, or `640px`), or `none` for a column that never becomes a drawer — which
5849
+ * a narrow host that has room for it wants, and a documentation frame needs.
5850
+ */
5851
+ _watchBreakpoint() {
5852
+ this._media?.removeEventListener("change", this._onMediaChange);
5853
+ this._media = null;
5854
+ const attr = (this.getAttribute("breakpoint") ?? "").trim();
5855
+ if (attr === "none") {
5856
+ this._applyDrawer(false);
5857
+ return;
5858
+ }
5859
+ if (typeof matchMedia !== "function") return;
5860
+ const query = attr ? `(max-width: ${attr})` : DRAWER_QUERY;
5861
+ this._media = matchMedia(query);
5862
+ this._media.addEventListener("change", this._onMediaChange);
5863
+ this._applyDrawer(this._media.matches);
5864
+ }
5865
+ disconnectedCallback() {
5866
+ this.removeEventListener("input", this._onInput);
5867
+ document.removeEventListener("keydown", this._onKeydown);
5868
+ document.removeEventListener("click", this._onDocumentClick);
5869
+ window.removeEventListener("aparte-config-change", this._onConfigChange);
5870
+ this._media?.removeEventListener("change", this._onMediaChange);
5871
+ this._media = null;
5872
+ this._ready = false;
5873
+ }
5874
+ attributeChangedCallback(name) {
5875
+ if (!this._ready) return;
5876
+ if (name === "breakpoint") {
5877
+ this._watchBreakpoint();
5878
+ return;
5879
+ }
5880
+ if (name !== "collapsed") return;
5881
+ const collapsed = this.collapsed;
5882
+ if (collapsed === this._lastCollapsed) return;
5883
+ this._lastCollapsed = collapsed;
5884
+ if (!this._auto && !this.drawer) this._closedByHost = collapsed;
5885
+ this._syncScrim();
5886
+ this._syncHidden();
5887
+ if (collapsed && this.drawer && this._opener?.isConnected) {
5888
+ this._opener.focus();
5889
+ this._opener = null;
5890
+ } else if (!collapsed && this.drawer) {
5891
+ this._focusFirst();
5892
+ }
5893
+ this.dispatchEvent(new CustomEvent("aparte-sidebar-toggle", {
5894
+ detail: { collapsed, drawer: this.drawer },
5895
+ bubbles: true,
5896
+ composed: true
5897
+ }));
5898
+ }
5899
+ // ─── Public API ───────────────────────────────────────────────────────
5900
+ /** Open the sidebar. `opener` is the control to hand the focus back to when it closes. */
5901
+ open(opener) {
5902
+ if (opener) this._opener = opener;
5903
+ this.collapsed = false;
5904
+ }
5905
+ close() {
5906
+ this.collapsed = true;
5907
+ }
5908
+ toggle(opener) {
5909
+ if (this.collapsed) this.open(opener);
5910
+ else this.close();
5911
+ }
5912
+ // ─── Drawer ───────────────────────────────────────────────────────────
5913
+ _onMediaChange = (e) => this._applyDrawer(e.matches);
5914
+ /**
5915
+ * Entering the drawer state closes the sidebar: a narrow window that opens on an
5916
+ * overlay covering the chat is the wrong first screen. Leaving it reopens, since
5917
+ * a wide window has room for the column — unless the host had collapsed it before.
5918
+ */
5919
+ _applyDrawer(drawer) {
5920
+ const was = this.drawer;
5921
+ this.toggleAttribute("data-drawer", drawer);
5922
+ this._auto = true;
5923
+ try {
5924
+ if (drawer && !was) this.collapsed = true;
5925
+ else if (!drawer && was && !this._closedByHost) this.collapsed = false;
5926
+ } finally {
5927
+ this._auto = false;
5928
+ }
5929
+ this._syncScrim();
5930
+ this._syncHidden();
5931
+ }
5932
+ /**
5933
+ * A collapsed sidebar is invisible — folded to nothing in the flow, slid off the
5934
+ * screen as a drawer — so it must not keep its tab stops or its place in the
5935
+ * accessibility tree. `inert` covers focus and the pointer, `aria-hidden` covers
5936
+ * the readers that predate it; a browser without `inert` still gets the second.
5937
+ *
5938
+ * It removes only what it wrote. Both are standard global attributes a host sets
5939
+ * itself — inerting the sidebar behind its own modal is the ordinary pattern — and
5940
+ * this runs on every breakpoint re-evaluation, so clearing them unconditionally lets
5941
+ * an unrelated resize re-expose the page under an overlay. Same convention as
5942
+ * `_relabel`, which leaves a host-authored `aria-label` alone.
5943
+ *
5944
+ * Neither is declared `@attr`, and `data-drawer` is not the precedent: a `@attr` on a
5945
+ * STANDARD name displaces the wrappers' own prop for it — React's JSX props are
5946
+ * `Omit<HTMLAttributes, keyof T>`, so declaring `inert` here would retype
5947
+ * `<aparte-sidebar inert={busy}>` out of existence. The class docblock says it in prose.
5948
+ */
5949
+ _syncHidden() {
5950
+ if (this.collapsed) {
5951
+ this.toggleAttribute("inert", true);
5952
+ this.setAttribute("aria-hidden", "true");
5953
+ this._ownHidden = true;
5954
+ } else if (this._ownHidden) {
5955
+ this.removeAttribute("inert");
5956
+ this.removeAttribute("aria-hidden");
5957
+ this._ownHidden = false;
5958
+ }
5959
+ }
5960
+ /**
5961
+ * The drawer covers the page, so the focus has to follow it in — otherwise the next
5962
+ * Tab walks the transcript under the overlay. The host owns the children, so this
5963
+ * takes the first thing that can hold focus and leaves the rest alone; a drawer with
5964
+ * nothing focusable in it keeps the focus where it was, which is the honest outcome.
5965
+ */
5966
+ _focusFirst() {
5967
+ this.querySelector(FOCUSABLE)?.focus();
5968
+ }
5969
+ _syncScrim() {
5970
+ const wanted = this.drawer && !this.collapsed;
5971
+ if (wanted && !this._scrim) {
5972
+ this._scrim = document.createElement("div");
5973
+ this._scrim.className = "aparte-sidebar__scrim";
5974
+ this._scrim.setAttribute("aria-hidden", "true");
5975
+ this._scrim.addEventListener("click", () => this.close());
5976
+ this.appendChild(this._scrim);
5977
+ } else if (!wanted && this._scrim) {
5978
+ this._scrim.remove();
5979
+ this._scrim = null;
5980
+ }
5981
+ }
5982
+ _onKeydown = (e) => {
5983
+ if (e.key === "Escape" && this.drawer && !this.collapsed) {
5984
+ e.preventDefault();
5985
+ this.close();
5986
+ }
5987
+ };
5988
+ /** A `[data-aparte-sidebar-toggle]` anywhere toggles this sidebar — no script in the host. */
5989
+ _onDocumentClick = (e) => {
5990
+ const control = e.target?.closest?.("[data-aparte-sidebar-toggle]");
5991
+ if (!control) return;
5992
+ const named = control.getAttribute("data-aparte-sidebar-toggle");
5993
+ if (named) {
5994
+ if (named !== this.id) return;
5995
+ } else if (nearestSidebar(control) !== this) {
5996
+ return;
5997
+ }
5998
+ this.toggle(control);
5999
+ };
6000
+ // ─── Search ───────────────────────────────────────────────────────────
6001
+ _onInput = (e) => {
6002
+ const input = e.target;
6003
+ if (!input?.matches?.("[data-aparte-sidebar-search]")) return;
6004
+ this.filter(input.value);
6005
+ };
6006
+ /**
6007
+ * Hide the conversation rows whose title does not contain `query`, and the date
6008
+ * groups left empty. Case- and accent-insensitive. An empty query shows everything.
6009
+ */
6010
+ filter(query) {
6011
+ const needle = fold(query);
6012
+ for (const list of this.querySelectorAll("aparte-conversation-list")) {
6013
+ for (const row of list.querySelectorAll("[data-conv-id]")) {
6014
+ const title = row.querySelector(".aparte-conv-item__title")?.textContent ?? row.textContent ?? "";
6015
+ row.hidden = needle !== "" && !fold(title).includes(needle);
6016
+ }
6017
+ for (const group of list.querySelectorAll(".aparte-conv-group")) {
6018
+ group.hidden = needle !== "" && !Array.from(group.querySelectorAll("[data-conv-id]")).some((r) => !r.hidden);
6019
+ }
6020
+ }
6021
+ }
6022
+ // ─── Locale ───────────────────────────────────────────────────────────
6023
+ _onConfigChange = () => this._relabel();
6024
+ _relabel() {
6025
+ if (this.hasAttribute("aria-label") && this.dataset["ownLabel"] !== "locale") return;
6026
+ const locale = resolveConfig(this).getLocale();
6027
+ this.setAttribute("aria-label", locale.sidebarLabel ?? "Conversations");
6028
+ this.dataset["ownLabel"] = "locale";
6029
+ }
6030
+ }
6031
+ const FOCUSABLE = [
6032
+ "a[href]",
6033
+ "button:not([disabled])",
6034
+ 'input:not([disabled]):not([type="hidden"])',
6035
+ "select:not([disabled])",
6036
+ "textarea:not([disabled])",
6037
+ '[tabindex]:not([tabindex="-1"])'
6038
+ ].join(",");
6039
+ function nearestSidebar(control) {
6040
+ return control.closest("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
6041
+ }
6042
+ function fold(text) {
6043
+ return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
6044
+ }
6045
+ if (!customElements.get("aparte-sidebar")) {
6046
+ customElements.define("aparte-sidebar", AparteSidebar);
6047
+ }
6048
+ function clamp(value, min, max) {
6049
+ if (!Number.isFinite(value)) return min;
6050
+ return value < min ? min : value > max ? max : value;
6051
+ }
6052
+ function nextPosition(step) {
6053
+ const start = Number.isFinite(step.startPercent) ? step.startPercent : 0;
6054
+ if (!Number.isFinite(step.containerPx) || step.containerPx <= 0) return clamp(start, 0, 100);
6055
+ const inverted = (!step.vertical && step.rtl) !== (step.primaryEnd === true);
6056
+ const sign = inverted ? -1 : 1;
6057
+ const moved = sign * step.deltaPx * 100 / step.containerPx;
6058
+ return clamp(start + moved, 0, 100);
6059
+ }
6060
+ function keyDelta(key, shift, rtl, vertical, primaryEnd = false) {
6061
+ if (key === "Home") return -100;
6062
+ if (key === "End") return 100;
6063
+ const step = (shift ? 10 : 1) * (primaryEnd ? -1 : 1);
6064
+ if (vertical) {
6065
+ if (key === "ArrowUp") return -step;
6066
+ if (key === "ArrowDown") return step;
6067
+ return null;
6068
+ }
6069
+ if (key === "ArrowLeft") return rtl ? step : -step;
6070
+ if (key === "ArrowRight") return rtl ? -step : step;
6071
+ return null;
6072
+ }
6073
+ const STACK_QUERY = "(max-width: 48rem)";
6074
+ const DEFAULT_POSITION = 38;
6075
+ const SCRIM_RETIRE_MS = 300;
6076
+ function toPercent(raw, fallback) {
6077
+ const value = Number.parseFloat(raw ?? "");
6078
+ if (!Number.isFinite(value)) return fallback;
6079
+ return value < 0 ? 0 : value > 100 ? 100 : value;
6080
+ }
6081
+ class AparteSplit extends HTMLElement {
6082
+ static get observedAttributes() {
6083
+ return ["position", "orientation", "primary", "collapsed", "breakpoint", "pane", "disabled", "label"];
6084
+ }
6085
+ _handle = null;
6086
+ _scrim = null;
6087
+ _media = null;
6088
+ _observer = null;
6089
+ /** The pending `_measureBounds()` from a resize, batched to one per frame. */
6090
+ _measureRafId = null;
6091
+ /** The host size the last resize tick measured, so an unchanged one does nothing. */
6092
+ _observedPx = 0;
6093
+ /** Ids for the panes `aria-controls` names — a counter, the way the primitives do it. */
6094
+ static _paneIdSeq = 0;
6095
+ /** The live position. The attribute holds it only after a commit. */
6096
+ _position = DEFAULT_POSITION;
6097
+ /** What `reset()` — and a double-click on the seam — goes back to. */
6098
+ _initialPosition = DEFAULT_POSITION;
6099
+ /**
6100
+ * `_initialPosition` has been taken from the markup once. A re-parent runs
6101
+ * `connectedCallback` again, and by then the attribute holds the LAST COMMIT, not
6102
+ * what the author wrote: capturing a second time makes a framework re-render, a tab
6103
+ * switch or a drag-and-drop of the panel redefine what "reset" means.
6104
+ */
6105
+ _initialCaptured = false;
6106
+ /** The size the primary pane had before it was collapsed, for the second Enter. */
6107
+ _preCollapsePosition = null;
6108
+ /** Committed values, so a reflection of our own does not read as a host's change. */
6109
+ _lastPosition = null;
6110
+ _lastCollapsed = null;
6111
+ _lastPane = null;
6112
+ /** What an attribute-driven commit blames. Raised around a key that changes state. */
6113
+ _source = "api";
6114
+ /**
6115
+ * `connectedCallback` has run and the element knows what its markup asked for.
6116
+ *
6117
+ * Not `isConnected`: during an UPGRADE the element is already in the document, so
6118
+ * `attributeChangedCallback` fires for every authored attribute — connected, and
6119
+ * before `connectedCallback` — which is the ordinary case for a server-rendered
6120
+ * `<aparte-split position="38">` upgraded when the module loads. Committing there
6121
+ * measures a layout the element has not set up yet (no `data-stacked`, so on a
6122
+ * phone the probe reads the stacked pane and writes `position="84"`), reflects that
6123
+ * over the authored number, and sends it to whatever the host persists.
6124
+ */
6125
+ _ready = false;
6126
+ _dragging = false;
6127
+ _captured = false;
6128
+ /** The gesture actually moved the seam. A press that did not commits nothing. */
6129
+ _moved = false;
6130
+ _pointerId = null;
6131
+ _dragStartPercent = DEFAULT_POSITION;
6132
+ _dragOriginPx = 0;
6133
+ _dragContainerPx = 0;
6134
+ /** A key is down and has moved the seam; its keyup is the commit. */
6135
+ _keying = false;
6136
+ // ─── Properties ───────────────────────────────────────────────────────
6137
+ /** The primary pane's size, as a percentage of the container. Setting it commits. */
6138
+ get position() {
6139
+ return this._position;
6140
+ }
6141
+ set position(value) {
6142
+ const next = toPercent(String(value), this._position);
6143
+ if (this.isConnected && next === this._lastPosition) return;
6144
+ this._position = next;
6145
+ this._setLive(next);
6146
+ if (this.isConnected) this._commit("api");
6147
+ }
6148
+ /** The container's axis: `horizontal` (side by side) or `vertical` (stacked). */
6149
+ get orientation() {
6150
+ return this.getAttribute("orientation") === "vertical" ? "vertical" : "horizontal";
6151
+ }
6152
+ set orientation(value) {
6153
+ this.setAttribute("orientation", value);
6154
+ }
6155
+ /** Which pane `position` sizes. */
6156
+ get primary() {
6157
+ return this.getAttribute("primary") === "end" ? "end" : "start";
6158
+ }
6159
+ set primary(value) {
6160
+ this.setAttribute("primary", value);
6161
+ }
6162
+ /** The primary pane is folded to its minimum. */
6163
+ get collapsed() {
6164
+ return this.hasAttribute("collapsed");
6165
+ }
6166
+ set collapsed(value) {
6167
+ this.toggleAttribute("collapsed", value);
6168
+ }
6169
+ /** Which pane is shown while the split is stacked. */
6170
+ get pane() {
6171
+ return this.getAttribute("pane") === "end" ? "end" : "start";
6172
+ }
6173
+ set pane(value) {
6174
+ this.setAttribute("pane", value);
6175
+ }
6176
+ /** No drag, no keys, no tab stop. The seam stays drawn. */
6177
+ get disabled() {
6178
+ return this.hasAttribute("disabled");
6179
+ }
6180
+ set disabled(value) {
6181
+ this.toggleAttribute("disabled", value);
6182
+ }
6183
+ /**
6184
+ * True while the split is showing one pane. Read-only.
6185
+ *
6186
+ * Both routes into that state count. `data-stacked` is the one this element writes
6187
+ * from its own breakpoint; `.aparte-split--only-start` / `--only-end` are the CSS
6188
+ * route a host takes when it owns its breakpoints and sets `breakpoint="none"`. The
6189
+ * sheet gives the two byte-identical rules, so the element has to read them the same
6190
+ * way — every guard downstream keys on this getter, and a split stacked by the class
6191
+ * alone would measure a one-track grid and commit `position="100"`.
6192
+ */
6193
+ get stacked() {
6194
+ return this.hasAttribute("data-stacked") || this.classList.contains("aparte-split--only-start") || this.classList.contains("aparte-split--only-end");
6195
+ }
6196
+ // ─── Public API ───────────────────────────────────────────────────────
6197
+ /** Fold the primary pane to its minimum, remembering the size it had. */
6198
+ collapse() {
6199
+ this.collapsed = true;
6200
+ }
6201
+ /** Unfold it, back to the size it had before it collapsed. */
6202
+ expand() {
6203
+ this.collapsed = false;
6204
+ }
6205
+ toggleCollapse() {
6206
+ this.collapsed = !this.collapsed;
6207
+ }
6208
+ /** Show one pane, which only has a visible effect while the split is stacked. */
6209
+ showPane(which) {
6210
+ this.pane = which;
6211
+ }
6212
+ /**
6213
+ * Back to the position the split was FIRST connected with — a re-parent restores
6214
+ * that, it does not redefine it. What a double-click on the seam does.
6215
+ */
6216
+ reset() {
6217
+ this._position = this._initialPosition;
6218
+ this._setLive(this._position);
6219
+ if (this.isConnected) this._commit("api");
6220
+ }
6221
+ // ─── Lifecycle ────────────────────────────────────────────────────────
6222
+ connectedCallback() {
6223
+ if (!this.classList.contains("aparte-split")) this.classList.add("aparte-split");
6224
+ this._handle = this._ensureHandle();
6225
+ const attr = this.getAttribute("position");
6226
+ if (attr !== null) {
6227
+ this._position = toPercent(attr, DEFAULT_POSITION);
6228
+ this._setLive(this._position);
6229
+ } else {
6230
+ this._position = toPercent(
6231
+ getComputedStyle(this).getPropertyValue("--aparte-split-position"),
6232
+ DEFAULT_POSITION
6233
+ );
6234
+ }
6235
+ if (!this._initialCaptured) {
6236
+ this._initialPosition = this._position;
6237
+ this._initialCaptured = true;
6238
+ }
6239
+ if (this.collapsed) {
6240
+ if (this._lastCollapsed !== true) this._preCollapsePosition = this._position;
6241
+ this._position = 0;
6242
+ this._setLive(0);
6243
+ }
6244
+ this._lastPosition = this._position;
6245
+ this._lastCollapsed = this.collapsed;
6246
+ this._lastPane = this.pane;
6247
+ this._stampHandle();
6248
+ this._relabel();
6249
+ this._measureBounds();
6250
+ this._handle.addEventListener("pointerdown", this._onPointerDown);
6251
+ this._handle.addEventListener("dblclick", this._onDoubleClick);
6252
+ this._handle.addEventListener("focusout", this._onFocusOut);
6253
+ this.addEventListener("keydown", this._onKeydown);
6254
+ this.addEventListener("keyup", this._onKeyup);
6255
+ document.addEventListener("click", this._onDocumentClick);
6256
+ window.addEventListener("aparte-config-change", this._onConfigChange);
6257
+ this._watchBreakpoint();
6258
+ this._watchSize();
6259
+ this._ready = true;
6260
+ }
6261
+ disconnectedCallback() {
6262
+ this._handle?.removeEventListener("pointerdown", this._onPointerDown);
6263
+ this._handle?.removeEventListener("dblclick", this._onDoubleClick);
6264
+ this._handle?.removeEventListener("focusout", this._onFocusOut);
6265
+ this.removeEventListener("keydown", this._onKeydown);
6266
+ this.removeEventListener("keyup", this._onKeyup);
6267
+ document.removeEventListener("click", this._onDocumentClick);
6268
+ window.removeEventListener("aparte-config-change", this._onConfigChange);
6269
+ this._releasePointer();
6270
+ this._media?.removeEventListener("change", this._onMediaChange);
6271
+ this._media = null;
6272
+ this._observer?.disconnect();
6273
+ this._observer = null;
6274
+ if (this._measureRafId !== null) {
6275
+ cancelAnimationFrame(this._measureRafId);
6276
+ this._measureRafId = null;
6277
+ }
6278
+ this._removeScrim();
6279
+ this._handle?.removeAttribute("data-dragging");
6280
+ this._dragging = false;
6281
+ this._keying = false;
6282
+ this._ready = false;
6283
+ }
6284
+ attributeChangedCallback(name) {
6285
+ if (!this._ready) return;
6286
+ switch (name) {
6287
+ case "position": {
6288
+ const next = toPercent(this.getAttribute("position"), this._position);
6289
+ if (next === this._lastPosition) return;
6290
+ this._position = next;
6291
+ this._setLive(next);
6292
+ this._commit();
6293
+ return;
6294
+ }
6295
+ case "collapsed": {
6296
+ const collapsed = this.collapsed;
6297
+ if (collapsed === this._lastCollapsed) return;
6298
+ this._lastCollapsed = collapsed;
6299
+ if (collapsed) {
6300
+ this._preCollapsePosition = this._position;
6301
+ this._position = 0;
6302
+ } else {
6303
+ this._position = this._preCollapsePosition ?? this._initialPosition;
6304
+ }
6305
+ this._setLive(this._position);
6306
+ this._commit();
6307
+ return;
6308
+ }
6309
+ case "pane": {
6310
+ const pane = this.pane;
6311
+ if (pane === this._lastPane) return;
6312
+ this._lastPane = pane;
6313
+ this._commit();
6314
+ return;
6315
+ }
6316
+ case "orientation":
6317
+ case "primary":
6318
+ this._stampHandle();
6319
+ this._measureBounds();
6320
+ return;
6321
+ case "disabled":
6322
+ if (this.disabled && this._dragging) this._endDrag("pointer", this._dragStartPercent);
6323
+ this._stampHandle();
6324
+ return;
6325
+ case "breakpoint":
6326
+ this._watchBreakpoint();
6327
+ return;
6328
+ case "label":
6329
+ this._relabel();
6330
+ }
6331
+ }
6332
+ // ─── Structure ────────────────────────────────────────────────────────
6333
+ /**
6334
+ * The seam. An author-written `.aparte-split__handle` is ADOPTED — a second one
6335
+ * would be a second tab stop drawn over the first — otherwise one is inserted
6336
+ * between the first two children, which is where a two-pane grid needs it.
6337
+ *
6338
+ * An adopted one is MOVED there if the author put it elsewhere. The sheet is
6339
+ * positional — three tracks with the seam in the middle, and the stacked rules hide
6340
+ * `:nth-child(1)` / `:nth-child(3)` — so a handle written first would take the
6341
+ * primary pane's track and make stacking hide the wrong child.
6342
+ */
6343
+ _ensureHandle() {
6344
+ const own = Array.from(this.children).find(
6345
+ (child) => child instanceof HTMLElement && child.classList.contains("aparte-split__handle")
6346
+ );
6347
+ if (own) {
6348
+ if (this.children[1] !== own) {
6349
+ const second2 = Array.from(this.children).filter((child) => child !== own)[1] ?? null;
6350
+ this.insertBefore(own, second2);
6351
+ }
6352
+ return own;
6353
+ }
6354
+ const handle = document.createElement("div");
6355
+ handle.className = "aparte-split__handle";
6356
+ const second = this.children[1] ?? null;
6357
+ if (second) this.insertBefore(handle, second);
6358
+ else this.appendChild(handle);
6359
+ return handle;
6360
+ }
6361
+ /** The panes: every element child that is not the seam. */
6362
+ _panes() {
6363
+ return Array.from(this.children).filter(
6364
+ (child) => child instanceof HTMLElement && !child.classList.contains("aparte-split__handle")
6365
+ );
6366
+ }
6367
+ _primaryPane() {
6368
+ const panes = this._panes();
6369
+ return (this.primary === "end" ? panes[panes.length - 1] : panes[0]) ?? null;
6370
+ }
6371
+ /**
6372
+ * The APG's window splitter, plus the two states that take it out of the tab order.
6373
+ *
6374
+ * `aria-orientation` is the INVERSE of `orientation`: the attribute names the
6375
+ * separator's own axis, so a split whose panes sit side by side has a VERTICAL
6376
+ * separator between them.
6377
+ */
6378
+ _stampHandle() {
6379
+ const handle = this._handle;
6380
+ if (!handle) return;
6381
+ handle.setAttribute("role", "separator");
6382
+ handle.setAttribute("aria-orientation", this.orientation === "vertical" ? "horizontal" : "vertical");
6383
+ if (this.disabled || this.stacked) handle.removeAttribute("tabindex");
6384
+ else handle.setAttribute("tabindex", "0");
6385
+ handle.setAttribute("aria-valuenow", String(Math.round(this._achievedPercent())));
6386
+ const pane = this._primaryPane();
6387
+ if (pane) {
6388
+ if (!pane.id) pane.id = `aparte-split-pane-${++AparteSplit._paneIdSeq}`;
6389
+ handle.setAttribute("aria-controls", pane.id);
6390
+ }
6391
+ }
6392
+ // ─── Measurement ──────────────────────────────────────────────────────
6393
+ _measure(el2) {
6394
+ const box = el2.getBoundingClientRect();
6395
+ return this.orientation === "vertical" ? box.height : box.width;
6396
+ }
6397
+ /**
6398
+ * What the pane ACTUALLY got, which is not what was asked for: the CSS clamp between
6399
+ * `--aparte-split-min` and `--aparte-split-max` has the last word, and announcing the
6400
+ * requested value instead is how a splitter comes to tell a screen reader 5% while
6401
+ * the pane sits at 22%. Without layout (jsdom, `display: none`, before first paint)
6402
+ * the request is all there is, so it stands.
6403
+ *
6404
+ * While STACKED there is nothing to read: one pane is `display: none` and the other
6405
+ * spans the single track, so the ratio is 0 or 100 whatever the seam was set to.
6406
+ * Measuring it there is how tapping "Preview" on a phone used to write `position="0"`
6407
+ * into the attribute, the event and the host's storage.
6408
+ */
6409
+ _achievedPercent() {
6410
+ if (this.stacked) return this._position;
6411
+ const total = this._measure(this);
6412
+ const pane = this._primaryPane();
6413
+ if (!pane || !(total > 0)) return this._position;
6414
+ return Math.max(0, Math.min(100, this._measure(pane) / total * 100));
6415
+ }
6416
+ /**
6417
+ * The range the seam can actually reach, written onto the separator: park the pane
6418
+ * at 0% and read it, park it at 100% and read it, put the position back. Two forced
6419
+ * layouts in one synchronous task, so nothing paints in between.
6420
+ *
6421
+ * It also reconciles `aria-valuenow`, because the two have to be one story: the
6422
+ * requested percentage can sit outside the clamped range the probe just announced —
6423
+ * with a `--aparte-split-min` of `20rem`, `position="38"` on a 700px container asks
6424
+ * for 38 and gets ~46 — and a separator whose value is below its own minimum is the
6425
+ * unclamped-announcement bug this probe exists to prevent, just at rest. `_position`
6426
+ * is left alone: it is the REQUESTED value, and `reset()` means it.
6427
+ *
6428
+ * Gated on `!_dragging`: a ResizeObserver tick mid-gesture would move the pane to
6429
+ * both ends under the pointer. Gated on `!stacked` for the reason
6430
+ * `_achievedPercent()` is: a pane that spans everything measures the same at 0% and
6431
+ * at 100%, which would announce a range of one number.
6432
+ */
6433
+ _measureBounds() {
6434
+ const handle = this._handle;
6435
+ if (!handle || this._dragging || this.stacked) return;
6436
+ const pane = this._primaryPane();
6437
+ const total = this._measure(this);
6438
+ if (!pane || !(total > 0)) {
6439
+ handle.setAttribute("aria-valuemin", "0");
6440
+ handle.setAttribute("aria-valuemax", "100");
6441
+ return;
6442
+ }
6443
+ const saved = this.style.getPropertyValue("--aparte-split-position");
6444
+ this.style.setProperty("--aparte-split-position", "0%");
6445
+ const min = this._measure(pane) / total * 100;
6446
+ this.style.setProperty("--aparte-split-position", "100%");
6447
+ const max = this._measure(pane) / total * 100;
6448
+ if (saved) this.style.setProperty("--aparte-split-position", saved);
6449
+ else this.style.removeProperty("--aparte-split-position");
6450
+ handle.setAttribute("aria-valuemin", String(Math.round(min)));
6451
+ handle.setAttribute("aria-valuemax", String(Math.round(max)));
6452
+ handle.setAttribute("aria-valuenow", String(Math.round(this._achievedPercent())));
6453
+ }
6454
+ /**
6455
+ * Re-measure when the container's size changes — and only then. The probe writes the
6456
+ * host's `style` three times, and the reflection is commit-only precisely because a
6457
+ * `MutationObserver({ attributes: true })` (the docs' preview frame has one) reposts
6458
+ * a frame height per mutation, which resizes the split, which re-ticks this observer.
6459
+ * So: ignore a tick that did not move the size, and batch the rest to one per frame.
6460
+ */
6461
+ _watchSize() {
6462
+ if (typeof ResizeObserver !== "function") return;
6463
+ this._observer = new ResizeObserver(() => {
6464
+ const px = this._measure(this);
6465
+ if (Math.abs(px - this._observedPx) <= 1) return;
6466
+ this._observedPx = px;
6467
+ if (this._measureRafId !== null) return;
6468
+ this._measureRafId = requestAnimationFrame(() => {
6469
+ this._measureRafId = null;
6470
+ this._measureBounds();
6471
+ });
6472
+ });
6473
+ this._observer.observe(this);
6474
+ }
6475
+ // ─── The one write, and the one event ─────────────────────────────────
6476
+ _setLive(value) {
6477
+ this.style.setProperty("--aparte-split-position", `${Math.round(value * 1e3) / 1e3}%`);
6478
+ }
6479
+ /**
6480
+ * The commit: reflect, announce, dispatch — once. Everything downstream reads the
6481
+ * ACHIEVED size, so the attribute, `aria-valuenow` and the detail are one number.
6482
+ *
6483
+ * There is no per-frame event on purpose. A bubbling, composed CustomEvent per
6484
+ * pointermove is a reconciler in the drag loop, and no pane in this repo re-lays
6485
+ * itself out in JS — they resize in CSS. The trigger for adding
6486
+ * `aparte-split-resizing` is a consumer whose pane needs a JS measurement mid-drag.
6487
+ */
6488
+ _commit(source = this._source) {
6489
+ const position = Math.round(this._achievedPercent());
6490
+ this._position = position;
6491
+ this._setLive(position);
6492
+ this._lastPosition = position;
6493
+ if (this.getAttribute("position") !== String(position)) this.setAttribute("position", String(position));
6494
+ this._handle?.setAttribute("aria-valuenow", String(position));
6495
+ this.dispatchEvent(
6496
+ new CustomEvent("aparte-split-resize", {
6497
+ detail: {
6498
+ position,
6499
+ collapsed: this.collapsed,
6500
+ stacked: this.stacked,
6501
+ pane: this.pane,
6502
+ orientation: this.orientation,
6503
+ source
6504
+ },
6505
+ bubbles: true,
6506
+ composed: true
6507
+ })
6508
+ );
6509
+ }
6510
+ // ─── Pointer ──────────────────────────────────────────────────────────
6511
+ /** The computed direction, never `document.dir`: a host can flip one subtree. */
6512
+ _rtl() {
6513
+ return getComputedStyle(this).direction === "rtl";
6514
+ }
6515
+ _onPointerDown = (event) => {
6516
+ if (this.disabled || this.stacked || this._dragging) return;
6517
+ if (event.button > 0) return;
6518
+ event.preventDefault();
6519
+ this._handle?.focus();
6520
+ this._dragging = true;
6521
+ this._captured = false;
6522
+ this._moved = false;
6523
+ this._pointerId = event.pointerId;
6524
+ this._dragStartPercent = this._position;
6525
+ this._dragOriginPx = this.orientation === "vertical" ? event.clientY : event.clientX;
6526
+ this._dragContainerPx = this._measure(this);
6527
+ this._handle?.setAttribute("data-dragging", "");
6528
+ this._addScrim();
6529
+ window.addEventListener("pointermove", this._onPointerMove);
6530
+ window.addEventListener("pointerup", this._onPointerUp);
6531
+ window.addEventListener("pointercancel", this._onPointerCancel);
6532
+ };
6533
+ /**
6534
+ * Only the pointer that started the gesture. On a hybrid device a hovering pen or a
6535
+ * stray touch delivers a `pointermove` with `buttons === 0`, which would otherwise
6536
+ * fall into the release branch below and end someone else's mouse drag.
6537
+ */
6538
+ _isOurPointer(event) {
6539
+ return this._pointerId === null || event.pointerId === this._pointerId;
6540
+ }
6541
+ _onPointerMove = (event) => {
6542
+ if (!this._dragging || !this._isOurPointer(event)) return;
6543
+ if (event.buttons === 0) {
6544
+ this._endDrag("pointer");
6545
+ return;
6546
+ }
6547
+ if (!this._captured && this._pointerId !== null) {
6548
+ this._captured = true;
6549
+ try {
6550
+ this._handle?.setPointerCapture(this._pointerId);
6551
+ } catch {
6552
+ }
6553
+ }
6554
+ const point = this.orientation === "vertical" ? event.clientY : event.clientX;
6555
+ this._position = nextPosition({
6556
+ startPercent: this._dragStartPercent,
6557
+ deltaPx: point - this._dragOriginPx,
6558
+ containerPx: this._dragContainerPx,
6559
+ rtl: this._rtl(),
6560
+ vertical: this.orientation === "vertical",
6561
+ primaryEnd: this.primary === "end"
6562
+ });
6563
+ this._moved = true;
6564
+ this._setLive(this._position);
6565
+ };
6566
+ _onPointerUp = (event) => {
6567
+ if (this._isOurPointer(event)) this._endDrag("pointer");
6568
+ };
6569
+ _onPointerCancel = (event) => {
6570
+ if (this._isOurPointer(event)) this._endDrag("pointer");
6571
+ };
6572
+ _endDrag(source, restore) {
6573
+ if (!this._dragging) return;
6574
+ const moved = this._moved;
6575
+ this._dragging = false;
6576
+ this._moved = false;
6577
+ this._releasePointer();
6578
+ this._handle?.removeAttribute("data-dragging");
6579
+ this._removeScrim(!moved);
6580
+ if (restore !== void 0) {
6581
+ this._position = restore;
6582
+ this._setLive(restore);
6583
+ }
6584
+ if (!moved && restore === void 0) return;
6585
+ this._measureBounds();
6586
+ this._commit(source);
6587
+ }
6588
+ _releasePointer() {
6589
+ window.removeEventListener("pointermove", this._onPointerMove);
6590
+ window.removeEventListener("pointerup", this._onPointerUp);
6591
+ window.removeEventListener("pointercancel", this._onPointerCancel);
6592
+ if (this._captured && this._pointerId !== null) {
6593
+ try {
6594
+ this._handle?.releasePointerCapture(this._pointerId);
6595
+ } catch {
6596
+ }
6597
+ }
6598
+ this._captured = false;
6599
+ this._pointerId = null;
6600
+ }
6601
+ /**
6602
+ * The drag overlay: a fixed, transparent child of the HANDLE, alive for one gesture.
6603
+ * Topmost, so it swallows an iframe pane's hit-testing — the case this exists for —
6604
+ * and paints the drag cursor across the page. A child of the handle rather than of
6605
+ * the body so `click`, and therefore `dblclick`, still dispatches at the handle.
6606
+ */
6607
+ _addScrim() {
6608
+ if (this._scrim || !this._handle) return;
6609
+ const scrim = document.createElement("div");
6610
+ scrim.className = "aparte-split__scrim";
6611
+ scrim.setAttribute("aria-hidden", "true");
6612
+ this._handle.appendChild(scrim);
6613
+ this._scrim = scrim;
6614
+ }
6615
+ /**
6616
+ * Take the overlay off.
6617
+ *
6618
+ * `retire` is the press that never moved — the one a click, and then a double-click,
6619
+ * is built from. Its release happened ON the scrim (nothing captured the pointer
6620
+ * yet), and WebKit works out what a click hit by walking the pointerup target's LIVE
6621
+ * ancestors: take the node out here and that chain is rooted nowhere, so no `click`
6622
+ * fires at all, no `dblclick` follows, and the seam never resets. Chromium and Gecko
6623
+ * resolve the target before dispatch and do not care.
6624
+ *
6625
+ * So a retired scrim goes INERT immediately — `pointer-events: none`, which is what
6626
+ * actually matters, since a full-page overlay left hit-testable is a page dead to the
6627
+ * pointer — and leaves the document a moment later. Not on the next task: a first
6628
+ * measurement removed it there and it still raced the click under a loaded machine
6629
+ * (the trace is in `e2e/tests/layout.spec.ts`). The reference is dropped either way,
6630
+ * so the next press builds its own; an inert one on its way out costs nothing.
6631
+ *
6632
+ * Teardown never retires: `disconnectedCallback` takes the live one out at once.
6633
+ */
6634
+ _removeScrim(retire = false) {
6635
+ const scrim = this._scrim;
6636
+ this._scrim = null;
6637
+ if (!scrim) return;
6638
+ if (!retire) {
6639
+ scrim.remove();
6640
+ return;
6641
+ }
6642
+ scrim.style.pointerEvents = "none";
6643
+ setTimeout(() => scrim.remove(), SCRIM_RETIRE_MS);
6644
+ }
6645
+ _onDoubleClick = () => {
6646
+ if (this.disabled || this.stacked) return;
6647
+ this.reset();
6648
+ };
6649
+ // ─── Keyboard ─────────────────────────────────────────────────────────
6650
+ _onKeydown = (event) => {
6651
+ if (this._dragging && event.key === "Escape") {
6652
+ event.preventDefault();
6653
+ this._endDrag("pointer", this._dragStartPercent);
6654
+ return;
6655
+ }
6656
+ if (this.disabled || this.stacked || event.target !== this._handle) return;
6657
+ if (event.key === "Enter") {
6658
+ event.preventDefault();
6659
+ this._source = "keyboard";
6660
+ try {
6661
+ this.toggleCollapse();
6662
+ } finally {
6663
+ this._source = "api";
6664
+ }
6665
+ return;
6666
+ }
6667
+ const delta = keyDelta(
6668
+ event.key,
6669
+ event.shiftKey,
6670
+ this._rtl(),
6671
+ this.orientation === "vertical",
6672
+ this.primary === "end"
6673
+ );
6674
+ if (delta === null) return;
6675
+ event.preventDefault();
6676
+ this._keying = true;
6677
+ this._position = toPercent(String(this._position + delta), this._position);
6678
+ this._setLive(this._position);
6679
+ };
6680
+ /**
6681
+ * The keyup commits, not the keydown: a held arrow repeats, and one release should
6682
+ * write one attribute and send one event, not thirty.
6683
+ */
6684
+ _onKeyup = (event) => {
6685
+ if (!this._keying || event.target !== this._handle) return;
6686
+ if (keyDelta(event.key, event.shiftKey, this._rtl(), this.orientation === "vertical") === null) return;
6687
+ this._keying = false;
6688
+ this._commit("keyboard");
6689
+ };
6690
+ /**
6691
+ * The other end of a key step. If focus leaves the seam while the key is still down —
6692
+ * alt-tab, a click into a pane, a host moving focus — the keyup never arrives, and
6693
+ * the pane would stay where the keydown put it while the attribute and
6694
+ * `aria-valuenow` kept the old number for good. What is rendered is what commits.
6695
+ */
6696
+ _onFocusOut = () => {
6697
+ if (!this._keying) return;
6698
+ this._keying = false;
6699
+ this._commit("keyboard");
6700
+ };
6701
+ // ─── The breakpoint ───────────────────────────────────────────────────
6702
+ /**
6703
+ * The stacking query, from the `breakpoint` attribute: a length (`48rem`, the
6704
+ * default, or `640px`), or `none` for a split that stays two panes at any width —
6705
+ * which a documentation frame needs and a host with its own breakpoint wants.
6706
+ */
6707
+ _watchBreakpoint() {
6708
+ this._media?.removeEventListener("change", this._onMediaChange);
6709
+ this._media = null;
6710
+ const attr = (this.getAttribute("breakpoint") ?? "").trim();
6711
+ if (attr === "none") {
6712
+ this._applyStacked(false);
6713
+ return;
6714
+ }
6715
+ if (typeof matchMedia !== "function") return;
6716
+ const query = attr ? `(max-width: ${attr})` : STACK_QUERY;
6717
+ this._media = matchMedia(query);
6718
+ this._media.addEventListener("change", this._onMediaChange);
6719
+ this._applyStacked(this._media.matches);
6720
+ }
6721
+ _onMediaChange = (e) => this._applyStacked(e.matches);
6722
+ /**
6723
+ * Entering the stacked state shows the START pane: the chat, never a preview of
6724
+ * nothing — the same judgement that makes the sidebar's drawer enter closed. Unless
6725
+ * the markup already named one, which is an answer to the same question and a better
6726
+ * one. Leaving it restores both panes, and with them the seam and its tab stop.
6727
+ *
6728
+ * `was` is the ATTRIBUTE, not the `stacked` getter: the getter also counts
6729
+ * `.aparte-split--only-*`, the CSS route a host takes when it owns its breakpoints
6730
+ * and sets `breakpoint="none"`. Read through the getter, that mount looks like a
6731
+ * split leaving a stacked state it never entered — and the `pane` the author wrote
6732
+ * is deleted on the way in.
6733
+ */
6734
+ _applyStacked(stacked) {
6735
+ const was = this.hasAttribute("data-stacked");
6736
+ if (stacked && !was && this._dragging) this._endDrag("pointer", this._dragStartPercent);
6737
+ this.toggleAttribute("data-stacked", stacked);
6738
+ if (stacked && !was) {
6739
+ if (!this.hasAttribute("pane")) this.showPane("start");
6740
+ } else if (!stacked && was) {
6741
+ this.removeAttribute("pane");
6742
+ }
6743
+ this._lastPane = this.pane;
6744
+ this._stampHandle();
6745
+ if (stacked !== was) this._measureBounds();
6746
+ }
6747
+ /**
6748
+ * A `[data-aparte-split-pane]` anywhere on the page switches a split's pane, so a
6749
+ * two-button toggle in a header needs no script.
6750
+ *
6751
+ * The value is `start` or `end` — the pane to show. Any other non-empty value names
6752
+ * a split's `id` and TOGGLES that one; empty toggles the nearest split, else the
6753
+ * first on the page. Same shape as `[data-aparte-sidebar-toggle]`.
6754
+ */
6755
+ _onDocumentClick = (e) => {
6756
+ const control = e.target?.closest?.("[data-aparte-split-pane]");
6757
+ if (!control) return;
6758
+ const value = (control.getAttribute("data-aparte-split-pane") ?? "").trim();
6759
+ const which = value === "start" || value === "end" ? value : null;
6760
+ const named = which ? "" : value;
6761
+ if (named) {
6762
+ if (named !== this.id) return;
6763
+ } else if ((control.closest("aparte-split") ?? document.querySelector("aparte-split")) !== this) {
6764
+ return;
6765
+ }
6766
+ this.showPane(which ?? (this.pane === "start" ? "end" : "start"));
6767
+ };
6768
+ // ─── Locale ───────────────────────────────────────────────────────────
6769
+ _onConfigChange = () => this._relabel();
6770
+ /**
6771
+ * The seam's accessible name. A host that wrote its own `aria-label` — or an
6772
+ * `aria-labelledby` pointing at a heading — keeps it: `data-own-label` marks the
6773
+ * one we set, so a language switch replaces ours and never theirs.
6774
+ */
6775
+ _relabel() {
6776
+ const handle = this._handle;
6777
+ if (!handle) return;
6778
+ if (handle.hasAttribute("aria-labelledby")) return;
6779
+ if (handle.hasAttribute("aria-label") && handle.dataset["ownLabel"] !== "locale") return;
6780
+ const locale = resolveConfig(this).getLocale();
6781
+ handle.setAttribute("aria-label", this.getAttribute("label") ?? locale.splitHandleLabel ?? "Resize the panes");
6782
+ handle.dataset["ownLabel"] = "locale";
6783
+ }
6784
+ }
6785
+ if (!customElements.get("aparte-split")) {
6786
+ customElements.define("aparte-split", AparteSplit);
6787
+ }
6788
+ const APARTE_ELEMENTS = [
6789
+ // Chat shell + transcript
6790
+ ["aparte-chat", AparteChat],
6791
+ ["aparte-chat-viewport", AparteChatViewport],
6792
+ ["aparte-chat-bubble", AparteChatBubble],
6793
+ ["aparte-chat-status", AparteChatStatus],
6794
+ // Composer and its primitives
6795
+ ["aparte-composer", AparteComposer],
6796
+ ["aparte-composer-input", AparteComposerInput],
6797
+ ["aparte-composer-send", AparteComposerSend],
6798
+ ["aparte-composer-cancel", AparteComposerCancel],
6799
+ ["aparte-composer-attachments", AparteComposerAttachments],
6800
+ ["aparte-composer-add-attachment", AparteComposerAddAttachment],
6801
+ ["aparte-composer-action", AparteComposerAction],
6802
+ ["aparte-composer-toolbar", AparteComposerToolbar],
6803
+ // Standalone components
6804
+ ["aparte-conversation-list", AparteConversationList],
6805
+ ["aparte-elicitation", AparteElicitation],
6806
+ ["aparte-scroll-rail", AparteScrollRail],
6807
+ ["aparte-sidebar", AparteSidebar],
6808
+ ["aparte-split", AparteSplit],
6809
+ ["aparte-suggestions", AparteSuggestions],
6810
+ ["aparte-context", AparteContext],
6811
+ // Primitives
6812
+ ["aparte-select", AparteSelect],
6813
+ ["aparte-option", AparteOption],
6814
+ ["aparte-optgroup", AparteOptgroup],
6815
+ ["aparte-progress-spinner", AparteProgressSpinner],
6816
+ ["aparte-icon", AparteIcon]
6817
+ ];
5019
6818
  function registerAllComponents() {
5020
- const _chat = customElements.get("aparte-chat");
5021
- const _viewport = customElements.get("aparte-chat-viewport");
5022
- const _bubble = customElements.get("aparte-chat-bubble");
5023
- const _status = customElements.get("aparte-chat-status");
5024
- if (!_chat || !_viewport || !_bubble || !_status) {
5025
- console.warn("[Aparte] Some components may not be registered. Ensure all component files are imported.");
6819
+ const missing = APARTE_ELEMENTS.filter(([tag]) => !customElements.get(tag)).map(([tag]) => tag);
6820
+ if (missing.length > 0) {
6821
+ console.warn(
6822
+ `[aparte] ${missing.length} of ${APARTE_ELEMENTS.length} elements are not registered: ${missing.join(", ")}. Import '@aparte/core' at module scope (not behind a lazy import()), and check that your bundler is honouring the package's sideEffects field.`
6823
+ );
5026
6824
  }
5027
6825
  }
6826
+ if (typeof document !== "undefined") installDialogTriggersOnce();
5028
6827
  export {
5029
6828
  A as APARTE_CONFIG_CHANGE,
5030
6829
  a as APARTE_CONVERSATION_SCHEMA_VERSION,
5031
6830
  b as APARTE_DEFAULT_BUBBLE_ACTIONS,
5032
6831
  c as APARTE_DEFAULT_HOST_HANDLERS,
5033
6832
  d as APARTE_DEFAULT_ICON_FALLBACKS,
5034
- e as APARTE_DEFAULT_LOCALE,
6833
+ APARTE_DEFAULT_LOCALE,
5035
6834
  f as APARTE_DEFAULT_UI_EVENTS,
5036
6835
  g as APARTE_HOST_ATTR,
5037
6836
  h as AparteBackendTransport,
@@ -5064,7 +6863,10 @@ export {
5064
6863
  AparteOptgroup,
5065
6864
  AparteOption,
5066
6865
  AparteProgressSpinner,
6866
+ AparteScrollRail,
5067
6867
  AparteSelect,
6868
+ AparteSidebar,
6869
+ AparteSplit,
5068
6870
  s as AparteStreamParser,
5069
6871
  AparteSuggestions,
5070
6872
  t as aparteGlobalConfig,
@@ -5075,38 +6877,39 @@ export {
5075
6877
  y as collectRendererStyles,
5076
6878
  z as contentToText,
5077
6879
  B as contextConfig,
5078
- C as createAparteChatHandler,
5079
- D as createStreamAdapter,
6880
+ copyText,
6881
+ D as createAparteChatHandler,
6882
+ E as createStreamAdapter,
5080
6883
  cssEscape,
5081
- F as declineDefaultRenderers,
5082
- G as defaultSanitizer,
5083
- deriveArtifactKind,
5084
- H as detachConfig,
6884
+ G as declineDefaultRenderers,
6885
+ H as defaultSanitizer,
6886
+ I as detachConfig,
5085
6887
  escapeAttr,
5086
6888
  escapeHtml,
5087
- K as filesToAttachments,
5088
- L as getAllRenderers,
6889
+ L as filesToAttachments,
6890
+ M as getAllRenderers,
5089
6891
  getSegmentRenderer,
5090
6892
  installDefaultRenderersOnce,
6893
+ installDialogTriggersOnce,
5091
6894
  isAwaitingReply,
5092
- P as isFormatAdapter,
5093
- Q as isSafeUrl,
5094
- R as isSegmentSettled,
5095
- S as parseAparteEventStream,
5096
- T as parseMarkdownToSegments,
6895
+ R as isFormatAdapter,
6896
+ S as isSafeUrl,
6897
+ T as isSegmentSettled,
6898
+ U as parseAparteEventStream,
6899
+ V as parseMarkdownToSegments,
5097
6900
  populateBubbleFromMessage,
5098
- V as readableToAsyncIterable,
6901
+ X as readableToAsyncIterable,
5099
6902
  registerAllComponents,
5100
- W as registerDefaultRenderers,
5101
- X as registerSegmentRenderer,
5102
- Y as requestUserInput,
6903
+ Y as registerDefaultRenderers,
6904
+ Z as registerSegmentRenderer,
6905
+ _ as requestUserInput,
5103
6906
  resolveConfig,
5104
6907
  revokeAttachmentUrls,
5105
6908
  runWithConfig,
5106
- a0 as segmentDuration,
5107
- a1 as segmentTiming,
6909
+ a2 as segmentDuration,
6910
+ a3 as segmentTiming,
5108
6911
  subscribeConfigChange,
5109
- a3 as unregisterSegmentRenderer,
6912
+ a5 as unregisterSegmentRenderer,
5110
6913
  uuid
5111
6914
  };
5112
6915
  //# sourceMappingURL=index.js.map