@aparte/core 0.2.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/dist/client/aparte-client.d.ts +326 -0
  4. package/dist/client/aparte-client.d.ts.map +1 -0
  5. package/dist/client/stream-adapter.d.ts +197 -0
  6. package/dist/client/stream-adapter.d.ts.map +1 -0
  7. package/dist/components/bubble/aparte-chat-bubble.d.ts +153 -0
  8. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -0
  9. package/dist/components/bubble/bubble-sync.d.ts +44 -0
  10. package/dist/components/bubble/bubble-sync.d.ts.map +1 -0
  11. package/dist/components/bubble/index.d.ts +4 -0
  12. package/dist/components/bubble/index.d.ts.map +1 -0
  13. package/dist/components/chat/aparte-chat.d.ts +49 -0
  14. package/dist/components/chat/aparte-chat.d.ts.map +1 -0
  15. package/dist/components/chat/index.d.ts +2 -0
  16. package/dist/components/chat/index.d.ts.map +1 -0
  17. package/dist/components/composer/aparte-composer-action.d.ts +37 -0
  18. package/dist/components/composer/aparte-composer-action.d.ts.map +1 -0
  19. package/dist/components/composer/aparte-composer-add-attachment.d.ts +27 -0
  20. package/dist/components/composer/aparte-composer-add-attachment.d.ts.map +1 -0
  21. package/dist/components/composer/aparte-composer-attachments.d.ts +26 -0
  22. package/dist/components/composer/aparte-composer-attachments.d.ts.map +1 -0
  23. package/dist/components/composer/aparte-composer-cancel.d.ts +21 -0
  24. package/dist/components/composer/aparte-composer-cancel.d.ts.map +1 -0
  25. package/dist/components/composer/aparte-composer-input.d.ts +63 -0
  26. package/dist/components/composer/aparte-composer-input.d.ts.map +1 -0
  27. package/dist/components/composer/aparte-composer-send.d.ts +25 -0
  28. package/dist/components/composer/aparte-composer-send.d.ts.map +1 -0
  29. package/dist/components/composer/aparte-composer.d.ts +150 -0
  30. package/dist/components/composer/aparte-composer.d.ts.map +1 -0
  31. package/dist/components/composer/index.d.ts +9 -0
  32. package/dist/components/composer/index.d.ts.map +1 -0
  33. package/dist/components/conversation-list/aparte-conversation-list.d.ts +50 -0
  34. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -0
  35. package/dist/components/conversation-list/index.d.ts +2 -0
  36. package/dist/components/conversation-list/index.d.ts.map +1 -0
  37. package/dist/components/elicitation/aparte-elicitation.d.ts +29 -0
  38. package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -0
  39. package/dist/components/index.d.ts +10 -0
  40. package/dist/components/index.d.ts.map +1 -0
  41. package/dist/components/status/aparte-chat-status.d.ts +34 -0
  42. package/dist/components/status/aparte-chat-status.d.ts.map +1 -0
  43. package/dist/components/status/index.d.ts +2 -0
  44. package/dist/components/status/index.d.ts.map +1 -0
  45. package/dist/components/viewport/aparte-chat-viewport.d.ts +358 -0
  46. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -0
  47. package/dist/components/viewport/index.d.ts +2 -0
  48. package/dist/components/viewport/index.d.ts.map +1 -0
  49. package/dist/config/action-provider.d.ts +59 -0
  50. package/dist/config/action-provider.d.ts.map +1 -0
  51. package/dist/config/aparte-config.d.ts +490 -0
  52. package/dist/config/aparte-config.d.ts.map +1 -0
  53. package/dist/config/attachment-renderer.d.ts +28 -0
  54. package/dist/config/attachment-renderer.d.ts.map +1 -0
  55. package/dist/config/avatar-provider.d.ts +35 -0
  56. package/dist/config/avatar-provider.d.ts.map +1 -0
  57. package/dist/config/bubble-shell-renderer.d.ts +41 -0
  58. package/dist/config/bubble-shell-renderer.d.ts.map +1 -0
  59. package/dist/config/config-context.d.ts +47 -0
  60. package/dist/config/config-context.d.ts.map +1 -0
  61. package/dist/config/error-renderer.d.ts +26 -0
  62. package/dist/config/error-renderer.d.ts.map +1 -0
  63. package/dist/config/icon-provider.d.ts +63 -0
  64. package/dist/config/icon-provider.d.ts.map +1 -0
  65. package/dist/config/index.d.ts +19 -0
  66. package/dist/config/index.d.ts.map +1 -0
  67. package/dist/config/locale.d.ts +60 -0
  68. package/dist/config/locale.d.ts.map +1 -0
  69. package/dist/config/sanitize.d.ts +31 -0
  70. package/dist/config/sanitize.d.ts.map +1 -0
  71. package/dist/config/sibling-nav-renderer.d.ts +27 -0
  72. package/dist/config/sibling-nav-renderer.d.ts.map +1 -0
  73. package/dist/config/skeleton-provider.d.ts +26 -0
  74. package/dist/config/skeleton-provider.d.ts.map +1 -0
  75. package/dist/config/status-renderer.d.ts +23 -0
  76. package/dist/config/status-renderer.d.ts.map +1 -0
  77. package/dist/conversations/conversation-controller.d.ts +126 -0
  78. package/dist/conversations/conversation-controller.d.ts.map +1 -0
  79. package/dist/conversations/conversation-manager.d.ts +98 -0
  80. package/dist/conversations/conversation-manager.d.ts.map +1 -0
  81. package/dist/conversations/index.d.ts +5 -0
  82. package/dist/conversations/index.d.ts.map +1 -0
  83. package/dist/conversations/types.d.ts +172 -0
  84. package/dist/conversations/types.d.ts.map +1 -0
  85. package/dist/custom-elements.json +15833 -0
  86. package/dist/elicitation/index.d.ts +20 -0
  87. package/dist/elicitation/index.d.ts.map +1 -0
  88. package/dist/elicitation/panel.d.ts +18 -0
  89. package/dist/elicitation/panel.d.ts.map +1 -0
  90. package/dist/elicitation/types.d.ts +98 -0
  91. package/dist/elicitation/types.d.ts.map +1 -0
  92. package/dist/host/aparte-chat-host.d.ts +176 -0
  93. package/dist/host/aparte-chat-host.d.ts.map +1 -0
  94. package/dist/host/index.d.ts +2 -0
  95. package/dist/host/index.d.ts.map +1 -0
  96. package/dist/index-D89k-n2Q.js +6294 -0
  97. package/dist/index-D89k-n2Q.js.map +1 -0
  98. package/dist/index.css +2554 -0
  99. package/dist/index.d.ts +62 -0
  100. package/dist/index.d.ts.map +1 -0
  101. package/dist/index.js +3763 -0
  102. package/dist/index.js.map +1 -0
  103. package/dist/index.node.d.ts +53 -0
  104. package/dist/index.node.d.ts.map +1 -0
  105. package/dist/index.node.js +49 -0
  106. package/dist/index.node.js.map +1 -0
  107. package/dist/interop/element-props.d.ts +23 -0
  108. package/dist/interop/element-props.d.ts.map +1 -0
  109. package/dist/parsers/aparte-event-stream.d.ts +13 -0
  110. package/dist/parsers/aparte-event-stream.d.ts.map +1 -0
  111. package/dist/parsers/aparte-stream-parser.d.ts +137 -0
  112. package/dist/parsers/aparte-stream-parser.d.ts.map +1 -0
  113. package/dist/parsers/index.d.ts +4 -0
  114. package/dist/parsers/index.d.ts.map +1 -0
  115. package/dist/primitives/index.d.ts +8 -0
  116. package/dist/primitives/index.d.ts.map +1 -0
  117. package/dist/primitives/progress-spinner/aparte-progress-spinner.d.ts +25 -0
  118. package/dist/primitives/progress-spinner/aparte-progress-spinner.d.ts.map +1 -0
  119. package/dist/primitives/progress-spinner/index.d.ts +2 -0
  120. package/dist/primitives/progress-spinner/index.d.ts.map +1 -0
  121. package/dist/primitives/select/aparte-optgroup.d.ts +32 -0
  122. package/dist/primitives/select/aparte-optgroup.d.ts.map +1 -0
  123. package/dist/primitives/select/aparte-option.d.ts +30 -0
  124. package/dist/primitives/select/aparte-option.d.ts.map +1 -0
  125. package/dist/primitives/select/aparte-select.d.ts +74 -0
  126. package/dist/primitives/select/aparte-select.d.ts.map +1 -0
  127. package/dist/primitives/select/index.d.ts +9 -0
  128. package/dist/primitives/select/index.d.ts.map +1 -0
  129. package/dist/renderers/index.d.ts +5 -0
  130. package/dist/renderers/index.d.ts.map +1 -0
  131. package/dist/renderers/segment-renderers.d.ts +31 -0
  132. package/dist/renderers/segment-renderers.d.ts.map +1 -0
  133. package/dist/runtime/message-repository.d.ts +111 -0
  134. package/dist/runtime/message-repository.d.ts.map +1 -0
  135. package/dist/transport/backend-handler.d.ts +50 -0
  136. package/dist/transport/backend-handler.d.ts.map +1 -0
  137. package/dist/transport/backend-transport.d.ts +39 -0
  138. package/dist/transport/backend-transport.d.ts.map +1 -0
  139. package/dist/transport/direct-transport.d.ts +36 -0
  140. package/dist/transport/direct-transport.d.ts.map +1 -0
  141. package/dist/transport/index.d.ts +6 -0
  142. package/dist/transport/index.d.ts.map +1 -0
  143. package/dist/transport/types.d.ts +73 -0
  144. package/dist/transport/types.d.ts.map +1 -0
  145. package/dist/types/chat.d.ts +223 -0
  146. package/dist/types/chat.d.ts.map +1 -0
  147. package/dist/types/errors.d.ts +25 -0
  148. package/dist/types/errors.d.ts.map +1 -0
  149. package/dist/types/event-map.d.ts +41 -0
  150. package/dist/types/event-map.d.ts.map +1 -0
  151. package/dist/types/events.d.ts +290 -0
  152. package/dist/types/events.d.ts.map +1 -0
  153. package/dist/types/imperative-api.d.ts +38 -0
  154. package/dist/types/imperative-api.d.ts.map +1 -0
  155. package/dist/types/index.d.ts +22 -0
  156. package/dist/types/index.d.ts.map +1 -0
  157. package/dist/types/model-provider.d.ts +229 -0
  158. package/dist/types/model-provider.d.ts.map +1 -0
  159. package/dist/types/models.d.ts +173 -0
  160. package/dist/types/models.d.ts.map +1 -0
  161. package/dist/types/parsers.d.ts +42 -0
  162. package/dist/types/parsers.d.ts.map +1 -0
  163. package/dist/types/providers.d.ts +93 -0
  164. package/dist/types/providers.d.ts.map +1 -0
  165. package/dist/types/segments.d.ts +221 -0
  166. package/dist/types/segments.d.ts.map +1 -0
  167. package/dist/types/theming.d.ts +44 -0
  168. package/dist/types/theming.d.ts.map +1 -0
  169. package/dist/types/tools.d.ts +98 -0
  170. package/dist/types/tools.d.ts.map +1 -0
  171. package/dist/utils/assert-never.d.ts +9 -0
  172. package/dist/utils/assert-never.d.ts.map +1 -0
  173. package/dist/utils/css-escape.d.ts +15 -0
  174. package/dist/utils/css-escape.d.ts.map +1 -0
  175. package/package.json +68 -0
package/dist/index.js ADDED
@@ -0,0 +1,3763 @@
1
+ import { N as resolveConfig, y as getSegmentRenderer, O as runWithConfig, M as MessageRepository, H as populateBubbleFromMessage, d as AparteConfig, p as buildElicitationPanel } from "./index-D89k-n2Q.js";
2
+ import { A, a, b, c, e, f, g, h, i, B, C, D, j, k, l, m, n, o, q, r, s, t, u, v, w, x, z, E, F, G, I, J, K, L, P } from "./index-D89k-n2Q.js";
3
+ class AparteOption extends HTMLElement {
4
+ static get observedAttributes() {
5
+ return ["value", "disabled", "selected", "data-status"];
6
+ }
7
+ connectedCallback() {
8
+ this.setAttribute("role", "option");
9
+ this._updateAriaSelected();
10
+ this._updateStatusDot();
11
+ }
12
+ attributeChangedCallback(name) {
13
+ if (name === "selected") {
14
+ this._updateAriaSelected();
15
+ }
16
+ if (name === "disabled") {
17
+ this.setAttribute("aria-disabled", this.hasAttribute("disabled") ? "true" : "false");
18
+ }
19
+ if (name === "data-status") {
20
+ this._updateStatusDot();
21
+ }
22
+ }
23
+ get value() {
24
+ return this.getAttribute("value") || this.textContent?.trim() || "";
25
+ }
26
+ set value(val) {
27
+ this.setAttribute("value", val);
28
+ }
29
+ get label() {
30
+ const textNode = Array.from(this.childNodes).find((n2) => n2.nodeType === Node.TEXT_NODE);
31
+ return textNode?.textContent?.trim() || this.value;
32
+ }
33
+ get disabled() {
34
+ return this.hasAttribute("disabled");
35
+ }
36
+ set disabled(val) {
37
+ if (val) {
38
+ this.setAttribute("disabled", "");
39
+ } else {
40
+ this.removeAttribute("disabled");
41
+ }
42
+ }
43
+ get selected() {
44
+ return this.hasAttribute("selected");
45
+ }
46
+ set selected(val) {
47
+ if (val) {
48
+ this.setAttribute("selected", "");
49
+ } else {
50
+ this.removeAttribute("selected");
51
+ }
52
+ }
53
+ _updateAriaSelected() {
54
+ this.setAttribute("aria-selected", this.selected ? "true" : "false");
55
+ }
56
+ _updateStatusDot() {
57
+ const status = this.getAttribute("data-status");
58
+ let dot = this.querySelector(".aparte-status-dot");
59
+ if (!status) {
60
+ dot?.remove();
61
+ return;
62
+ }
63
+ if (!dot) {
64
+ dot = document.createElement("span");
65
+ dot.className = "aparte-status-dot";
66
+ dot.setAttribute("aria-hidden", "true");
67
+ this.appendChild(dot);
68
+ }
69
+ dot.setAttribute("data-status", status);
70
+ }
71
+ }
72
+ if (!customElements.get("aparte-option")) {
73
+ customElements.define("aparte-option", AparteOption);
74
+ }
75
+ class AparteOptgroup extends HTMLElement {
76
+ static get observedAttributes() {
77
+ return ["label", "collapsible", "collapsed", "loading"];
78
+ }
79
+ connectedCallback() {
80
+ this.setAttribute("role", "group");
81
+ this._render();
82
+ }
83
+ attributeChangedCallback(name, oldValue, newValue) {
84
+ if (oldValue === newValue) return;
85
+ if (name === "collapsed") {
86
+ this._updateCollapsedState();
87
+ }
88
+ if (this.isConnected) {
89
+ this._render();
90
+ }
91
+ }
92
+ get label() {
93
+ return this.getAttribute("label") || "";
94
+ }
95
+ set label(val) {
96
+ this.setAttribute("label", val);
97
+ }
98
+ get collapsible() {
99
+ return this.hasAttribute("collapsible");
100
+ }
101
+ get collapsed() {
102
+ return this.hasAttribute("collapsed");
103
+ }
104
+ set collapsed(val) {
105
+ if (val) {
106
+ this.setAttribute("collapsed", "");
107
+ } else {
108
+ this.removeAttribute("collapsed");
109
+ }
110
+ }
111
+ get loading() {
112
+ return this.hasAttribute("loading");
113
+ }
114
+ set loading(val) {
115
+ if (val) this.setAttribute("loading", "");
116
+ else this.removeAttribute("loading");
117
+ }
118
+ _render() {
119
+ if (this.label) {
120
+ const existingHeader = this.querySelector(".aparte-optgroup-header");
121
+ if (!existingHeader) {
122
+ const header = document.createElement("div");
123
+ header.className = "aparte-optgroup-header";
124
+ header.setAttribute("aria-label", this.label);
125
+ const labelSpan = document.createElement("span");
126
+ labelSpan.className = "aparte-optgroup-label";
127
+ labelSpan.textContent = this.label;
128
+ header.appendChild(labelSpan);
129
+ if (this.collapsible) {
130
+ const chevron = document.createElement("span");
131
+ chevron.className = "aparte-optgroup-chevron";
132
+ header.appendChild(chevron);
133
+ header.style.cursor = "pointer";
134
+ header.addEventListener("click", (e2) => {
135
+ e2.stopPropagation();
136
+ this._toggleCollapse();
137
+ });
138
+ }
139
+ this.insertBefore(header, this.firstChild);
140
+ }
141
+ }
142
+ this._updateLoadingState();
143
+ this._updateCollapsedState();
144
+ }
145
+ _updateLoadingState() {
146
+ let loader = this.querySelector(".aparte-optgroup-loader");
147
+ if (this.loading) {
148
+ if (!loader) {
149
+ loader = document.createElement("div");
150
+ loader.className = "aparte-optgroup-loader";
151
+ loader.innerHTML = '<span class="aparte-spinner-small"></span> Fetching models...';
152
+ this.appendChild(loader);
153
+ }
154
+ } else if (loader) {
155
+ loader.remove();
156
+ }
157
+ }
158
+ _toggleCollapse() {
159
+ this.collapsed = !this.collapsed;
160
+ this.dispatchEvent(new CustomEvent("aparte-optgroup-toggle", {
161
+ bubbles: true,
162
+ composed: true,
163
+ detail: {
164
+ label: this.label,
165
+ collapsed: this.collapsed
166
+ }
167
+ }));
168
+ this._updateCollapsedState();
169
+ }
170
+ _updateCollapsedState() {
171
+ const options = this.querySelectorAll("aparte-option");
172
+ options.forEach((opt) => {
173
+ opt.style.display = this.collapsed ? "none" : "";
174
+ });
175
+ }
176
+ }
177
+ if (!customElements.get("aparte-optgroup")) {
178
+ customElements.define("aparte-optgroup", AparteOptgroup);
179
+ }
180
+ class AparteSelect extends HTMLElement {
181
+ static _optIdSeq = 0;
182
+ _value = "";
183
+ _isOpen = false;
184
+ _activeIndex = -1;
185
+ _trigger = null;
186
+ _dropdown = null;
187
+ _searchInput = null;
188
+ _observer = null;
189
+ // Bound handlers for cleanup
190
+ _boundHandleDocumentClick = this._handleDocumentClick.bind(this);
191
+ _boundHandleKeydown = this._handleKeydown.bind(this);
192
+ static get observedAttributes() {
193
+ return ["value", "placeholder", "disabled", "grouped", "searchable", "open"];
194
+ }
195
+ connectedCallback() {
196
+ this._value = this.getAttribute("value") || "";
197
+ this._isOpen = this.hasAttribute("open");
198
+ this._render();
199
+ this._setupEventListeners();
200
+ this._setupMutationObserver();
201
+ }
202
+ disconnectedCallback() {
203
+ document.removeEventListener("click", this._boundHandleDocumentClick);
204
+ document.removeEventListener("keydown", this._boundHandleKeydown);
205
+ this._observer?.disconnect();
206
+ }
207
+ attributeChangedCallback(name, oldValue, newValue) {
208
+ if (!this.isConnected) return;
209
+ if (name === "value" && oldValue !== newValue && newValue !== this._value) {
210
+ this._value = newValue || "";
211
+ this._updateTriggerLabel();
212
+ }
213
+ if (name === "open") {
214
+ this._isOpen = this.hasAttribute("open");
215
+ if (this._isOpen) {
216
+ this._dropdown?.removeAttribute("hidden");
217
+ this._searchInput?.focus();
218
+ } else {
219
+ this._dropdown?.setAttribute("hidden", "");
220
+ }
221
+ }
222
+ }
223
+ // ─────────────────────────────────────────────────────────────────────────
224
+ // Public API
225
+ // ─────────────────────────────────────────────────────────────────────────
226
+ get value() {
227
+ return this._value;
228
+ }
229
+ set value(val) {
230
+ if (val === this._value) return;
231
+ const previousValue = this._value;
232
+ this._value = val;
233
+ this.setAttribute("value", val);
234
+ this._updateTriggerLabel();
235
+ this._emitChange(previousValue);
236
+ }
237
+ get open() {
238
+ return this._isOpen;
239
+ }
240
+ set open(val) {
241
+ if (val) {
242
+ this.setAttribute("open", "");
243
+ } else {
244
+ this.removeAttribute("open");
245
+ }
246
+ }
247
+ // ─────────────────────────────────────────────────────────────────────────
248
+ // Rendering
249
+ // ─────────────────────────────────────────────────────────────────────────
250
+ _render() {
251
+ const placeholder = this.getAttribute("placeholder") || "Select...";
252
+ const searchable = this.hasAttribute("searchable");
253
+ if (this.querySelector(".aparte-select-dropdown")) {
254
+ this._updateTriggerLabel();
255
+ return;
256
+ }
257
+ const slottedChildren = Array.from(this.children);
258
+ const trigger = document.createElement("div");
259
+ trigger.className = "aparte-select-trigger";
260
+ trigger.setAttribute("tabindex", "0");
261
+ trigger.setAttribute("role", "combobox");
262
+ trigger.setAttribute("aria-haspopup", "listbox");
263
+ trigger.setAttribute("aria-expanded", "false");
264
+ const labelSpan = document.createElement("span");
265
+ labelSpan.className = "aparte-select-label";
266
+ labelSpan.textContent = placeholder;
267
+ const chevronSpan = document.createElement("span");
268
+ chevronSpan.className = "aparte-select-chevron";
269
+ chevronSpan.innerHTML = `<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><path d="M2.5 4.5L6 8L9.5 4.5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg>`;
270
+ trigger.append(labelSpan, chevronSpan);
271
+ const dropdown = document.createElement("div");
272
+ dropdown.className = "aparte-select-dropdown";
273
+ dropdown.setAttribute("role", "listbox");
274
+ dropdown.hidden = !this._isOpen;
275
+ if (searchable) {
276
+ const searchInput = document.createElement("input");
277
+ searchInput.type = "text";
278
+ searchInput.className = "aparte-select-search";
279
+ searchInput.placeholder = "Search...";
280
+ dropdown.appendChild(searchInput);
281
+ }
282
+ const optionsContainer = document.createElement("div");
283
+ optionsContainer.className = "aparte-select-options";
284
+ slottedChildren.forEach((child) => {
285
+ if (child.tagName === "APARTE-OPTION" || child.tagName === "APARTE-OPTGROUP") {
286
+ optionsContainer.appendChild(child);
287
+ }
288
+ });
289
+ dropdown.appendChild(optionsContainer);
290
+ this._observer?.disconnect();
291
+ this.innerHTML = "";
292
+ this.appendChild(trigger);
293
+ this.appendChild(dropdown);
294
+ this._trigger = trigger;
295
+ this._dropdown = dropdown;
296
+ this._searchInput = dropdown.querySelector(".aparte-select-search");
297
+ if (this.isConnected) {
298
+ this._setupMutationObserver();
299
+ }
300
+ this._updateTriggerLabel();
301
+ }
302
+ _setupMutationObserver() {
303
+ this._observer = new MutationObserver(() => {
304
+ this._updateDropdownContent();
305
+ });
306
+ this._observer.observe(this, { childList: true });
307
+ }
308
+ _updateDropdownContent() {
309
+ if (!this._trigger || !this.contains(this._trigger)) {
310
+ this._render();
311
+ return;
312
+ }
313
+ const optionsContainer = this.querySelector(".aparte-select-options");
314
+ if (!optionsContainer) {
315
+ this._render();
316
+ return;
317
+ }
318
+ const lightChildren = Array.from(this.children).filter(
319
+ (child) => child.className !== "aparte-select-trigger" && child.className !== "aparte-select-dropdown"
320
+ );
321
+ if (lightChildren.length === 0) return;
322
+ this._observer?.disconnect();
323
+ optionsContainer.innerHTML = "";
324
+ lightChildren.forEach((child) => {
325
+ optionsContainer.appendChild(child);
326
+ });
327
+ if (this.isConnected) {
328
+ this._observer?.observe(this, { childList: true });
329
+ }
330
+ this._updateTriggerLabel();
331
+ }
332
+ _setupEventListeners() {
333
+ this._trigger?.addEventListener("click", () => this._toggle());
334
+ this._trigger?.addEventListener("keydown", (e2) => {
335
+ if (e2.key === "Enter" || e2.key === " ") {
336
+ if (this._isOpen) return;
337
+ e2.preventDefault();
338
+ this._toggle();
339
+ }
340
+ if (e2.key === "ArrowDown" && !this._isOpen) {
341
+ e2.preventDefault();
342
+ this._openDropdown();
343
+ }
344
+ });
345
+ this.addEventListener("click", (e2) => {
346
+ const option = e2.target.closest("aparte-option");
347
+ if (option && !option.hasAttribute("disabled")) {
348
+ this._selectOption(option);
349
+ }
350
+ });
351
+ this._searchInput?.addEventListener("input", (e2) => {
352
+ const query = e2.target.value.toLowerCase();
353
+ this._filterOptions(query);
354
+ });
355
+ document.addEventListener("click", this._boundHandleDocumentClick);
356
+ document.addEventListener("keydown", this._boundHandleKeydown);
357
+ }
358
+ _handleDocumentClick(e2) {
359
+ if (!this.contains(e2.target)) {
360
+ this._closeDropdown();
361
+ }
362
+ }
363
+ _handleKeydown(e2) {
364
+ if (!this._isOpen) return;
365
+ const inSearch = document.activeElement === this._searchInput;
366
+ switch (e2.key) {
367
+ case "Escape":
368
+ e2.preventDefault();
369
+ this._closeDropdown();
370
+ this._trigger?.focus();
371
+ break;
372
+ case "ArrowDown":
373
+ e2.preventDefault();
374
+ this._moveActive(1);
375
+ break;
376
+ case "ArrowUp":
377
+ e2.preventDefault();
378
+ this._moveActive(-1);
379
+ break;
380
+ case "Home":
381
+ if (inSearch) break;
382
+ e2.preventDefault();
383
+ this._setActive(0);
384
+ break;
385
+ case "End":
386
+ if (inSearch) break;
387
+ e2.preventDefault();
388
+ this._setActive(this._visibleOptions().length - 1);
389
+ break;
390
+ case "Enter": {
391
+ const active = this._visibleOptions()[this._activeIndex];
392
+ if (active) {
393
+ e2.preventDefault();
394
+ this._selectOption(active);
395
+ }
396
+ break;
397
+ }
398
+ }
399
+ }
400
+ /** Non-disabled, non-filtered options in DOM order. */
401
+ _visibleOptions() {
402
+ return Array.from(this.querySelectorAll("aparte-option")).filter(
403
+ (opt) => !opt.hasAttribute("disabled") && opt.style.display !== "none"
404
+ );
405
+ }
406
+ /** Move the active (keyboard-highlighted) option by `delta`, clamped. */
407
+ _moveActive(delta) {
408
+ const opts = this._visibleOptions();
409
+ if (opts.length === 0) return;
410
+ const base = this._activeIndex < 0 ? delta > 0 ? -1 : 0 : this._activeIndex;
411
+ this._setActive(base + delta);
412
+ }
413
+ /** Highlight the option at `index` (clamped) and point aria-activedescendant at it. */
414
+ _setActive(index) {
415
+ const all = this.querySelectorAll("aparte-option");
416
+ all.forEach((o2) => o2.removeAttribute("data-active"));
417
+ const opts = this._visibleOptions();
418
+ if (opts.length === 0) {
419
+ this._activeIndex = -1;
420
+ this._trigger?.removeAttribute("aria-activedescendant");
421
+ return;
422
+ }
423
+ const clamped = Math.max(0, Math.min(index, opts.length - 1));
424
+ this._activeIndex = clamped;
425
+ const active = opts[clamped];
426
+ if (!active.id) active.id = `aparte-option-${++AparteSelect._optIdSeq}`;
427
+ active.setAttribute("data-active", "");
428
+ this._trigger?.setAttribute("aria-activedescendant", active.id);
429
+ active.scrollIntoView?.({ block: "nearest" });
430
+ }
431
+ /** Clear the keyboard highlight (on close). */
432
+ _clearActive() {
433
+ this._activeIndex = -1;
434
+ this._trigger?.removeAttribute("aria-activedescendant");
435
+ this.querySelectorAll("aparte-option").forEach((o2) => o2.removeAttribute("data-active"));
436
+ }
437
+ // ─────────────────────────────────────────────────────────────────────────
438
+ // Actions
439
+ // ─────────────────────────────────────────────────────────────────────────
440
+ _toggle() {
441
+ if (this._isOpen) {
442
+ this._closeDropdown();
443
+ } else {
444
+ this._openDropdown();
445
+ }
446
+ }
447
+ _openDropdown() {
448
+ if (this.hasAttribute("disabled")) return;
449
+ this._isOpen = true;
450
+ this._dropdown?.removeAttribute("hidden");
451
+ this._trigger?.setAttribute("aria-expanded", "true");
452
+ this.setAttribute("open", "");
453
+ this._updatePosition();
454
+ this._searchInput?.focus();
455
+ const opts = this._visibleOptions();
456
+ const selectedIdx = opts.findIndex((o2) => o2.getAttribute("value") === this._value);
457
+ this._setActive(selectedIdx >= 0 ? selectedIdx : 0);
458
+ this.dispatchEvent(new CustomEvent("aparte-select-open", { bubbles: true }));
459
+ }
460
+ _closeDropdown() {
461
+ this._isOpen = false;
462
+ this._clearActive();
463
+ this._dropdown?.setAttribute("hidden", "");
464
+ this._trigger?.setAttribute("aria-expanded", "false");
465
+ this.removeAttribute("open");
466
+ this.removeAttribute("position");
467
+ if (this._dropdown) {
468
+ this._dropdown.style.top = "";
469
+ this._dropdown.style.bottom = "";
470
+ this._dropdown.style.left = "";
471
+ this._dropdown.style.width = "";
472
+ }
473
+ if (this._searchInput) {
474
+ this._searchInput.value = "";
475
+ this._filterOptions("");
476
+ }
477
+ this.dispatchEvent(new CustomEvent("aparte-select-close", { bubbles: true }));
478
+ }
479
+ _updatePosition() {
480
+ if (!this._dropdown || !this._trigger) return;
481
+ const rect = this._trigger.getBoundingClientRect();
482
+ const dropdownHeight = this._dropdown.offsetHeight || 300;
483
+ const viewportHeight = window.innerHeight;
484
+ const spaceBelow = viewportHeight - rect.bottom;
485
+ const GAP = 4;
486
+ this._dropdown.style.left = `${rect.left}px`;
487
+ this._dropdown.style.width = `${rect.width}px`;
488
+ if (spaceBelow < dropdownHeight && rect.top > dropdownHeight) {
489
+ this._dropdown.style.top = "";
490
+ this._dropdown.style.bottom = `${viewportHeight - rect.top + GAP}px`;
491
+ this.setAttribute("position", "top");
492
+ } else {
493
+ this._dropdown.style.top = `${rect.bottom + GAP}px`;
494
+ this._dropdown.style.bottom = "";
495
+ this.removeAttribute("position");
496
+ }
497
+ }
498
+ _selectOption(option) {
499
+ const value = option.getAttribute("value") || option.textContent?.trim() || "";
500
+ const previousValue = this._value;
501
+ this._value = value;
502
+ this.setAttribute("value", value);
503
+ this._updateTriggerLabel();
504
+ this._closeDropdown();
505
+ this._emitChange(previousValue);
506
+ this._trigger?.focus();
507
+ }
508
+ _updateTriggerLabel() {
509
+ const labelEl = this._trigger?.querySelector(".aparte-select-label");
510
+ if (labelEl) {
511
+ const selectedLabel = this._getSelectedLabel();
512
+ labelEl.textContent = selectedLabel || this.getAttribute("placeholder") || "Select...";
513
+ }
514
+ const options = this.querySelectorAll("aparte-option");
515
+ options.forEach((opt) => {
516
+ const isSelected = opt.getAttribute("value") === this._value;
517
+ if (isSelected) {
518
+ opt.setAttribute("selected", "");
519
+ } else {
520
+ opt.removeAttribute("selected");
521
+ }
522
+ });
523
+ }
524
+ _getSelectedLabel() {
525
+ for (const opt of this.querySelectorAll("aparte-option")) {
526
+ if (opt.getAttribute("value") === this._value) return opt.textContent?.trim() || "";
527
+ }
528
+ return "";
529
+ }
530
+ _filterOptions(query) {
531
+ const options = this.querySelectorAll("aparte-option");
532
+ options.forEach((opt) => {
533
+ const label = opt.textContent?.toLowerCase() || "";
534
+ const matches = label.includes(query);
535
+ opt.style.display = matches ? "" : "none";
536
+ });
537
+ if (this._isOpen) this._setActive(0);
538
+ }
539
+ _emitChange(previousValue) {
540
+ const detail = {
541
+ value: this._value,
542
+ label: this._getSelectedLabel(),
543
+ previousValue
544
+ };
545
+ this.dispatchEvent(new CustomEvent("aparte-select-change", {
546
+ bubbles: true,
547
+ composed: true,
548
+ detail
549
+ }));
550
+ }
551
+ }
552
+ if (!customElements.get("aparte-select")) {
553
+ customElements.define("aparte-select", AparteSelect);
554
+ }
555
+ class AparteProgressSpinner extends HTMLElement {
556
+ static get observedAttributes() {
557
+ return ["value"];
558
+ }
559
+ /** Radius of the SVG circle (viewBox is 0 0 24 24, center at 12,12) */
560
+ _r = 9;
561
+ get _circ() {
562
+ return 2 * Math.PI * this._r;
563
+ }
564
+ connectedCallback() {
565
+ this._render();
566
+ }
567
+ attributeChangedCallback() {
568
+ this._render();
569
+ }
570
+ _render() {
571
+ const raw = this.getAttribute("value");
572
+ const value = raw !== null ? Math.min(100, Math.max(0, parseFloat(raw) || 0)) : null;
573
+ this.setAttribute("role", "progressbar");
574
+ this.setAttribute("aria-valuemin", "0");
575
+ this.setAttribute("aria-valuemax", "100");
576
+ if (value !== null) {
577
+ this.setAttribute("aria-valuenow", String(value));
578
+ } else {
579
+ this.removeAttribute("aria-valuenow");
580
+ }
581
+ const dashoffset = value !== null ? this._circ * (1 - value / 100) : 0;
582
+ const dasharray = value !== null ? `${this._circ.toFixed(2)}` : `${(this._circ * 0.72).toFixed(2)} ${(this._circ * 0.28).toFixed(2)}`;
583
+ this.innerHTML = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><circle class="aparte-spinner-track" cx="12" cy="12" r="${this._r}"/><circle class="aparte-spinner-fill" cx="12" cy="12" r="${this._r}" stroke-dasharray="${dasharray}" stroke-dashoffset="${dashoffset.toFixed(2)}"/></svg>`;
584
+ }
585
+ }
586
+ if (!customElements.get("aparte-progress-spinner")) {
587
+ customElements.define("aparte-progress-spinner", AparteProgressSpinner);
588
+ }
589
+ class AparteChat extends HTMLElement {
590
+ static get observedAttributes() {
591
+ return ["placeholder", "disabled", "center-empty"];
592
+ }
593
+ _observer = null;
594
+ connectedCallback() {
595
+ this._render();
596
+ this._forwardAttr("placeholder");
597
+ this._forwardAttr("disabled");
598
+ this._syncEmptyWatch();
599
+ }
600
+ disconnectedCallback() {
601
+ this._observer?.disconnect();
602
+ this._observer = null;
603
+ }
604
+ attributeChangedCallback(name, oldValue, newValue) {
605
+ if (oldValue === newValue) return;
606
+ if (name === "center-empty") {
607
+ this._syncEmptyWatch();
608
+ return;
609
+ }
610
+ const composer = this.querySelector("aparte-composer");
611
+ if (!composer) return;
612
+ if (newValue !== null) composer.setAttribute(name, newValue);
613
+ else composer.removeAttribute(name);
614
+ }
615
+ /** The message viewport (yours or the default), or `null` before connect. */
616
+ get viewport() {
617
+ return this.querySelector("aparte-chat-viewport");
618
+ }
619
+ /** The composer (yours or the default), or `null` before connect. */
620
+ get composer() {
621
+ return this.querySelector("aparte-composer");
622
+ }
623
+ _render() {
624
+ if (this.hasAttribute("framework-managed")) return;
625
+ if (this.querySelector("aparte-chat-viewport")) return;
626
+ const placeholder = this.getAttribute("placeholder");
627
+ const composerAttrs = (placeholder !== null ? ` placeholder="${this._escapeAttr(placeholder)}"` : "") + (this.hasAttribute("disabled") ? " disabled" : "");
628
+ this.innerHTML = `
629
+ <aparte-chat-viewport></aparte-chat-viewport>
630
+ <aparte-composer${composerAttrs}>
631
+ <div class="aparte-composer-shell">
632
+ <div class="aparte-composer-row">
633
+ <aparte-composer-input></aparte-composer-input>
634
+ <aparte-composer-send></aparte-composer-send>
635
+ </div>
636
+ </div>
637
+ </aparte-composer>
638
+ `;
639
+ }
640
+ /** Set an attribute on the inner composer only when the shell carries it. */
641
+ _forwardAttr(name) {
642
+ if (!this.hasAttribute(name)) return;
643
+ this.querySelector("aparte-composer")?.setAttribute(name, this.getAttribute(name) ?? "");
644
+ }
645
+ /** Start/stop watching the viewport so `center-empty` toggles itself. */
646
+ _syncEmptyWatch() {
647
+ this._observer?.disconnect();
648
+ this._observer = null;
649
+ if (!this.hasAttribute("center-empty")) {
650
+ this.removeAttribute("data-empty");
651
+ return;
652
+ }
653
+ const viewport = this.querySelector("aparte-chat-viewport");
654
+ if (!viewport) return;
655
+ this._updateEmpty();
656
+ this._observer = new MutationObserver(() => this._updateEmpty());
657
+ this._observer.observe(viewport, { childList: true, subtree: true });
658
+ }
659
+ _updateEmpty() {
660
+ const viewport = this.querySelector("aparte-chat-viewport");
661
+ const empty = !viewport || !viewport.querySelector("aparte-chat-bubble");
662
+ this.toggleAttribute("data-empty", empty);
663
+ }
664
+ _escapeAttr(value) {
665
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
666
+ }
667
+ }
668
+ if (!customElements.get("aparte-chat")) {
669
+ customElements.define("aparte-chat", AparteChat);
670
+ }
671
+ function cssEscape(value) {
672
+ return value.replace(/["\\]/g, "\\$&");
673
+ }
674
+ let _warnedNoRenderer = false;
675
+ function warnMissingRenderer(type) {
676
+ if (_warnedNoRenderer) return;
677
+ _warnedNoRenderer = true;
678
+ console.warn(`[aparte] No renderer for segment "${type}". Did you call registerDefaultRenderers() from @aparte/core?`);
679
+ }
680
+ function segmentRenderResultToElement(result) {
681
+ if (result instanceof HTMLElement) return result;
682
+ const wrapper = document.createElement("div");
683
+ wrapper.innerHTML = result;
684
+ return wrapper.firstElementChild;
685
+ }
686
+ const INFO_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>';
687
+ class AparteChatBubble extends HTMLElement {
688
+ _contentEl = null;
689
+ _segmentsEl = null;
690
+ _attachmentsEl = null;
691
+ _actionBarEl = null;
692
+ _branchPickerEl = null;
693
+ _content = "";
694
+ _streaming = false;
695
+ _segments = [];
696
+ _role = "assistant";
697
+ _attachments = [];
698
+ _usage = null;
699
+ /** Cleanup returned by the avatar provider — called on disconnect/re-render. */
700
+ _avatarCleanup = null;
701
+ /** Sibling count for tree-based branch navigation (set by setSiblings()) */
702
+ _siblingCount = 1;
703
+ /** Sibling index for tree-based branch navigation (set by setSiblings()) */
704
+ _siblingIndex = 0;
705
+ /** True while the user-message inline editor is open. */
706
+ _editing = false;
707
+ /** The live inline editor (the composer's contenteditable primitive), present only while `_editing`. */
708
+ _editInput = null;
709
+ static get observedAttributes() {
710
+ return ["role", "data-role", "content", "timestamp", "message-id", "streaming", "name"];
711
+ }
712
+ constructor() {
713
+ super();
714
+ }
715
+ // Rebuild the action bar when the global config changes (e.g. a live skin
716
+ // switch calling setBubbleActions / setIconProvider) so already-rendered
717
+ // bubbles pick up the new per-role actions + icons without being re-created.
718
+ _onConfigChange = (e2) => {
719
+ const detail = e2.detail;
720
+ if (detail?.config && detail.config !== this._cfg) return;
721
+ this._updateActionBar();
722
+ };
723
+ /**
724
+ * Config governing this bubble: the instance config of the nearest
725
+ * `[data-aparte-host]` boundary, else the global singleton. Resolved live
726
+ * (a single `closest()`) rather than cached — the boundary may be attached
727
+ * AFTER this bubble mounts (AparteChatHost.bind() runs post-mount), so a
728
+ * connect-time cache would freeze the wrong config.
729
+ */
730
+ get _cfg() {
731
+ return resolveConfig(this);
732
+ }
733
+ connectedCallback() {
734
+ this._render();
735
+ this._updateContent();
736
+ this._updateTimestamp(this.getAttribute("timestamp"));
737
+ window.addEventListener("aparte-config-change", this._onConfigChange);
738
+ }
739
+ disconnectedCallback() {
740
+ window.removeEventListener("aparte-config-change", this._onConfigChange);
741
+ if (this._avatarCleanup) {
742
+ try {
743
+ this._avatarCleanup();
744
+ } catch {
745
+ }
746
+ this._avatarCleanup = null;
747
+ }
748
+ }
749
+ attributeChangedCallback(name, oldValue, newValue) {
750
+ if (oldValue === newValue) return;
751
+ switch (name) {
752
+ case "role":
753
+ case "data-role":
754
+ if (newValue === "article") return;
755
+ if (newValue === "user" || newValue === "assistant") {
756
+ this._role = newValue;
757
+ this._updateRole();
758
+ }
759
+ break;
760
+ case "content":
761
+ this._content = newValue || "";
762
+ this._updateContent();
763
+ break;
764
+ case "timestamp":
765
+ this._updateTimestamp(newValue);
766
+ break;
767
+ case "streaming":
768
+ this._updateStreaming(newValue !== null && newValue !== "false");
769
+ break;
770
+ case "name":
771
+ this._updateName();
772
+ break;
773
+ }
774
+ }
775
+ // ─────────────────────────────────────────────────────────────────────────
776
+ // Public API
777
+ // ─────────────────────────────────────────────────────────────────────────
778
+ /** Append a token chunk (for streaming) */
779
+ appendToken(chunk) {
780
+ this._content += chunk;
781
+ this._updateContent();
782
+ }
783
+ /** Set content directly */
784
+ setContent(content) {
785
+ this._content = content;
786
+ this.setAttribute("content", content);
787
+ this._updateContent();
788
+ }
789
+ /** Get current content */
790
+ getContent() {
791
+ return this._content;
792
+ }
793
+ /** Set segments for rich content */
794
+ setSegments(segments) {
795
+ this._segments = segments;
796
+ this._renderSegments();
797
+ }
798
+ /** Add a segment */
799
+ addSegment(segment) {
800
+ this._segments.push(segment);
801
+ this._appendSegmentEl(segment);
802
+ }
803
+ /** Update a specific segment */
804
+ updateSegment(segmentId, updates) {
805
+ const index = this._segments.findIndex((s2) => s2.id === segmentId);
806
+ if (index !== -1) {
807
+ const updated = { ...this._segments[index], ...updates };
808
+ this._segments[index] = updated;
809
+ this._applySegmentUpdate(segmentId, updated, updates);
810
+ }
811
+ }
812
+ /** Append content to a segment */
813
+ appendToSegment(segmentId, content) {
814
+ const segment = this._segments.find((s2) => s2.id === segmentId);
815
+ if (segment && "content" in segment) {
816
+ segment.content += content;
817
+ this._applySegmentUpdate(segmentId, segment, { content: segment.content });
818
+ }
819
+ }
820
+ /** Get all segments */
821
+ getSegments() {
822
+ return [...this._segments];
823
+ }
824
+ /** Remove a segment by id (e.g. to discard a transient waiting indicator) */
825
+ removeSegment(segmentId) {
826
+ const index = this._segments.findIndex((s2) => s2.id === segmentId);
827
+ if (index !== -1) {
828
+ this._segments.splice(index, 1);
829
+ }
830
+ const el = this._segmentsEl?.querySelector(`[data-segment-id="${cssEscape(segmentId)}"]`);
831
+ el?.remove();
832
+ }
833
+ /** Set attachments (chips shown above message content, user role only) */
834
+ setAttachments(attachments) {
835
+ this._attachments = attachments;
836
+ this._updateAttachments();
837
+ }
838
+ /**
839
+ * Set token usage + timing for this message (assistant only).
840
+ * Renders the info ("i") action in the action bar; clicking it opens
841
+ * the app-owned stats popover (`aparte-message-info`).
842
+ */
843
+ setUsage(usage) {
844
+ this._usage = usage ?? null;
845
+ this._updateActionBar();
846
+ }
847
+ /**
848
+ * Update the branch picker UI for tree-based navigation.
849
+ * The viewport calls this after a branch switch or re-render.
850
+ * Prev/Next clicks dispatch `aparte-branch-navigate` (bubbles: true) so
851
+ * the viewport can handle the actual tree switch.
852
+ */
853
+ setSiblings(count, index) {
854
+ this._siblingCount = count;
855
+ this._siblingIndex = index;
856
+ this._updateBranchPicker();
857
+ }
858
+ /**
859
+ * Atomic update for the message
860
+ */
861
+ updateMessage(updates) {
862
+ if ("role" in updates) {
863
+ this._role = updates.role;
864
+ this._updateRole();
865
+ }
866
+ if ("content" in updates) {
867
+ this._content = updates.content;
868
+ this._updateContent();
869
+ }
870
+ if ("segments" in updates) {
871
+ this._segments = updates.segments;
872
+ this._renderSegments();
873
+ }
874
+ if ("timestamp" in updates) {
875
+ this._updateTimestamp(updates.timestamp);
876
+ }
877
+ if ("status" in updates) {
878
+ const isStreaming = updates.status === "streaming" || updates.status === "pending";
879
+ this._updateStreaming(isStreaming);
880
+ }
881
+ if ("attachments" in updates) {
882
+ this._attachments = updates.attachments ?? [];
883
+ this._updateAttachments();
884
+ }
885
+ }
886
+ // ─────────────────────────────────────────────────────────────────────────
887
+ // Private Methods
888
+ // ─────────────────────────────────────────────────────────────────────────
889
+ _appendSegmentEl(segment) {
890
+ if (!this._segmentsEl) {
891
+ console.warn(`[AparteChatBubble] _appendSegmentEl ABORT: _segmentsEl is null`);
892
+ return;
893
+ }
894
+ const renderer = getSegmentRenderer(segment.type);
895
+ if (renderer) {
896
+ const el = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)));
897
+ if (el) {
898
+ this._segmentsEl.appendChild(el);
899
+ runWithConfig(this._cfg, () => renderer.setup?.(el, segment));
900
+ }
901
+ } else {
902
+ warnMissingRenderer(segment.type);
903
+ const fallback = document.createElement("div");
904
+ fallback.className = "segment segment-unknown";
905
+ fallback.textContent = `[Unknown segment type: ${segment.type}]`;
906
+ this._segmentsEl.appendChild(fallback);
907
+ }
908
+ if (this._contentEl) this._contentEl.style.display = "none";
909
+ this._reflectError();
910
+ }
911
+ _applySegmentUpdate(segmentId, segment, updates) {
912
+ const el = this._segmentsEl?.querySelector(`[data-segment-id="${cssEscape(segmentId)}"]`);
913
+ if (!el) {
914
+ this._renderSegments();
915
+ return;
916
+ }
917
+ const renderer = getSegmentRenderer(segment.type);
918
+ if (!renderer) return;
919
+ if (renderer.update) {
920
+ runWithConfig(this._cfg, () => renderer.update(el, segment));
921
+ } else {
922
+ const newEl = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)));
923
+ if (newEl) {
924
+ el.replaceWith(newEl);
925
+ runWithConfig(this._cfg, () => renderer.setup?.(newEl, segment));
926
+ }
927
+ }
928
+ if ("collapsed" in updates) {
929
+ if (updates.collapsed) {
930
+ el.removeAttribute("open");
931
+ } else {
932
+ el.setAttribute("open", "");
933
+ }
934
+ }
935
+ }
936
+ _getDisplayName() {
937
+ const nameAttr = this.getAttribute("name");
938
+ if (nameAttr) return nameAttr;
939
+ const locale = this._cfg.getLocale();
940
+ return this._role === "user" ? locale.roleNameUser ?? "You" : locale.roleNameAssistant ?? "Assistant";
941
+ }
942
+ _getAvatarInitial() {
943
+ const name = this._getDisplayName();
944
+ return name.length > 0 ? name[0] : this._role === "user" ? "U" : "A";
945
+ }
946
+ _render() {
947
+ const dataRole = this.getAttribute("data-role");
948
+ const legacyRole = this.getAttribute("role");
949
+ const role = dataRole && dataRole !== "article" ? dataRole : legacyRole && legacyRole !== "article" ? legacyRole : "assistant";
950
+ this._role = role;
951
+ if (this.getAttribute("role") !== "article") {
952
+ this.setAttribute("role", "article");
953
+ }
954
+ if (!this.hasAttribute("data-role")) {
955
+ this.setAttribute("data-role", role);
956
+ }
957
+ if (this.querySelector(".aparte-message")) return;
958
+ const displayName = this._getDisplayName();
959
+ const initial = this._getAvatarInitial();
960
+ const shell = this._cfg.getBubbleShellRenderer?.();
961
+ if (shell) {
962
+ const out = runWithConfig(this._cfg, () => shell({ role: this._role, name: displayName, avatarInitial: initial }));
963
+ if (out instanceof HTMLElement) this.replaceChildren(out);
964
+ else this.innerHTML = out;
965
+ } else {
966
+ this.innerHTML = `
967
+ <div class="aparte-message" data-role="${role}" role="article" aria-label="${this._getAriaLabel()}">
968
+ <div class="aparte-avatar" data-role="${role}"></div>
969
+ <div class="aparte-body">
970
+ <div class="aparte-header">
971
+ <span class="aparte-name">${this._escapeHtml(displayName)}</span>
972
+ <span class="aparte-timestamp"></span>
973
+ </div>
974
+ <div class="aparte-attachments" hidden></div>
975
+ <div class="aparte-message-content">
976
+ <div class="aparte-segments"></div>
977
+ <div class="aparte-content"></div>
978
+ </div>
979
+ <div class="aparte-footer">
980
+ <div class="aparte-branch-picker" hidden>
981
+ <button class="aparte-branch-prev" aria-label="${this._cfg.getLocale().previousResponse ?? "Previous response"}">&#8249;</button>
982
+ <span class="aparte-branch-label">1 / 1</span>
983
+ <button class="aparte-branch-next" aria-label="${this._cfg.getLocale().nextResponse ?? "Next response"}">&#8250;</button>
984
+ </div>
985
+ <div class="aparte-action-bar" role="toolbar" aria-label="${this._cfg.getLocale().messageActions ?? "Message actions"}"></div>
986
+ </div>
987
+ </div>
988
+ </div>
989
+ `;
990
+ }
991
+ this._contentEl = this.querySelector(".aparte-content");
992
+ this._segmentsEl = this.querySelector(".aparte-segments");
993
+ this._attachmentsEl = this.querySelector(".aparte-attachments");
994
+ this._actionBarEl = this.querySelector(".aparte-action-bar");
995
+ this._branchPickerEl = this.querySelector(".aparte-branch-picker");
996
+ this._setupBranchPickerListeners();
997
+ this._updateActionBar();
998
+ this._renderAvatar();
999
+ }
1000
+ /**
1001
+ * Hand the avatar host element off to the registered AvatarProvider, if
1002
+ * any. Falls back to the default initial / image rendered by `_render()`
1003
+ * when no provider is set.
1004
+ */
1005
+ _renderAvatar() {
1006
+ const avatar = this.querySelector(".aparte-avatar");
1007
+ if (!avatar) return;
1008
+ if (this._avatarCleanup) {
1009
+ try {
1010
+ this._avatarCleanup();
1011
+ } catch {
1012
+ }
1013
+ this._avatarCleanup = null;
1014
+ }
1015
+ const provider = this._cfg.getAvatarProvider();
1016
+ if (!provider) return;
1017
+ avatar.textContent = "";
1018
+ const cleanup = provider.render(this._role, avatar);
1019
+ if (typeof cleanup === "function") this._avatarCleanup = cleanup;
1020
+ }
1021
+ _updateRole() {
1022
+ const message = this.querySelector(".aparte-message");
1023
+ const avatar = this.querySelector(".aparte-avatar");
1024
+ const nameEl = this.querySelector(".aparte-name");
1025
+ if (message) {
1026
+ message.setAttribute("data-role", this._role);
1027
+ message.setAttribute("aria-label", this._getAriaLabel());
1028
+ }
1029
+ if (avatar) {
1030
+ avatar.setAttribute("data-role", this._role);
1031
+ avatar.textContent = this._getAvatarInitial();
1032
+ }
1033
+ if (nameEl) {
1034
+ nameEl.textContent = this._getDisplayName();
1035
+ }
1036
+ this._updateActionBar();
1037
+ this._renderAvatar();
1038
+ }
1039
+ _updateName() {
1040
+ const avatar = this.querySelector(".aparte-avatar");
1041
+ const nameEl = this.querySelector(".aparte-name");
1042
+ if (avatar && !this._cfg.getAvatarProvider()) avatar.textContent = this._getAvatarInitial();
1043
+ if (nameEl) nameEl.textContent = this._getDisplayName();
1044
+ }
1045
+ _updateContent() {
1046
+ if (!this._contentEl) return;
1047
+ if (this._segments.length > 0) {
1048
+ this._contentEl.style.display = "none";
1049
+ return;
1050
+ }
1051
+ this._contentEl.style.display = "";
1052
+ this._contentEl.innerHTML = this._cfg.renderMarkdown(this._content);
1053
+ if (!this._streaming) this._highlightContentCode();
1054
+ }
1055
+ /**
1056
+ * Apply the registered syntax-highlight provider to the code blocks produced
1057
+ * by the Markdown provider in the simple-content path. Provider-agnostic: a
1058
+ * full-block provider (e.g. Shiki) returns `<pre>…</pre>` so we replace the
1059
+ * element; a token provider (e.g. Prism, highlight.js) returns inner HTML so
1060
+ * we fill the existing `<code>`. No-op when no highlighter is installed.
1061
+ */
1062
+ _highlightContentCode() {
1063
+ if (!this._contentEl || !this._cfg.hasHighlightProvider()) return;
1064
+ this._contentEl.querySelectorAll("pre > code").forEach((codeEl) => {
1065
+ const code = codeEl.textContent ?? "";
1066
+ if (!code.trim()) return;
1067
+ const match = codeEl.className.match(/language-([\w+#-]+)/i);
1068
+ const lang = match?.[1] ?? "";
1069
+ const pre = codeEl.parentElement;
1070
+ Promise.resolve(this._cfg.highlightCode(code, lang)).then((html) => {
1071
+ const out = (html ?? "").trim();
1072
+ if (!out || !pre || !pre.isConnected) return;
1073
+ if (/^<pre[\s>]/i.test(out)) {
1074
+ pre.outerHTML = out;
1075
+ } else {
1076
+ codeEl.innerHTML = out;
1077
+ }
1078
+ }).catch(() => {
1079
+ });
1080
+ });
1081
+ }
1082
+ _renderSegments() {
1083
+ if (!this._segmentsEl) return;
1084
+ this._segmentsEl.innerHTML = "";
1085
+ for (const segment of this._segments) {
1086
+ const renderer = getSegmentRenderer(segment.type);
1087
+ if (renderer) {
1088
+ const el = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)));
1089
+ if (el) {
1090
+ this._segmentsEl.appendChild(el);
1091
+ runWithConfig(this._cfg, () => renderer.setup?.(el, segment));
1092
+ }
1093
+ } else {
1094
+ warnMissingRenderer(segment.type);
1095
+ const fallback = document.createElement("div");
1096
+ fallback.className = "segment segment-unknown";
1097
+ fallback.textContent = `[Unknown segment type: ${segment.type}]`;
1098
+ this._segmentsEl.appendChild(fallback);
1099
+ }
1100
+ }
1101
+ if (this._contentEl) {
1102
+ this._contentEl.style.display = this._segments.length > 0 ? "none" : "";
1103
+ }
1104
+ this._reflectError();
1105
+ }
1106
+ /**
1107
+ * Reflect the error state on the bubble: `data-error` on `.aparte-message` while
1108
+ * an error segment is present. Derived from segments (not the message `status`
1109
+ * attribute) so it works identically in vanilla and in every wrapper — the
1110
+ * error segment flows through the reactive list in all of them. CSS themes
1111
+ * `.aparte-message[data-error]`; custom error content is via setErrorRenderer.
1112
+ */
1113
+ _reflectError() {
1114
+ const message = this.querySelector(".aparte-message");
1115
+ if (!message) return;
1116
+ const hasError = this._segments.some((s2) => s2.type === "error");
1117
+ if (hasError) message.setAttribute("data-error", "");
1118
+ else message.removeAttribute("data-error");
1119
+ }
1120
+ _updateTimestamp(value) {
1121
+ const timestampEl = this.querySelector(".aparte-timestamp");
1122
+ if (!timestampEl || !value) return;
1123
+ try {
1124
+ const date = new Date(isNaN(Number(value)) ? value : Number(value));
1125
+ timestampEl.textContent = date.toLocaleTimeString(void 0, {
1126
+ hour: "2-digit",
1127
+ minute: "2-digit"
1128
+ });
1129
+ } catch {
1130
+ timestampEl.textContent = "";
1131
+ }
1132
+ }
1133
+ _getAriaLabel() {
1134
+ const locale = this._cfg.getLocale();
1135
+ return this._role === "user" ? locale.yourMessage ?? "Your message" : locale.assistantResponse ?? "Assistant response";
1136
+ }
1137
+ // ─────────────────────────────────────────────────────────────────────────
1138
+ // Attachments
1139
+ // ─────────────────────────────────────────────────────────────────────────
1140
+ _updateAttachments() {
1141
+ if (!this._attachmentsEl) return;
1142
+ if (this._role !== "user" || this._attachments.length === 0) {
1143
+ this._attachmentsEl.hidden = true;
1144
+ this._attachmentsEl.innerHTML = "";
1145
+ return;
1146
+ }
1147
+ this._attachmentsEl.hidden = false;
1148
+ const customAttachment = this._cfg.getAttachmentRenderer?.();
1149
+ if (customAttachment) {
1150
+ this._attachmentsEl.replaceChildren();
1151
+ for (const a2 of this._attachments) {
1152
+ const el = segmentRenderResultToElement(runWithConfig(this._cfg, () => customAttachment(a2)));
1153
+ if (el) this._attachmentsEl.appendChild(el);
1154
+ }
1155
+ return;
1156
+ }
1157
+ this._attachmentsEl.innerHTML = this._attachments.map((a2) => {
1158
+ const name = this._escapeHtml(a2.name);
1159
+ if (a2.type.startsWith("image/")) {
1160
+ return `<div class="aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${this._escapeHtml(a2.url)}" alt="${name}" loading="lazy" /><span class="aparte-thumb__name">${name}</span></div>`;
1161
+ }
1162
+ return `<div class="aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${this._escapeHtml(this._fileExt(a2.name))}</span><span class="aparte-thumb__name">${name}</span></div>`;
1163
+ }).join("");
1164
+ this._attachmentsEl.querySelectorAll(".aparte-thumb--image").forEach((tile) => {
1165
+ tile.addEventListener("click", () => {
1166
+ const img = tile.querySelector(".aparte-thumb__img");
1167
+ if (!img) return;
1168
+ this.dispatchEvent(new CustomEvent("aparte-attachment-preview", {
1169
+ bubbles: true,
1170
+ composed: true,
1171
+ detail: { url: img.src, name: tile.getAttribute("title") ?? "" }
1172
+ }));
1173
+ });
1174
+ });
1175
+ }
1176
+ /** Uppercased file extension (≤4 chars), or 'FILE' when there is none. */
1177
+ _fileExt(filename) {
1178
+ const dot = filename.lastIndexOf(".");
1179
+ return dot > 0 ? filename.slice(dot + 1).toUpperCase().slice(0, 4) : "FILE";
1180
+ }
1181
+ // ─────────────────────────────────────────────────────────────────────────
1182
+ // Branch Picker
1183
+ // ─────────────────────────────────────────────────────────────────────────
1184
+ _setupBranchPickerListeners() {
1185
+ const prevBtn = this._branchPickerEl?.querySelector(".aparte-branch-prev");
1186
+ const nextBtn = this._branchPickerEl?.querySelector(".aparte-branch-next");
1187
+ const dispatchNav = (direction) => {
1188
+ const messageId = this.getAttribute("message-id");
1189
+ if (!messageId) return;
1190
+ const detail = { messageId, direction };
1191
+ this.dispatchEvent(new CustomEvent("aparte-branch-navigate", {
1192
+ bubbles: true,
1193
+ composed: true,
1194
+ detail
1195
+ }));
1196
+ };
1197
+ prevBtn?.addEventListener("click", () => dispatchNav("prev"));
1198
+ nextBtn?.addEventListener("click", () => dispatchNav("next"));
1199
+ }
1200
+ _updateBranchPicker() {
1201
+ if (!this._branchPickerEl) return;
1202
+ if (this._siblingCount <= 1 || this._role !== "assistant") {
1203
+ this._branchPickerEl.hidden = true;
1204
+ return;
1205
+ }
1206
+ this._branchPickerEl.hidden = false;
1207
+ const label = this._branchPickerEl.querySelector(".aparte-branch-label");
1208
+ if (label) {
1209
+ const customNav = this._cfg.getSiblingNavRenderer?.();
1210
+ if (customNav) {
1211
+ const out = runWithConfig(this._cfg, () => customNav({ count: this._siblingCount, index: this._siblingIndex }));
1212
+ if (out instanceof HTMLElement) label.replaceChildren(out);
1213
+ else label.innerHTML = out;
1214
+ } else {
1215
+ label.textContent = `${this._siblingIndex + 1} / ${this._siblingCount}`;
1216
+ }
1217
+ }
1218
+ const prevBtn = this._branchPickerEl.querySelector(".aparte-branch-prev");
1219
+ const nextBtn = this._branchPickerEl.querySelector(".aparte-branch-next");
1220
+ if (prevBtn) prevBtn.disabled = this._siblingIndex === 0;
1221
+ if (nextBtn) nextBtn.disabled = this._siblingIndex === this._siblingCount - 1;
1222
+ }
1223
+ // ─────────────────────────────────────────────────────────────────────────
1224
+ // Action Bar
1225
+ // ─────────────────────────────────────────────────────────────────────────
1226
+ _updateActionBar() {
1227
+ if (!this._actionBarEl) return;
1228
+ if (this._editing) {
1229
+ this._renderEditActions();
1230
+ return;
1231
+ }
1232
+ const config = this._cfg.getBubbleActions();
1233
+ const icons = this._cfg.getIconProvider();
1234
+ const locale = this._cfg.getLocale();
1235
+ const buttons = [];
1236
+ if (this._role === "user") {
1237
+ if (config.user) {
1238
+ for (const a2 of config.user) buttons.push(this._actionButtonHtml(a2, icons, locale));
1239
+ } else {
1240
+ if (config.copy) buttons.push(this._actionButtonHtml("copy", icons, locale));
1241
+ if (config.edit) buttons.push(this._actionButtonHtml("edit", icons, locale));
1242
+ }
1243
+ } else if (this._role === "assistant") {
1244
+ if (config.assistant) {
1245
+ for (const a2 of config.assistant) buttons.push(this._actionButtonHtml(a2, icons, locale));
1246
+ } else {
1247
+ if (config.copy) buttons.push(this._actionButtonHtml("copy", icons, locale));
1248
+ if (config.retry) buttons.push(this._actionButtonHtml("retry", icons, locale));
1249
+ if (config.feedback) {
1250
+ buttons.push(this._actionButtonHtml("thumbUp", icons, locale));
1251
+ buttons.push(this._actionButtonHtml("thumbDown", icons, locale));
1252
+ }
1253
+ if (this._usage) {
1254
+ const infoLabel = locale.messageInfo ?? "Details";
1255
+ buttons.push(`<button class="aparte-action-btn aparte-action-info" data-action="info" aria-label="${infoLabel}" title="${infoLabel}">
1256
+ ${INFO_ICON_SVG}
1257
+ </button>`);
1258
+ }
1259
+ }
1260
+ }
1261
+ this._actionBarEl.innerHTML = buttons.join("");
1262
+ this._appendCustomActions(icons);
1263
+ this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
1264
+ btn.addEventListener("click", (e2) => this._handleActionClick(e2));
1265
+ });
1266
+ }
1267
+ /** Append the registered custom action buttons for this bubble's role. */
1268
+ _appendCustomActions(icons) {
1269
+ if (!this._actionBarEl) return;
1270
+ for (const a2 of this._cfg.getActions("bubble")) {
1271
+ const roles = a2.bubble?.roles ?? ["user", "assistant"];
1272
+ if (!roles.includes(this._role)) continue;
1273
+ const btn = document.createElement("button");
1274
+ btn.className = "aparte-action-btn aparte-action-custom";
1275
+ btn.dataset["action"] = `custom:${a2.id}`;
1276
+ btn.setAttribute("aria-label", a2.label);
1277
+ btn.setAttribute("title", a2.label);
1278
+ const fromProvider = icons[a2.icon];
1279
+ btn.innerHTML = a2.icon.startsWith("<") ? a2.icon : typeof fromProvider === "function" ? fromProvider() : a2.iconFallback ?? "";
1280
+ this._actionBarEl.appendChild(btn);
1281
+ }
1282
+ }
1283
+ /** Build the `<button>` HTML for a single named action (shared by flag + per-role rendering). */
1284
+ _actionButtonHtml(action, icons, locale) {
1285
+ switch (action) {
1286
+ case "copy": {
1287
+ const l2 = locale.copy ?? "Copy";
1288
+ return `<button class="aparte-action-btn aparte-action-copy" data-action="copy" aria-label="${l2}" title="${l2}">${icons.copy()}</button>`;
1289
+ }
1290
+ case "edit": {
1291
+ const l2 = locale.edit ?? "Edit message";
1292
+ return `<button class="aparte-action-btn aparte-action-edit" data-action="edit" aria-label="${l2}" title="${l2}">${icons.edit()}</button>`;
1293
+ }
1294
+ case "retry": {
1295
+ const l2 = locale.retry ?? "Retry";
1296
+ return `<button class="aparte-action-btn aparte-action-retry" data-action="retry" aria-label="${l2}" title="${l2}">${icons.retry()}</button>`;
1297
+ }
1298
+ case "thumbUp": {
1299
+ const l2 = locale.feedbackPositive ?? "Good response";
1300
+ return `<button class="aparte-action-btn aparte-action-feedback-pos" data-action="feedback-positive" aria-label="${l2}" title="${l2}">${icons.thumbUp()}</button>`;
1301
+ }
1302
+ case "thumbDown": {
1303
+ const l2 = locale.feedbackNegative ?? "Bad response";
1304
+ return `<button class="aparte-action-btn aparte-action-feedback-neg" data-action="feedback-negative" aria-label="${l2}" title="${l2}">${icons.thumbDown()}</button>`;
1305
+ }
1306
+ default:
1307
+ return "";
1308
+ }
1309
+ }
1310
+ /** Render the edit-mode action bar: ✓ save (green) + ✗ cancel (red). */
1311
+ _renderEditActions() {
1312
+ if (!this._actionBarEl) return;
1313
+ const locale = this._cfg.getLocale();
1314
+ const saveLabel = locale.editConfirm ?? "Save";
1315
+ const cancelLabel = locale.editCancel ?? "Cancel";
1316
+ this._actionBarEl.innerHTML = `<button class="aparte-action-btn aparte-action-edit-save" data-action="edit-save" aria-label="${saveLabel}" title="${saveLabel}">${this._cfg.getIcon("check")}</button><button class="aparte-action-btn aparte-action-edit-cancel" data-action="edit-cancel" aria-label="${cancelLabel}" title="${cancelLabel}">${this._cfg.getIcon("close")}</button>`;
1317
+ this._actionBarEl.querySelectorAll(".aparte-action-btn").forEach((btn) => {
1318
+ btn.addEventListener("click", (e2) => this._handleActionClick(e2));
1319
+ });
1320
+ }
1321
+ _handleActionClick(e2) {
1322
+ const btn = e2.currentTarget;
1323
+ const action = btn.dataset["action"];
1324
+ const messageId = this.getAttribute("message-id");
1325
+ if (action?.startsWith("custom:") && messageId) {
1326
+ const actionId = action.slice("custom:".length);
1327
+ const detail = {
1328
+ actionId,
1329
+ zone: "bubble",
1330
+ messageId,
1331
+ role: this._role,
1332
+ targetId: this._resolveTargetId()
1333
+ };
1334
+ this.dispatchEvent(new CustomEvent("aparte-action", {
1335
+ bubbles: true,
1336
+ composed: true,
1337
+ detail
1338
+ }));
1339
+ this._cfg.getActions("bubble").find((x2) => x2.id === actionId)?.onClick?.(e2);
1340
+ return;
1341
+ }
1342
+ switch (action) {
1343
+ case "copy": {
1344
+ const text = this._content || this._segments.map((s2) => s2.content ?? "").join("\n");
1345
+ const icons = this._cfg.getIconProvider();
1346
+ const locale = this._cfg.getLocale();
1347
+ navigator.clipboard.writeText(text).then(() => {
1348
+ btn.innerHTML = icons.check();
1349
+ btn.setAttribute("data-copied", "");
1350
+ const copiedLabel = locale.copied ?? locale.copy ?? "Copied";
1351
+ btn.setAttribute("title", copiedLabel);
1352
+ btn.setAttribute("aria-label", copiedLabel);
1353
+ setTimeout(() => {
1354
+ btn.removeAttribute("data-copied");
1355
+ btn.innerHTML = icons.copy();
1356
+ const copyLabel = locale.copy ?? "Copy";
1357
+ btn.setAttribute("title", copyLabel);
1358
+ btn.setAttribute("aria-label", copyLabel);
1359
+ }, 2e3);
1360
+ }).catch(() => {
1361
+ console.warn("[aparte] Clipboard write failed");
1362
+ });
1363
+ break;
1364
+ }
1365
+ case "retry": {
1366
+ if (!messageId) break;
1367
+ const targetId = this._resolveTargetId();
1368
+ const detail = { messageId, targetId };
1369
+ this.dispatchEvent(new CustomEvent("aparte-retry", {
1370
+ bubbles: true,
1371
+ composed: true,
1372
+ detail
1373
+ }));
1374
+ break;
1375
+ }
1376
+ case "edit": {
1377
+ this._enterEditMode();
1378
+ break;
1379
+ }
1380
+ case "edit-save": {
1381
+ this._exitEditMode(true);
1382
+ break;
1383
+ }
1384
+ case "edit-cancel": {
1385
+ this._exitEditMode(false);
1386
+ break;
1387
+ }
1388
+ case "feedback-positive":
1389
+ case "feedback-negative": {
1390
+ if (!messageId) break;
1391
+ const value = action === "feedback-positive" ? "positive" : "negative";
1392
+ btn.setAttribute("data-submitted", "");
1393
+ const detail = { messageId, value };
1394
+ this.dispatchEvent(new CustomEvent("aparte-feedback", {
1395
+ bubbles: true,
1396
+ composed: true,
1397
+ detail
1398
+ }));
1399
+ break;
1400
+ }
1401
+ case "info": {
1402
+ if (!messageId) break;
1403
+ const detail = {
1404
+ messageId,
1405
+ usage: this._usage ?? void 0
1406
+ };
1407
+ this.dispatchEvent(new CustomEvent("aparte-message-info", {
1408
+ bubbles: true,
1409
+ composed: true,
1410
+ detail
1411
+ }));
1412
+ break;
1413
+ }
1414
+ }
1415
+ }
1416
+ /**
1417
+ * Open the inline editor for a user message. Idempotent — a second `edit`
1418
+ * click while already editing is a no-op (no stacked editors).
1419
+ *
1420
+ * The editor reuses the composer's contenteditable primitive
1421
+ * (`<aparte-composer-input>`) so editing is iso with composing: same autosize,
1422
+ * IME, paste and styling. With no `<aparte-composer>` root it runs standalone —
1423
+ * `Enter` (Shift+Enter = newline) surfaces as `aparte-composer-submit`, which we
1424
+ * treat as save; `Esc` cancels.
1425
+ */
1426
+ _enterEditMode() {
1427
+ if (this._editing || !this._contentEl) return;
1428
+ this._editing = true;
1429
+ this.querySelector(".aparte-message")?.setAttribute("data-editing", "");
1430
+ const input = document.createElement("aparte-composer-input");
1431
+ input.setAttribute("placeholder", this._cfg.getLocale().edit ?? "Edit message");
1432
+ this._editInput = input;
1433
+ this._contentEl.style.display = "none";
1434
+ this._contentEl.insertAdjacentElement("afterend", input);
1435
+ input.setValue(this._content);
1436
+ input.addEventListener("aparte-composer-submit", () => this._exitEditMode(true));
1437
+ input.addEventListener("keydown", (e2) => {
1438
+ if (e2.key === "Escape" && !e2.isComposing) {
1439
+ e2.preventDefault();
1440
+ this._exitEditMode(false);
1441
+ }
1442
+ });
1443
+ this._updateActionBar();
1444
+ input.focusEnd();
1445
+ }
1446
+ /**
1447
+ * Leave edit mode. When `save` is true and the text actually changed, emits
1448
+ * `aparte-edit`; otherwise restores the original message untouched. Always
1449
+ * restores the normal action bar and removes the inline editor.
1450
+ */
1451
+ _exitEditMode(save) {
1452
+ if (!this._editing) return;
1453
+ const newContent = this._editInput?.getValue() ?? "";
1454
+ const original = this._content;
1455
+ this._editInput?.remove();
1456
+ this._editInput = null;
1457
+ if (this._contentEl) this._contentEl.style.display = "";
1458
+ this.querySelector(".aparte-message")?.removeAttribute("data-editing");
1459
+ this._editing = false;
1460
+ this._updateActionBar();
1461
+ if (save && newContent && newContent !== original) {
1462
+ const messageId = this.getAttribute("message-id");
1463
+ if (messageId) {
1464
+ const detail = {
1465
+ messageId,
1466
+ content: newContent,
1467
+ targetId: this._resolveTargetId()
1468
+ };
1469
+ this.dispatchEvent(new CustomEvent("aparte-edit", {
1470
+ bubbles: true,
1471
+ composed: true,
1472
+ detail
1473
+ }));
1474
+ }
1475
+ }
1476
+ }
1477
+ _resolveTargetId() {
1478
+ let el = this.parentElement;
1479
+ while (el) {
1480
+ const tag = el.tagName?.toLowerCase();
1481
+ const isHost = tag === "aparte-chat" || tag === "aparte-chat-component" || el.hasAttribute?.("data-aparte-chat");
1482
+ if (isHost && el.id) return el.id;
1483
+ el = el.parentElement;
1484
+ }
1485
+ return void 0;
1486
+ }
1487
+ // ─────────────────────────────────────────────────────────────────────────
1488
+ // Utilities
1489
+ // ─────────────────────────────────────────────────────────────────────────
1490
+ _escapeHtml(str) {
1491
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
1492
+ }
1493
+ _updateStreaming(streaming) {
1494
+ const wasStreaming = this._streaming;
1495
+ this._streaming = streaming;
1496
+ const message = this.querySelector(".aparte-message");
1497
+ if (message) {
1498
+ message.setAttribute("data-streaming", String(streaming));
1499
+ if (streaming) {
1500
+ message.setAttribute("aria-busy", "true");
1501
+ message.classList.add("aparte-message-streaming");
1502
+ } else {
1503
+ message.removeAttribute("aria-busy");
1504
+ message.classList.remove("aparte-message-streaming");
1505
+ }
1506
+ }
1507
+ if (wasStreaming && !streaming) this._highlightContentCode();
1508
+ }
1509
+ }
1510
+ if (!customElements.get("aparte-chat-bubble")) {
1511
+ customElements.define("aparte-chat-bubble", AparteChatBubble);
1512
+ }
1513
+ class AparteChatStatus extends HTMLElement {
1514
+ static get observedAttributes() {
1515
+ return ["visible", "text"];
1516
+ }
1517
+ _cfg;
1518
+ constructor() {
1519
+ super();
1520
+ }
1521
+ connectedCallback() {
1522
+ this._cfg = resolveConfig(this);
1523
+ this._render();
1524
+ window.addEventListener("aparte-config-change", this._onConfigChange);
1525
+ }
1526
+ disconnectedCallback() {
1527
+ window.removeEventListener("aparte-config-change", this._onConfigChange);
1528
+ }
1529
+ _onConfigChange = (e2) => {
1530
+ const detail = e2.detail;
1531
+ if (detail?.config && detail.config !== this._cfg) return;
1532
+ this.innerHTML = "";
1533
+ this._render();
1534
+ };
1535
+ attributeChangedCallback(name, oldValue, newValue) {
1536
+ if (oldValue === newValue) return;
1537
+ switch (name) {
1538
+ case "visible":
1539
+ this._updateVisibility(newValue !== null);
1540
+ break;
1541
+ case "text":
1542
+ this._updateText(newValue);
1543
+ break;
1544
+ }
1545
+ }
1546
+ /**
1547
+ * Show the typing indicator
1548
+ */
1549
+ show() {
1550
+ this.setAttribute("visible", "");
1551
+ }
1552
+ /**
1553
+ * Hide the typing indicator
1554
+ */
1555
+ hide() {
1556
+ this.removeAttribute("visible");
1557
+ }
1558
+ /**
1559
+ * Toggle visibility
1560
+ */
1561
+ toggle() {
1562
+ if (this.hasAttribute("visible")) {
1563
+ this.hide();
1564
+ } else {
1565
+ this.show();
1566
+ }
1567
+ }
1568
+ /**
1569
+ * Check if visible
1570
+ */
1571
+ isVisible() {
1572
+ return this.hasAttribute("visible");
1573
+ }
1574
+ _render() {
1575
+ const text = this.getAttribute("text") || "Typing";
1576
+ const visible = this.hasAttribute("visible");
1577
+ if (this.querySelector(".aparte-status-container")) return;
1578
+ const custom = this._cfg?.getStatusRenderer?.();
1579
+ if (custom) {
1580
+ this.innerHTML = `<div class="aparte-message aparte-status-container" data-visible="${visible}" role="status" aria-live="polite"></div>`;
1581
+ const container = this.querySelector(".aparte-status-container");
1582
+ container.setAttribute("aria-label", text);
1583
+ const result = runWithConfig(this._cfg, () => custom(text));
1584
+ if (result instanceof HTMLElement) container.appendChild(result);
1585
+ else container.innerHTML = result;
1586
+ return;
1587
+ }
1588
+ this.innerHTML = `
1589
+ <div
1590
+ class="aparte-message aparte-status-container"
1591
+ data-visible="${visible}"
1592
+ role="status"
1593
+ aria-live="polite"
1594
+ >
1595
+ <div class="aparte-avatar" data-role="assistant" style="visibility: hidden"></div>
1596
+ <div class="aparte-body">
1597
+ <div class="aparte-status-content">
1598
+ <div class="aparte-dots" aria-hidden="true">
1599
+ <span class="aparte-dot"></span>
1600
+ </div>
1601
+ <span class="aparte-status-text"></span>
1602
+ </div>
1603
+ </div>
1604
+ </div>
1605
+ `;
1606
+ this.querySelector(".aparte-status-container")?.setAttribute("aria-label", text);
1607
+ if (this.hasAttribute("text")) {
1608
+ const textEl = this.querySelector(".aparte-status-text");
1609
+ if (textEl) textEl.textContent = text;
1610
+ }
1611
+ }
1612
+ _updateVisibility(visible) {
1613
+ const container = this.querySelector(".aparte-status-container");
1614
+ if (container) {
1615
+ container.setAttribute("data-visible", String(visible));
1616
+ }
1617
+ }
1618
+ _updateText(text) {
1619
+ const textEl = this.querySelector(".aparte-status-text");
1620
+ const container = this.querySelector(".aparte-status-container");
1621
+ if (!container) return;
1622
+ if (textEl) textEl.textContent = text ?? "";
1623
+ container.setAttribute("aria-label", text || "Typing");
1624
+ }
1625
+ }
1626
+ if (!customElements.get("aparte-chat-status")) {
1627
+ customElements.define("aparte-chat-status", AparteChatStatus);
1628
+ }
1629
+ class AparteChatViewport extends HTMLElement {
1630
+ // The scroll surface: an internal `.aparte-viewport-container` div (core mode)
1631
+ // or the host element itself (framework-managed mode). HTMLElement covers both.
1632
+ _container = null;
1633
+ _scrollBtn = null;
1634
+ _bottomSpacer = null;
1635
+ /**
1636
+ * In framework-managed mode there is no spacer ELEMENT (an extra child would
1637
+ * collide with the framework's own DOM reconciliation). The spacer is an
1638
+ * additive `padding-bottom` on the host, tracked here so `_recalculateSpacer`
1639
+ * can read the current value without measuring an element.
1640
+ */
1641
+ _fwSpacerHeight = 0;
1642
+ _spacerRafId = null;
1643
+ _spacerFrozenUntil = 0;
1644
+ _layoutTransitionMs = 0;
1645
+ _repo = new MessageRepository();
1646
+ _isAutoScrollEnabled = true;
1647
+ _scrollThreshold = 50;
1648
+ /** When true, the next _autoScroll() call uses smooth instead of instant, then resets. */
1649
+ _smoothScrollOnce = false;
1650
+ /**
1651
+ * DOM render cap: the max number of `<aparte-chat-bubble>` elements kept in the
1652
+ * DOM at once (a perf ceiling for very long conversations). This NEVER evicts
1653
+ * messages from the repository — the full conversation tree and its persistence
1654
+ * snapshot stay intact; only the oldest rendered bubbles are dropped from view.
1655
+ */
1656
+ _maxRenderedBubbles = 1e3;
1657
+ /** One-time guard for the deprecated `maxMessages` warning. */
1658
+ _warnedMaxMessagesDeprecation = false;
1659
+ _resizeObserver = null;
1660
+ _mutationObserver = null;
1661
+ _boundResetHandler = null;
1662
+ /**
1663
+ * When true, _reRenderActivePath() only dispatches aparte-path-changed without
1664
+ * touching the DOM. Set via setFrameworkManagedDOM(true) when a framework
1665
+ * (e.g. Angular) owns the bubble elements.
1666
+ */
1667
+ _frameworkManagedDOM = false;
1668
+ static get observedAttributes() {
1669
+ return ["scroll-threshold", "max-rendered-bubbles", "max-messages"];
1670
+ }
1671
+ constructor() {
1672
+ super();
1673
+ this._handleScroll = this._handleScroll.bind(this);
1674
+ }
1675
+ connectedCallback() {
1676
+ if (this.hasAttribute("framework-managed")) this._frameworkManagedDOM = true;
1677
+ this._render();
1678
+ this._setupEventListeners();
1679
+ this._setupObservers();
1680
+ this._boundResetHandler = () => this.clearAll();
1681
+ window.addEventListener("aparte-reset", this._boundResetHandler);
1682
+ }
1683
+ disconnectedCallback() {
1684
+ if (this._boundResetHandler) {
1685
+ window.removeEventListener("aparte-reset", this._boundResetHandler);
1686
+ this._boundResetHandler = null;
1687
+ }
1688
+ this._cleanup();
1689
+ }
1690
+ attributeChangedCallback(name, oldValue, newValue) {
1691
+ if (oldValue === newValue) return;
1692
+ switch (name) {
1693
+ case "scroll-threshold":
1694
+ this._scrollThreshold = parseInt(newValue || "50", 10);
1695
+ break;
1696
+ case "max-rendered-bubbles":
1697
+ this._maxRenderedBubbles = parseInt(newValue || "1000", 10);
1698
+ this._pruneRenderedBubbles();
1699
+ break;
1700
+ case "max-messages":
1701
+ this._warnMaxMessagesDeprecated();
1702
+ this._maxRenderedBubbles = parseInt(newValue || "1000", 10);
1703
+ this._pruneRenderedBubbles();
1704
+ break;
1705
+ }
1706
+ }
1707
+ /**
1708
+ * Configure viewport with options
1709
+ */
1710
+ configure(config) {
1711
+ if (config.scrollThreshold !== void 0) {
1712
+ this._scrollThreshold = config.scrollThreshold;
1713
+ }
1714
+ if (config.maxRenderedBubbles !== void 0) {
1715
+ this._maxRenderedBubbles = config.maxRenderedBubbles;
1716
+ this._pruneRenderedBubbles();
1717
+ }
1718
+ if (config.maxMessages !== void 0) {
1719
+ this._warnMaxMessagesDeprecated();
1720
+ this._maxRenderedBubbles = config.maxMessages;
1721
+ this._pruneRenderedBubbles();
1722
+ }
1723
+ if (config.layoutTransitionMs !== void 0) {
1724
+ this._layoutTransitionMs = config.layoutTransitionMs;
1725
+ }
1726
+ }
1727
+ // ─────────────────────────────────────────────────────────────────────────
1728
+ // Simple Content Streaming
1729
+ // ─────────────────────────────────────────────────────────────────────────
1730
+ /**
1731
+ * Append a token chunk to a message's content (simple text streaming)
1732
+ * @param messageId - Unique identifier for the message
1733
+ * @param chunk - Token chunk to append
1734
+ */
1735
+ appendToken(messageId, chunk) {
1736
+ const message = this._getOrCreateMessage(messageId);
1737
+ message.content = (message.content || "") + chunk;
1738
+ this._notifyBubble(messageId, "appendToken", chunk);
1739
+ this._autoScroll();
1740
+ this._scheduleSpacerUpdate();
1741
+ }
1742
+ // ─────────────────────────────────────────────────────────────────────────
1743
+ // Segment-Aware Streaming
1744
+ // ─────────────────────────────────────────────────────────────────────────
1745
+ /**
1746
+ * Append content to a specific segment within a message
1747
+ * @param messageId - Message containing the segment
1748
+ * @param segmentId - Target segment ID
1749
+ * @param chunk - Content to append
1750
+ */
1751
+ appendToSegment(messageId, segmentId, chunk) {
1752
+ const message = this._getOrCreateMessage(messageId);
1753
+ if (!message.segments) {
1754
+ message.segments = [];
1755
+ }
1756
+ const segment = message.segments.find((s2) => s2.id === segmentId);
1757
+ if (segment && "content" in segment) {
1758
+ segment.content += chunk;
1759
+ }
1760
+ this.dispatchEvent(new CustomEvent("aparte-segment-update", {
1761
+ bubbles: true,
1762
+ composed: true,
1763
+ detail: { messageId, segmentId, content: chunk, append: true }
1764
+ }));
1765
+ this._notifyBubble(messageId, "appendToSegment", chunk, segmentId);
1766
+ this._autoScroll();
1767
+ }
1768
+ /**
1769
+ * The active (head) message id — the target of `AparteClient`'s 1-argument
1770
+ * streaming convention (`addSegment(segment)`, `updateSegment(segmentId,
1771
+ * updates)`, …) which operates on "the current message". Lets a bare
1772
+ * `<aparte-chat-viewport>` be a valid `AparteClient` target, exactly like a
1773
+ * framework wrapper's host element.
1774
+ */
1775
+ _activeMessageId() {
1776
+ return this._repo.headId;
1777
+ }
1778
+ addSegment(messageIdOrSegment, maybeSegment) {
1779
+ const messageId = typeof messageIdOrSegment === "string" ? messageIdOrSegment : this._activeMessageId();
1780
+ const segment = typeof messageIdOrSegment === "string" ? maybeSegment : messageIdOrSegment;
1781
+ if (!messageId || !segment) return;
1782
+ const message = this._getOrCreateMessage(messageId);
1783
+ if (!message.segments) {
1784
+ message.segments = [];
1785
+ }
1786
+ message.segments.push(segment);
1787
+ this._notifyBubble(messageId, "addSegment", segment);
1788
+ this._autoScroll();
1789
+ }
1790
+ updateSegment(a2, b2, c2) {
1791
+ const clientForm = c2 === void 0 && typeof b2 === "object";
1792
+ const messageId = clientForm ? this._activeMessageId() : a2;
1793
+ const segmentId = clientForm ? a2 : b2;
1794
+ const updates = clientForm ? b2 : c2;
1795
+ if (!messageId) return;
1796
+ const message = this._repo.getMessageById(messageId);
1797
+ if (!message?.segments) return;
1798
+ const segmentIndex = message.segments.findIndex((s2) => s2.id === segmentId);
1799
+ if (segmentIndex !== -1) {
1800
+ message.segments[segmentIndex] = {
1801
+ ...message.segments[segmentIndex],
1802
+ ...updates
1803
+ };
1804
+ this._notifyBubble(messageId, "updateSegment", { segmentId, updates });
1805
+ }
1806
+ }
1807
+ removeSegment(a2, b2) {
1808
+ const clientForm = b2 === void 0;
1809
+ const messageId = clientForm ? this._activeMessageId() : a2;
1810
+ const segmentId = clientForm ? a2 : b2;
1811
+ if (!messageId || !segmentId) return;
1812
+ const message = this._repo.getMessageById(messageId);
1813
+ if (message?.segments) {
1814
+ const idx = message.segments.findIndex((s2) => s2.id === segmentId);
1815
+ if (idx !== -1) message.segments.splice(idx, 1);
1816
+ }
1817
+ this._notifyBubble(messageId, "removeSegment", segmentId);
1818
+ }
1819
+ /**
1820
+ * Start a new streaming segment (e.g., thinking or code block)
1821
+ * Creates the segment and marks it as streaming
1822
+ */
1823
+ startSegment(messageId, segment) {
1824
+ const streamingSegment = { ...segment, isStreaming: true };
1825
+ this.addSegment(messageId, streamingSegment);
1826
+ }
1827
+ /**
1828
+ * Complete a streaming segment
1829
+ */
1830
+ completeSegment(messageId, segmentId) {
1831
+ this.updateSegment(messageId, segmentId, { isStreaming: false });
1832
+ }
1833
+ /**
1834
+ * Persist token usage on a message and propagate to the live bubble so the
1835
+ * perf chip (tokens/sec) renders in the action bar.
1836
+ */
1837
+ setUsage(messageId, usage) {
1838
+ const message = this._repo.getMessageById(messageId);
1839
+ if (message) message.usage = usage;
1840
+ this._notifyBubble(messageId, "setUsage", usage);
1841
+ }
1842
+ // ─────────────────────────────────────────────────────────────────────────
1843
+ // Message Management
1844
+ // ─────────────────────────────────────────────────────────────────────────
1845
+ /**
1846
+ * Mark a message as finished streaming
1847
+ */
1848
+ completeMessage(messageId) {
1849
+ const message = this._repo.getMessageById(messageId);
1850
+ if (message) {
1851
+ message.isStreaming = false;
1852
+ message.status = "completed";
1853
+ message.segments?.forEach((s2) => {
1854
+ s2.isStreaming = false;
1855
+ });
1856
+ this._notifyBubble(messageId, "complete", { status: "completed" });
1857
+ this._recalculateSpacer();
1858
+ }
1859
+ }
1860
+ /**
1861
+ * Atomic update for a message by ID
1862
+ * Supports updating content, status, segments, and other metadata
1863
+ */
1864
+ updateMessage(messageId, updates) {
1865
+ const message = this._repo.getMessageById(messageId);
1866
+ if (!message) return;
1867
+ Object.assign(message, updates);
1868
+ if (updates.status) {
1869
+ message.isStreaming = updates.status === "streaming" || updates.status === "pending";
1870
+ }
1871
+ this._notifyBubble(messageId, "update", updates);
1872
+ this._autoScroll();
1873
+ }
1874
+ /**
1875
+ * Add a complete message to the message registry.
1876
+ *
1877
+ * @remarks
1878
+ * **Framework-managed DOM only.** Records the message in the tree but does NOT
1879
+ * paint a bubble on its own (a framework wrapper reconciles the DOM from the
1880
+ * list). For standalone / vanilla usage call {@link appendMessage} instead,
1881
+ * which both records the message and creates its bubble element.
1882
+ */
1883
+ addMessage(message) {
1884
+ this._repo.addOrUpdateMessage(this._repo.headId, { ...message });
1885
+ this._pruneRenderedBubbles();
1886
+ this._autoScroll();
1887
+ }
1888
+ /**
1889
+ * Append a new message and create its bubble in the DOM.
1890
+ * Implements the same contract as the Angular wrapper's appendMessage(),
1891
+ * making aparte-chat-viewport a fully standalone target for aparte-client.
1892
+ * When `_frameworkManagedDOM` is true, only the internal repo is updated —
1893
+ * the framework owns the DOM and will create the bubble element itself.
1894
+ */
1895
+ appendMessage(message) {
1896
+ this._repo.addOrUpdateMessage(this._repo.headId, { ...message });
1897
+ if (!this._frameworkManagedDOM) {
1898
+ const wrapper = this.querySelector(".aparte-messages-wrapper");
1899
+ if (wrapper) {
1900
+ const bubble = document.createElement("aparte-chat-bubble");
1901
+ bubble.setAttribute("message-id", message.id);
1902
+ bubble.setAttribute("role", message.role);
1903
+ if (message.timestamp) bubble.setAttribute("timestamp", String(message.timestamp));
1904
+ if (message.content) bubble.setAttribute("content", message.content);
1905
+ if (message.status === "streaming" || message.status === "pending") {
1906
+ bubble.setAttribute("streaming", "");
1907
+ }
1908
+ if (this._bottomSpacer && this._bottomSpacer.parentNode === wrapper) {
1909
+ wrapper.insertBefore(bubble, this._bottomSpacer);
1910
+ } else {
1911
+ wrapper.appendChild(bubble);
1912
+ }
1913
+ }
1914
+ }
1915
+ this._pruneRenderedBubbles();
1916
+ this._recalculateSpacer();
1917
+ if (message.role === "user") {
1918
+ this._isAutoScrollEnabled = true;
1919
+ requestAnimationFrame(() => this._smoothScrollToBottom());
1920
+ } else {
1921
+ this._autoScroll();
1922
+ }
1923
+ }
1924
+ /**
1925
+ * Update the last message content, optionally appending.
1926
+ * Implements the same contract as the Angular wrapper's updateLastMessage(),'
1927
+ * making aparte-chat-viewport a fully standalone streaming target for aparte-client.
1928
+ */
1929
+ updateLastMessage(content, options) {
1930
+ const lastId = this._repo.headId;
1931
+ if (!lastId) return;
1932
+ if (options?.append) {
1933
+ this.appendToken(lastId, content);
1934
+ } else {
1935
+ const message = this._repo.getMessageById(lastId);
1936
+ if (message) message.content = content;
1937
+ this._notifyBubble(lastId, "appendToken", content);
1938
+ }
1939
+ }
1940
+ /**
1941
+ * Add a new sibling branch to an assistant message (retry flow).
1942
+ * Creates a new empty assistant message as a sibling of `messageId`
1943
+ * under the same parent, switches the active branch to it, and
1944
+ * re-renders the active path.
1945
+ * @returns The index of the new branch in the siblings array, or 0 on failure.
1946
+ */
1947
+ addBranch(messageId) {
1948
+ const meta = this._repo.getMessage(messageId);
1949
+ if (!meta) return 0;
1950
+ const newMsg = {
1951
+ id: crypto.randomUUID(),
1952
+ role: "assistant",
1953
+ content: "",
1954
+ status: "pending",
1955
+ timestamp: Date.now()
1956
+ };
1957
+ this._repo.addOrUpdateMessage(meta.parentId, newMsg);
1958
+ this._repo.switchToBranch(newMsg.id);
1959
+ this._reRenderActivePath();
1960
+ const siblings = this._repo.getBranches(newMsg.id);
1961
+ return siblings.indexOf(newMsg.id);
1962
+ }
1963
+ /**
1964
+ * Add a new message relative to `existingId`, switch to it, and re-render.
1965
+ *
1966
+ * Role-aware semantics:
1967
+ * - existingId is an **assistant** message → create a sibling (same parent),
1968
+ * so the active path replaces the old response with the new one.
1969
+ * - existingId is a **user** message → create a child of that message,
1970
+ * so the user message stays on the active path and the new response follows it.
1971
+ *
1972
+ * Returns the new message's ID, or null if `existingId` is not found.
1973
+ */
1974
+ addSiblingOf(existingId, newMessage) {
1975
+ const meta = this._repo.getMessage(existingId);
1976
+ if (!meta) return null;
1977
+ const parentId = meta.message.role === "user" ? existingId : meta.parentId;
1978
+ this._repo.addOrUpdateMessage(parentId, { ...newMessage });
1979
+ this._repo.switchToBranch(newMessage.id);
1980
+ this._reRenderActivePath();
1981
+ return newMessage.id;
1982
+ }
1983
+ /**
1984
+ * Navigate to the previous or next sibling branch of a message.
1985
+ * Triggers a full re-render of the active path.
1986
+ */
1987
+ navigateBranch(messageId, direction) {
1988
+ const siblings = this._repo.getBranches(messageId);
1989
+ const currentIdx = siblings.indexOf(messageId);
1990
+ if (currentIdx === -1) return;
1991
+ const targetIdx = direction === "prev" ? currentIdx - 1 : currentIdx + 1;
1992
+ if (targetIdx < 0 || targetIdx >= siblings.length) return;
1993
+ this._isAutoScrollEnabled = false;
1994
+ this._updateScrollButton();
1995
+ this._repo.switchToBranch(siblings[targetIdx]);
1996
+ this._reRenderActivePath();
1997
+ }
1998
+ /**
1999
+ * Remove ALL responses to a user message (every child branch) and set head
2000
+ * back to `userMessageId`. Cleaner than `truncateFrom` for edit flows: it
2001
+ * discards stale sibling branches so the regenerated response starts alone.
2002
+ */
2003
+ truncateResponsesAfter(userMessageId) {
2004
+ const prevMessages = this._repo.getMessages();
2005
+ this._repo.clearChildren(userMessageId);
2006
+ if (!this._frameworkManagedDOM) {
2007
+ const wrapper = this.querySelector(".aparte-messages-wrapper");
2008
+ if (wrapper) {
2009
+ const startIdx = prevMessages.findIndex((m2) => m2.id === userMessageId);
2010
+ const toRemove = startIdx >= 0 ? prevMessages.slice(startIdx + 1) : [];
2011
+ for (const m2 of toRemove) {
2012
+ wrapper.querySelector(`aparte-chat-bubble[message-id="${cssEscape(m2.id)}"]`)?.remove();
2013
+ }
2014
+ }
2015
+ }
2016
+ }
2017
+ /**
2018
+ * Remove all messages from `messageId` onwards (inclusive) from state and DOM.
2019
+ * Used by edit to truncate history before re-generating.
2020
+ */
2021
+ truncateFrom(messageId) {
2022
+ const allMsgs = this._repo.getMessages();
2023
+ const startIdx = allMsgs.findIndex((m2) => m2.id === messageId);
2024
+ if (startIdx === -1) return;
2025
+ const toRemove = allMsgs.slice(startIdx).map((m2) => m2.id);
2026
+ this._repo.resetHead(messageId);
2027
+ if (!this._frameworkManagedDOM) {
2028
+ const wrapper = this.querySelector(".aparte-messages-wrapper");
2029
+ for (const id of toRemove) {
2030
+ wrapper?.querySelector(`aparte-chat-bubble[message-id="${cssEscape(id)}"]`)?.remove();
2031
+ }
2032
+ }
2033
+ }
2034
+ /**
2035
+ * Get a message by ID
2036
+ */
2037
+ getMessage(messageId) {
2038
+ return this._repo.getMessageById(messageId);
2039
+ }
2040
+ getMessages() {
2041
+ return this._repo.getMessages();
2042
+ }
2043
+ /**
2044
+ * Export the full conversation tree (all branches, not just the active path).
2045
+ * The returned snapshot can be persisted and restored via `importTree()`.
2046
+ */
2047
+ exportTree() {
2048
+ return this._repo.export();
2049
+ }
2050
+ /**
2051
+ * Import a previously-exported tree snapshot, restoring the full branch
2052
+ * topology and the active head. Replaces any existing repo content.
2053
+ *
2054
+ * Always calls `_reRenderActivePath()`:
2055
+ * - In native DOM mode: rebuilds bubble elements.
2056
+ * - In framework-managed mode: skips DOM manipulation but dispatches
2057
+ * `aparte-path-changed` with sibling metadata so the wrapper can update
2058
+ * branch arrows on already-rendered bubbles.
2059
+ */
2060
+ importTree(tree) {
2061
+ this.clearAll();
2062
+ this._repo.import(tree);
2063
+ this._reRenderActivePath();
2064
+ }
2065
+ /**
2066
+ * Clear all messages and remove all bubble elements from the DOM.
2067
+ * Also dispatches a aparte-reset-done event.
2068
+ *
2069
+ * In framework-managed mode the DOM is owned by the host framework
2070
+ * (Angular @for, React, etc.) and we must not clear `innerHTML` — doing
2071
+ * so desynchronises the framework's view tree from the live DOM and the
2072
+ * next change-detection pass throws `NotFoundError` on insertBefore.
2073
+ */
2074
+ clearAll() {
2075
+ this._repo.clear();
2076
+ if (!this._frameworkManagedDOM) {
2077
+ const wrapper = this.querySelector(".aparte-messages-wrapper");
2078
+ if (wrapper) {
2079
+ Array.from(wrapper.querySelectorAll("aparte-chat-bubble")).forEach((b2) => b2.remove());
2080
+ }
2081
+ }
2082
+ this._setSpacerHeight(0);
2083
+ this._isAutoScrollEnabled = true;
2084
+ this._updateScrollButton();
2085
+ this.dispatchEvent(new CustomEvent("aparte-reset-done", { bubbles: true, composed: true }));
2086
+ }
2087
+ /**
2088
+ * Clear all messages
2089
+ * @deprecated Use clearAll() to also remove DOM bubbles
2090
+ */
2091
+ clearMessages() {
2092
+ this._repo.clear();
2093
+ }
2094
+ /**
2095
+ * Replace the entire message list in one shot. Used when switching
2096
+ * conversations: clears existing repo + DOM, then appends each message.
2097
+ *
2098
+ * In framework-managed mode the framework re-renders the bubble DOM
2099
+ * itself; we only update the internal repo (used by aparte-client to
2100
+ * build chat history).
2101
+ */
2102
+ setMessages(messages) {
2103
+ this.clearAll();
2104
+ for (const m2 of messages) {
2105
+ this.appendMessage(m2);
2106
+ }
2107
+ }
2108
+ /**
2109
+ * Scroll to bottom of viewport
2110
+ */
2111
+ scrollToBottom() {
2112
+ this._scrollToBottom();
2113
+ }
2114
+ /**
2115
+ * Reset the bottom spacer to 0 height immediately and freeze it for
2116
+ * 350 ms so the host-app layout transition (e.g. flex: 0→1 animation)
2117
+ * does not trigger a premature recalculation with mid-animation geometry.
2118
+ * Call before a full messages swap.
2119
+ */
2120
+ resetSpacer() {
2121
+ this._setSpacerHeight(0);
2122
+ if (this._layoutTransitionMs > 0) {
2123
+ this._spacerFrozenUntil = Date.now() + this._layoutTransitionMs;
2124
+ }
2125
+ }
2126
+ /**
2127
+ * Enable or disable auto-scroll
2128
+ */
2129
+ setAutoScroll(enabled) {
2130
+ this._isAutoScrollEnabled = enabled;
2131
+ }
2132
+ /**
2133
+ * Signal that a framework (e.g. Angular) manages the bubble DOM.
2134
+ * When true, branch navigation dispatches `aparte-path-changed` without
2135
+ * clearing/rebuilding the messages wrapper — the framework re-renders instead.
2136
+ */
2137
+ setFrameworkManagedDOM(managed) {
2138
+ this._frameworkManagedDOM = managed;
2139
+ }
2140
+ // ─────────────────────────────────────────────────────────────────────────
2141
+ // Private Helpers
2142
+ // ─────────────────────────────────────────────────────────────────────────
2143
+ _getOrCreateMessage(messageId) {
2144
+ const existing = this._repo.getMessageById(messageId);
2145
+ if (existing) return existing;
2146
+ const message = {
2147
+ id: messageId,
2148
+ role: "assistant",
2149
+ content: "",
2150
+ timestamp: Date.now(),
2151
+ isStreaming: true,
2152
+ status: "streaming"
2153
+ };
2154
+ this._repo.addOrUpdateMessage(this._repo.headId, message);
2155
+ return message;
2156
+ }
2157
+ _notifyBubble(messageId, action, payload, segmentId) {
2158
+ const bubble = this.querySelector(
2159
+ `aparte-chat-bubble[message-id="${cssEscape(messageId)}"], [data-aparte-bubble][message-id="${cssEscape(messageId)}"]`
2160
+ );
2161
+ if (!bubble) return;
2162
+ switch (action) {
2163
+ case "appendToken":
2164
+ bubble.appendToken?.(payload);
2165
+ break;
2166
+ case "appendToSegment":
2167
+ bubble.appendToSegment?.(segmentId, payload);
2168
+ break;
2169
+ case "addSegment":
2170
+ bubble.addSegment?.(payload);
2171
+ break;
2172
+ case "updateSegment": {
2173
+ const { segmentId: sid, updates: segUpdates } = payload;
2174
+ bubble.updateSegment?.(sid, segUpdates);
2175
+ break;
2176
+ }
2177
+ case "removeSegment":
2178
+ bubble.removeSegment?.(payload);
2179
+ break;
2180
+ case "setUsage":
2181
+ bubble.setUsage?.(payload);
2182
+ break;
2183
+ case "update":
2184
+ if ("status" in payload || "segments" in payload) {
2185
+ bubble.updateMessage?.(payload);
2186
+ }
2187
+ break;
2188
+ case "complete":
2189
+ bubble.updateMessage?.(payload);
2190
+ break;
2191
+ }
2192
+ }
2193
+ /**
2194
+ * Re-render the active path: clears the messages wrapper and rebuilds bubbles
2195
+ * for every message on the current active branch path (root → head).
2196
+ * Calls `setSiblings(count, index)` on each bubble that has siblings, and
2197
+ * dispatches `aparte-path-changed` so Angular wrapper can sync its signal.
2198
+ *
2199
+ * When `_frameworkManagedDOM` is true (set via setFrameworkManagedDOM), the DOM
2200
+ * manipulation is skipped — only `aparte-path-changed` is dispatched so the
2201
+ * framework can re-render from updated signal state.
2202
+ */
2203
+ _reRenderActivePath() {
2204
+ const activeMessages = this._repo.getMessages();
2205
+ const siblingsInfo = activeMessages.map((m2) => {
2206
+ const sibs = this._repo.getBranches(m2.id);
2207
+ return { id: m2.id, count: sibs.length, index: sibs.indexOf(m2.id) };
2208
+ });
2209
+ if (this._frameworkManagedDOM) {
2210
+ this._dispatchPathChanged(activeMessages, siblingsInfo);
2211
+ return;
2212
+ }
2213
+ const wrapper = this.querySelector(".aparte-messages-wrapper");
2214
+ if (!wrapper) return;
2215
+ wrapper.innerHTML = "";
2216
+ const startIdx = Math.max(0, activeMessages.length - this._maxRenderedBubbles);
2217
+ for (let i2 = startIdx; i2 < activeMessages.length; i2++) {
2218
+ const message = activeMessages[i2];
2219
+ const sibInfo = siblingsInfo[i2];
2220
+ const bubble = document.createElement("aparte-chat-bubble");
2221
+ bubble.setAttribute("message-id", message.id);
2222
+ bubble.setAttribute("role", message.role);
2223
+ if (message.timestamp) bubble.setAttribute("timestamp", String(message.timestamp));
2224
+ if (message.status === "streaming" || message.status === "pending") {
2225
+ bubble.setAttribute("streaming", "");
2226
+ }
2227
+ wrapper.appendChild(bubble);
2228
+ populateBubbleFromMessage(bubble, message, sibInfo);
2229
+ }
2230
+ this._dispatchPathChanged(activeMessages, siblingsInfo);
2231
+ this._recalculateSpacer();
2232
+ requestAnimationFrame(() => this._handleScroll());
2233
+ }
2234
+ _dispatchPathChanged(messages, siblings) {
2235
+ const detail = { messages, siblings };
2236
+ this.dispatchEvent(new CustomEvent("aparte-path-changed", {
2237
+ bubbles: true,
2238
+ composed: true,
2239
+ detail
2240
+ }));
2241
+ }
2242
+ _autoScroll() {
2243
+ if (this._isAutoScrollEnabled) {
2244
+ if (this._smoothScrollOnce) {
2245
+ this._smoothScrollOnce = false;
2246
+ requestAnimationFrame(() => this._smoothScrollToBottom());
2247
+ } else {
2248
+ requestAnimationFrame(() => this._scrollToBottom());
2249
+ }
2250
+ }
2251
+ this._pruneRenderedBubbles();
2252
+ }
2253
+ /**
2254
+ * Request that the next auto-scroll triggered by a DOM mutation uses
2255
+ * smooth behaviour instead of instant. Call this just before adding a
2256
+ * user message bubble so the viewport animates down rather than jumping.
2257
+ * Resets automatically after the first auto-scroll fires.
2258
+ */
2259
+ requestSmoothScroll() {
2260
+ this._smoothScrollOnce = true;
2261
+ }
2262
+ _render() {
2263
+ if (this._frameworkManagedDOM) {
2264
+ this._setupFrameworkDOM();
2265
+ return;
2266
+ }
2267
+ if (!this.querySelector(".aparte-viewport-container")) {
2268
+ const container = document.createElement("div");
2269
+ container.className = "aparte-viewport-container";
2270
+ const locale = resolveConfig(this).getLocale();
2271
+ if (locale.direction) {
2272
+ container.setAttribute("dir", locale.direction);
2273
+ }
2274
+ container.setAttribute("role", "log");
2275
+ container.setAttribute("aria-live", "polite");
2276
+ container.setAttribute("aria-atomic", "false");
2277
+ container.setAttribute("aria-relevant", "additions");
2278
+ const wrapper = document.createElement("div");
2279
+ wrapper.className = "aparte-messages-wrapper";
2280
+ while (this.firstChild) {
2281
+ wrapper.appendChild(this.firstChild);
2282
+ }
2283
+ this._bottomSpacer = document.createElement("div");
2284
+ this._bottomSpacer.className = "aparte-bottom-spacer";
2285
+ this._bottomSpacer.setAttribute("aria-hidden", "true");
2286
+ wrapper.appendChild(this._bottomSpacer);
2287
+ container.appendChild(wrapper);
2288
+ this.appendChild(container);
2289
+ this._container = container;
2290
+ this._scrollBtn = document.createElement("button");
2291
+ this._scrollBtn.className = "aparte-scroll-btn aparte-scroll-btn--hidden";
2292
+ this._scrollBtn.setAttribute("type", "button");
2293
+ this._scrollBtn.setAttribute("aria-label", "Scroll to bottom");
2294
+ const scrollIcon = resolveConfig(this).getIcon("scrollDown");
2295
+ this._scrollBtn.innerHTML = scrollIcon || `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>`;
2296
+ this.appendChild(this._scrollBtn);
2297
+ } else {
2298
+ this._container = this.querySelector(".aparte-viewport-container");
2299
+ this._scrollBtn = this.querySelector(".aparte-scroll-btn");
2300
+ this._bottomSpacer = this.querySelector(".aparte-bottom-spacer");
2301
+ }
2302
+ }
2303
+ /**
2304
+ * DOM setup for framework-managed mode. The framework (React/Vue/Svelte/
2305
+ * Angular) renders the bubble elements as DIRECT children of the host, so we
2306
+ * must NOT relocate them into an internal wrapper — that desyncs the
2307
+ * framework's virtual DOM from the real DOM and throws NotFoundError on the
2308
+ * next append. Instead the HOST itself is the scroll surface, the spacer is
2309
+ * additive `padding-bottom` (no element), and the scroll button is a
2310
+ * `position: sticky` TRAILING foreign child (kept last by the framework
2311
+ * MutationObserver). A present foreign node is still a valid `insertBefore`
2312
+ * reference for the framework — the crash came from a RELOCATED node, not a
2313
+ * foreign one.
2314
+ */
2315
+ _setupFrameworkDOM() {
2316
+ this._container = this;
2317
+ this._bottomSpacer = null;
2318
+ if (this.classList.contains("aparte-viewport--framework")) {
2319
+ this._scrollBtn = this.querySelector(":scope > .aparte-scroll-btn");
2320
+ return;
2321
+ }
2322
+ this.classList.add("aparte-viewport--framework");
2323
+ const scrollBtn = document.createElement("button");
2324
+ scrollBtn.className = "aparte-scroll-btn aparte-scroll-btn--hidden";
2325
+ scrollBtn.setAttribute("type", "button");
2326
+ scrollBtn.setAttribute("aria-label", "Scroll to bottom");
2327
+ const scrollIcon = resolveConfig(this).getIcon("scrollDown");
2328
+ scrollBtn.innerHTML = scrollIcon || `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>`;
2329
+ this.appendChild(scrollBtn);
2330
+ this._scrollBtn = scrollBtn;
2331
+ }
2332
+ /**
2333
+ * Keep the sticky scroll button as the last child in framework-managed mode.
2334
+ * The framework usually inserts bubbles before its own trailing nodes (so the
2335
+ * button stays last), but a plain `appendChild` at the very end (e.g. some
2336
+ * Angular @for paths) can land a bubble after it — move it back. Idempotent:
2337
+ * a no-op when already last, so it never loops the MutationObserver.
2338
+ */
2339
+ _keepScrollButtonLast() {
2340
+ if (!this._scrollBtn) return;
2341
+ if (this.lastElementChild !== this._scrollBtn) {
2342
+ this.appendChild(this._scrollBtn);
2343
+ }
2344
+ }
2345
+ /** Current spacer height — a padding value (framework) or the element's height (core). */
2346
+ _getSpacerHeight() {
2347
+ if (this._frameworkManagedDOM) return this._fwSpacerHeight;
2348
+ return this._bottomSpacer?.offsetHeight ?? 0;
2349
+ }
2350
+ /** Set the spacer — host padding (framework, additive to base padding) or element height (core). */
2351
+ _setSpacerHeight(px) {
2352
+ if (this._frameworkManagedDOM) {
2353
+ this._fwSpacerHeight = px;
2354
+ this.style.setProperty("--aparte-fw-spacer", `${px}px`);
2355
+ } else if (this._bottomSpacer) {
2356
+ this._bottomSpacer.style.height = `${px}px`;
2357
+ }
2358
+ }
2359
+ _setupEventListeners() {
2360
+ this._container?.addEventListener("scroll", this._handleScroll, { passive: true });
2361
+ this._scrollBtn?.addEventListener("click", () => {
2362
+ this._isAutoScrollEnabled = true;
2363
+ this._smoothScrollToBottom();
2364
+ this._updateScrollButton();
2365
+ });
2366
+ this.addEventListener("aparte-branch-navigate", (e2) => {
2367
+ const evt = e2;
2368
+ evt.stopPropagation();
2369
+ this.navigateBranch(evt.detail.messageId, evt.detail.direction);
2370
+ });
2371
+ }
2372
+ _setupObservers() {
2373
+ this._resizeObserver = new ResizeObserver(() => {
2374
+ if (this._isAutoScrollEnabled) {
2375
+ this._scrollToBottom();
2376
+ }
2377
+ this._recalculateSpacer();
2378
+ });
2379
+ const wrapper = this.querySelector(".aparte-messages-wrapper");
2380
+ if (this._container) {
2381
+ if (this._frameworkManagedDOM) {
2382
+ this._resizeObserver.observe(this._container, { box: "border-box" });
2383
+ } else {
2384
+ this._resizeObserver.observe(this._container);
2385
+ }
2386
+ }
2387
+ this._mutationObserver = new MutationObserver(() => {
2388
+ if (this._frameworkManagedDOM) this._keepScrollButtonLast();
2389
+ if (this._isAutoScrollEnabled) {
2390
+ requestAnimationFrame(() => this._scrollToBottom());
2391
+ }
2392
+ this._scheduleSpacerUpdate();
2393
+ });
2394
+ const observeTarget = this._frameworkManagedDOM ? this : wrapper;
2395
+ if (observeTarget) {
2396
+ this._mutationObserver.observe(observeTarget, {
2397
+ childList: true,
2398
+ subtree: true
2399
+ });
2400
+ }
2401
+ }
2402
+ _handleScroll() {
2403
+ if (!this._container) return;
2404
+ const { scrollTop, scrollHeight, clientHeight } = this._container;
2405
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
2406
+ this._isAutoScrollEnabled = distanceFromBottom <= this._scrollThreshold;
2407
+ this._updateScrollButton();
2408
+ }
2409
+ _scrollToBottom() {
2410
+ if (!this._container) return;
2411
+ this._container.scrollTop = this._container.scrollHeight;
2412
+ }
2413
+ _smoothScrollToBottom() {
2414
+ if (!this._container) return;
2415
+ if (typeof this._container.scrollTo === "function" && !this._prefersReducedMotion()) {
2416
+ this._container.scrollTo({ top: this._container.scrollHeight, behavior: "smooth" });
2417
+ } else {
2418
+ this._container.scrollTop = this._container.scrollHeight;
2419
+ }
2420
+ }
2421
+ _prefersReducedMotion() {
2422
+ return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
2423
+ }
2424
+ /**
2425
+ * Show/hide the scroll-to-bottom button based on the current auto-scroll state.
2426
+ * Hidden when already at bottom (_isAutoScrollEnabled = true).
2427
+ */
2428
+ _updateScrollButton() {
2429
+ this._scrollBtn?.classList.toggle("aparte-scroll-btn--hidden", this._isAutoScrollEnabled);
2430
+ }
2431
+ /**
2432
+ * Recalculate the bottom spacer height so the last user message is always
2433
+ * pinned to the top of the scroll area when a response is being generated.
2434
+ *
2435
+ * spacer = max(0, viewportHeight - lastUserBubble.offsetHeight - lastAssistantBubble.offsetHeight)
2436
+ *
2437
+ * The spacer shrinks progressively as the assistant streams content, eventually
2438
+ * reaching 0 when the combined height fills the viewport.
2439
+ */
2440
+ _recalculateSpacer() {
2441
+ if (!this._container) return;
2442
+ if (!this._frameworkManagedDOM && !this._bottomSpacer) return;
2443
+ if (Date.now() < this._spacerFrozenUntil) return;
2444
+ const allBubbles = Array.from(
2445
+ this.querySelectorAll("aparte-chat-bubble")
2446
+ );
2447
+ if (allBubbles.length === 0) {
2448
+ this._setSpacerHeight(0);
2449
+ return;
2450
+ }
2451
+ const lastUserBubble = [...allBubbles].reverse().find((b2) => b2.getAttribute("role") === "user");
2452
+ if (!lastUserBubble) {
2453
+ this._setSpacerHeight(0);
2454
+ return;
2455
+ }
2456
+ const currentSpacerH = this._getSpacerHeight();
2457
+ const containerRect = this._container.getBoundingClientRect();
2458
+ const userRect = lastUserBubble.getBoundingClientRect();
2459
+ const userTopInContent = userRect.top - containerRect.top + this._container.scrollTop;
2460
+ const scrollHeightWithoutSpacer = this._container.scrollHeight - currentSpacerH;
2461
+ if (scrollHeightWithoutSpacer <= this._container.clientHeight) {
2462
+ this._setSpacerHeight(0);
2463
+ return;
2464
+ }
2465
+ const contentBelowUserTop = scrollHeightWithoutSpacer - userTopInContent;
2466
+ const needed = this._container.clientHeight - contentBelowUserTop;
2467
+ const maxSpacer = this._container.clientHeight;
2468
+ this._setSpacerHeight(Math.min(Math.max(0, needed), maxSpacer));
2469
+ if (this._isAutoScrollEnabled) {
2470
+ this._scrollToBottom();
2471
+ }
2472
+ }
2473
+ /**
2474
+ * Schedule a spacer recalculation on the next animation frame.
2475
+ * Batches multiple rapid calls (e.g. during token streaming) into one.
2476
+ *
2477
+ * Single-RAF intentional: both the scroll-to-bottom queued by MutationObserver
2478
+ * and this spacer recalculation must land in the *same* frame so the browser
2479
+ * paints exactly once — with the correct scroll position *and* the correct
2480
+ * spacer height. A double-RAF would put the spacer shrink one frame after the
2481
+ * scroll, causing a 1-frame layout jump during streaming.
2482
+ */
2483
+ _scheduleSpacerUpdate() {
2484
+ if (this._spacerRafId !== null) return;
2485
+ this._spacerRafId = requestAnimationFrame(() => {
2486
+ this._spacerRafId = null;
2487
+ this._recalculateSpacer();
2488
+ });
2489
+ }
2490
+ /**
2491
+ * Cap the number of rendered bubbles in the DOM (perf ceiling only).
2492
+ *
2493
+ * Drops the oldest `<aparte-chat-bubble>` elements beyond `_maxRenderedBubbles`
2494
+ * from the DOM. It **never** touches the MessageRepository — the conversation
2495
+ * model and its persistence snapshot stay complete (retention/eviction is a
2496
+ * consumer/persistence concern, not the viewport's). No-op when a framework
2497
+ * owns the DOM.
2498
+ */
2499
+ _pruneRenderedBubbles() {
2500
+ if (this._frameworkManagedDOM) return;
2501
+ const wrapper = this.querySelector(".aparte-messages-wrapper");
2502
+ if (!wrapper) return;
2503
+ const bubbles = wrapper.querySelectorAll("aparte-chat-bubble");
2504
+ const excess = bubbles.length - this._maxRenderedBubbles;
2505
+ for (let i2 = 0; i2 < excess; i2++) {
2506
+ bubbles[i2]?.remove();
2507
+ }
2508
+ }
2509
+ _warnMaxMessagesDeprecated() {
2510
+ if (this._warnedMaxMessagesDeprecation) return;
2511
+ this._warnedMaxMessagesDeprecation = true;
2512
+ console.warn(
2513
+ "[Aparte] `maxMessages` / `max-messages` on aparte-chat-viewport is deprecated: it used to silently evict messages from the conversation model. It now only caps rendered bubbles in the DOM — use `maxRenderedBubbles` / `max-rendered-bubbles`. For actual history retention, configure it on your ConversationManager instead."
2514
+ );
2515
+ }
2516
+ _cleanup() {
2517
+ this._container?.removeEventListener("scroll", this._handleScroll);
2518
+ this._resizeObserver?.disconnect();
2519
+ this._mutationObserver?.disconnect();
2520
+ this._resizeObserver = null;
2521
+ this._mutationObserver = null;
2522
+ if (this._spacerRafId !== null) {
2523
+ cancelAnimationFrame(this._spacerRafId);
2524
+ this._spacerRafId = null;
2525
+ }
2526
+ }
2527
+ }
2528
+ if (!customElements.get("aparte-chat-viewport")) {
2529
+ customElements.define("aparte-chat-viewport", AparteChatViewport);
2530
+ }
2531
+ class AparteComposer extends HTMLElement {
2532
+ _value = "";
2533
+ _streaming = false;
2534
+ _attachments = [];
2535
+ _listeners = /* @__PURE__ */ new Map();
2536
+ _panelActive = false;
2537
+ _panelSubmitEnabled = false;
2538
+ _panelOnSubmit = null;
2539
+ // Internal bus events that represent an observable STATE change — these are
2540
+ // mirrored to the public `aparte-composer-change` DOM event. `submit`/`cancel`
2541
+ // are actions, not state, and are covered by `aparte-send`/`aparte-cancel`.
2542
+ static _STATE_EVENTS = /* @__PURE__ */ new Set([
2543
+ "value-change",
2544
+ "streaming-change",
2545
+ "disabled-change",
2546
+ "attachments-change",
2547
+ "panel-change"
2548
+ ]);
2549
+ // Window event bindings
2550
+ _onMessageStart = this._handleMessageStart.bind(this);
2551
+ _onMessageDone = this._handleMessageDone.bind(this);
2552
+ /** Config governing THIS composer (nearest instance boundary, else global). */
2553
+ _cfg = AparteConfig;
2554
+ _configUnsub = null;
2555
+ /** True while `requireModelSelection` is on AND no model is selected — blocks send. */
2556
+ _modelGated = false;
2557
+ _onConfigChange = () => {
2558
+ this._evaluateModelGate();
2559
+ };
2560
+ static get observedAttributes() {
2561
+ return ["placeholder", "disabled", "target"];
2562
+ }
2563
+ connectedCallback() {
2564
+ window.addEventListener("aparte-message-start", this._onMessageStart);
2565
+ window.addEventListener("aparte-message-done", this._onMessageDone);
2566
+ window.addEventListener("aparte-message-error", this._onMessageDone);
2567
+ window.addEventListener("aparte-message-aborted", this._onMessageDone);
2568
+ this._cfg = resolveConfig(this);
2569
+ this._configUnsub = this._cfg.subscribe(this._onConfigChange);
2570
+ this._evaluateModelGate();
2571
+ }
2572
+ disconnectedCallback() {
2573
+ window.removeEventListener("aparte-message-start", this._onMessageStart);
2574
+ window.removeEventListener("aparte-message-done", this._onMessageDone);
2575
+ window.removeEventListener("aparte-message-error", this._onMessageDone);
2576
+ window.removeEventListener("aparte-message-aborted", this._onMessageDone);
2577
+ this._configUnsub?.();
2578
+ this._configUnsub = null;
2579
+ this._listeners.clear();
2580
+ }
2581
+ attributeChangedCallback(name, _old, value) {
2582
+ if (name === "disabled") {
2583
+ this._emit("disabled-change", { disabled: value !== null });
2584
+ }
2585
+ }
2586
+ // ── Public API ─────────────────────────────────────────────────────────
2587
+ get value() {
2588
+ return this._value;
2589
+ }
2590
+ get streaming() {
2591
+ return this._streaming;
2592
+ }
2593
+ get disabled() {
2594
+ return this.hasAttribute("disabled");
2595
+ }
2596
+ /**
2597
+ * When false, Shift+Enter submits and a bare Enter inserts a newline —
2598
+ * the inverse of the default. Driven by the `submit-on-enter` attribute.
2599
+ */
2600
+ get submitOnEnter() {
2601
+ return this.getAttribute("submit-on-enter") !== "false";
2602
+ }
2603
+ get attachments() {
2604
+ return this._attachments;
2605
+ }
2606
+ get placeholder() {
2607
+ return this.getAttribute("placeholder") ?? "";
2608
+ }
2609
+ get targetId() {
2610
+ return this.getAttribute("target");
2611
+ }
2612
+ /**
2613
+ * Snapshot of the composer's observable state. Pair with the
2614
+ * `aparte-composer-change` DOM event to drive a custom send button or footer
2615
+ * control that lives outside the composer package:
2616
+ *
2617
+ * @example
2618
+ * // A custom send button. Keep it CLICKABLE while streaming — submit()
2619
+ * // routes to cancel() when a response is in flight, so one button is
2620
+ * // Send/Stop. Disabling it on `streaming` would make "stop" unreachable.
2621
+ * composer.addEventListener('aparte-composer-change', (e) => {
2622
+ * const { streaming, disabled, value, attachments } = e.detail.state;
2623
+ * myButton.textContent = streaming ? 'Stop' : 'Send';
2624
+ * myButton.disabled = disabled || (!streaming && !value.trim() && attachments.length === 0);
2625
+ * });
2626
+ * myButton.addEventListener('click', () => composer.submit()); // send or stop
2627
+ */
2628
+ getState() {
2629
+ return {
2630
+ value: this._value,
2631
+ streaming: this._streaming,
2632
+ disabled: this.disabled,
2633
+ attachments: [...this._attachments],
2634
+ panelActive: this._panelActive,
2635
+ submitEnabled: this._panelSubmitEnabled
2636
+ };
2637
+ }
2638
+ setValue(value) {
2639
+ this._value = value;
2640
+ this._emit("value-change", { value });
2641
+ }
2642
+ addAttachments(files) {
2643
+ this._attachments = [...this._attachments, ...Array.from(files)];
2644
+ this._emit("attachments-change", { attachments: this._attachments });
2645
+ }
2646
+ removeAttachment(file) {
2647
+ this._attachments = this._attachments.filter((f2) => f2 !== file);
2648
+ this._emit("attachments-change", { attachments: this._attachments });
2649
+ }
2650
+ clearAttachments() {
2651
+ this._attachments = [];
2652
+ this._emit("attachments-change", { attachments: [] });
2653
+ }
2654
+ /** Inject a panel into the composer, hiding the text input. The send button calls onSubmit when clicked. */
2655
+ showPanel(panel, options) {
2656
+ this.hidePanel();
2657
+ const inputEl = this.querySelector("aparte-composer-input");
2658
+ if (inputEl) inputEl.style.display = "none";
2659
+ panel.dataset["apartePanel"] = "true";
2660
+ if (inputEl) {
2661
+ inputEl.insertAdjacentElement("afterend", panel);
2662
+ } else {
2663
+ this.appendChild(panel);
2664
+ }
2665
+ this._panelActive = true;
2666
+ this._panelSubmitEnabled = options?.submitEnabled ?? false;
2667
+ this._panelOnSubmit = options?.onSubmit ?? null;
2668
+ this._emit("panel-change", { active: true, submitEnabled: this._panelSubmitEnabled });
2669
+ }
2670
+ /** Remove the panel and restore the text input. */
2671
+ hidePanel() {
2672
+ const existing = this.querySelector("[data-aparte-panel]");
2673
+ if (existing) existing.remove();
2674
+ const inputEl = this.querySelector("aparte-composer-input");
2675
+ if (inputEl) inputEl.style.display = "";
2676
+ this._panelActive = false;
2677
+ this._panelSubmitEnabled = false;
2678
+ this._panelOnSubmit = null;
2679
+ this._emit("panel-change", { active: false, submitEnabled: false });
2680
+ this.focus();
2681
+ }
2682
+ /** Update the send button enabled state while a panel is active. */
2683
+ setPanelSubmitEnabled(enabled) {
2684
+ if (!this._panelActive) return;
2685
+ this._panelSubmitEnabled = enabled;
2686
+ this._emit("panel-change", { active: true, submitEnabled: enabled });
2687
+ }
2688
+ get panelActive() {
2689
+ return this._panelActive;
2690
+ }
2691
+ /**
2692
+ * Recompute the model gate from the resolved config. When
2693
+ * `requireModelSelection` is on and no model is selected, block sending and
2694
+ * reflect `data-model-gated` so the shipped CSS greys the composer. Re-runs on
2695
+ * every config change (e.g. the model selector's auto-select firing).
2696
+ */
2697
+ _evaluateModelGate() {
2698
+ const gated = this._cfg.getRequireModelSelection() && !this._cfg.hasSelectedModel();
2699
+ if (gated === this._modelGated) return;
2700
+ this._modelGated = gated;
2701
+ this.toggleAttribute("data-model-gated", gated);
2702
+ }
2703
+ /** Submit the current value. Called by aparte-composer-send or programmatically. */
2704
+ submit() {
2705
+ if (this._panelActive) {
2706
+ if (this._panelSubmitEnabled) this._panelOnSubmit?.();
2707
+ return;
2708
+ }
2709
+ if (this._streaming) {
2710
+ this.cancel();
2711
+ return;
2712
+ }
2713
+ const value = this._value.trim();
2714
+ if (!value && this._attachments.length === 0) return;
2715
+ if (this.disabled) return;
2716
+ if (this._modelGated) return;
2717
+ this._emit("submit", { value, attachments: this._attachments });
2718
+ const detail = {
2719
+ content: value,
2720
+ timestamp: Date.now(),
2721
+ targetId: this.targetId ?? void 0,
2722
+ files: this._attachments.length > 0 ? [...this._attachments] : void 0
2723
+ };
2724
+ this.dispatchEvent(new CustomEvent("aparte-send", {
2725
+ bubbles: true,
2726
+ composed: true,
2727
+ detail
2728
+ }));
2729
+ this.setValue("");
2730
+ this.clearAttachments();
2731
+ }
2732
+ /** Cancel the current streaming response. */
2733
+ cancel() {
2734
+ this._emit("cancel", {});
2735
+ this.dispatchEvent(new CustomEvent("aparte-cancel", { bubbles: true, composed: true }));
2736
+ const abortDetail = { targetId: this.targetId ?? void 0 };
2737
+ window.dispatchEvent(new CustomEvent("aparte-abort", { bubbles: false, detail: abortDetail }));
2738
+ window.dispatchEvent(new CustomEvent("aparte-message-aborted", { bubbles: false, detail: abortDetail }));
2739
+ }
2740
+ /**
2741
+ * Reset the composer to its initial state.
2742
+ * Clears value, attachments, and hides any active panel.
2743
+ * Call this when switching conversations.
2744
+ */
2745
+ reset() {
2746
+ this.setValue("");
2747
+ this.clearAttachments();
2748
+ if (this._panelActive) this.hidePanel();
2749
+ }
2750
+ /** Focus the input primitive inside this composer. */
2751
+ focus() {
2752
+ const input = this.querySelector("aparte-composer-input");
2753
+ input?.focus();
2754
+ }
2755
+ // ── Internal pub/sub ────────────────────────────────────────────────────
2756
+ _emit(event, payload) {
2757
+ this._listeners.get(event)?.forEach((cb) => cb(payload));
2758
+ if (AparteComposer._STATE_EVENTS.has(event)) {
2759
+ this.dispatchEvent(new CustomEvent("aparte-composer-change", {
2760
+ bubbles: true,
2761
+ composed: true,
2762
+ detail: { state: this.getState(), composer: this }
2763
+ }));
2764
+ }
2765
+ }
2766
+ _on(event, cb) {
2767
+ if (!this._listeners.has(event)) this._listeners.set(event, /* @__PURE__ */ new Set());
2768
+ this._listeners.get(event).add(cb);
2769
+ return () => this._listeners.get(event)?.delete(cb);
2770
+ }
2771
+ // ── Window events ───────────────────────────────────────────────────────
2772
+ /** A window lifecycle event is for THIS composer when neither side is scoped
2773
+ * (single-instance broadcast) or the target ids match (multi-chat page).
2774
+ * Without this filter, streaming in one chat flips every composer's state. */
2775
+ _isForThisComposer(e2) {
2776
+ const evtTargetId = e2.detail?.targetId;
2777
+ return !evtTargetId || !this.targetId || evtTargetId === this.targetId;
2778
+ }
2779
+ _handleMessageStart(e2) {
2780
+ if (!this._isForThisComposer(e2)) return;
2781
+ this._streaming = true;
2782
+ this._emit("streaming-change", { streaming: true });
2783
+ }
2784
+ _handleMessageDone(e2) {
2785
+ if (!this._isForThisComposer(e2)) return;
2786
+ this._streaming = false;
2787
+ this._emit("streaming-change", { streaming: false });
2788
+ if (this._panelActive) this.hidePanel();
2789
+ }
2790
+ }
2791
+ if (!customElements.get("aparte-composer")) {
2792
+ customElements.define("aparte-composer", AparteComposer);
2793
+ }
2794
+ class AparteComposerInput extends HTMLElement {
2795
+ _editor = null;
2796
+ _maxHeight = 200;
2797
+ _minHeight = 44;
2798
+ _unsubscribes = [];
2799
+ // Bound handlers
2800
+ _onInput = this._handleInput.bind(this);
2801
+ _onKeydown = this._handleKeydown.bind(this);
2802
+ _onFocus = this._handleFocus.bind(this);
2803
+ _onBlur = this._handleBlur.bind(this);
2804
+ _onPaste = this._handlePaste.bind(this);
2805
+ static get observedAttributes() {
2806
+ return ["placeholder", "max-height", "min-height", "disabled"];
2807
+ }
2808
+ connectedCallback() {
2809
+ this._render();
2810
+ this._connectToRoot();
2811
+ this._scheduleInitialReflow();
2812
+ }
2813
+ disconnectedCallback() {
2814
+ this._editor?.removeEventListener("input", this._onInput);
2815
+ this._editor?.removeEventListener("keydown", this._onKeydown);
2816
+ this._editor?.removeEventListener("focus", this._onFocus);
2817
+ this._editor?.removeEventListener("blur", this._onBlur);
2818
+ this._editor?.removeEventListener("paste", this._onPaste);
2819
+ this._unsubscribes.forEach((fn) => fn());
2820
+ this._unsubscribes = [];
2821
+ }
2822
+ attributeChangedCallback(name, _old, value) {
2823
+ if (name === "placeholder") this._updatePlaceholder();
2824
+ if (name === "max-height") this._maxHeight = parseInt(value || "200", 10);
2825
+ if (name === "min-height") this._minHeight = parseInt(value || "44", 10);
2826
+ if (name === "disabled") this._updateDisabled(value !== null);
2827
+ }
2828
+ // ── Public API ──────────────────────────────────────────────────────────
2829
+ getValue() {
2830
+ if (!this._editor) return "";
2831
+ let out = "";
2832
+ const walk = (node) => {
2833
+ node.childNodes.forEach((child) => {
2834
+ if (child.nodeType === Node.TEXT_NODE) out += child.textContent ?? "";
2835
+ else if (child.nodeName === "BR") out += "\n";
2836
+ else walk(child);
2837
+ });
2838
+ };
2839
+ walk(this._editor);
2840
+ return out.trim();
2841
+ }
2842
+ setValue(value) {
2843
+ if (!this._editor) return;
2844
+ this._editor.textContent = value;
2845
+ this._updatePlaceholderVisibility();
2846
+ this._adjustHeight();
2847
+ this._getRoot()?.setValue(value);
2848
+ }
2849
+ clear() {
2850
+ if (!this._editor) return;
2851
+ this._editor.innerHTML = "";
2852
+ this._updatePlaceholderVisibility();
2853
+ this._adjustHeight();
2854
+ this._getRoot()?.setValue("");
2855
+ }
2856
+ focus() {
2857
+ this._editor?.focus();
2858
+ }
2859
+ blur() {
2860
+ this._editor?.blur();
2861
+ }
2862
+ /** Focus the editor and place the caret at the very end of its content. */
2863
+ focusEnd() {
2864
+ if (!this._editor) return;
2865
+ this._editor.focus();
2866
+ const sel = this.ownerDocument?.getSelection();
2867
+ if (!sel) return;
2868
+ const range = this.ownerDocument.createRange();
2869
+ range.selectNodeContents(this._editor);
2870
+ range.collapse(false);
2871
+ sel.removeAllRanges();
2872
+ sel.addRange(range);
2873
+ }
2874
+ // ── Private ─────────────────────────────────────────────────────────────
2875
+ _getRoot() {
2876
+ return this.closest("aparte-composer");
2877
+ }
2878
+ _getPlaceholder() {
2879
+ return this.getAttribute("placeholder") || this._getRoot()?.placeholder || resolveConfig(this).t("inputPlaceholder") || "Type a message...";
2880
+ }
2881
+ _render() {
2882
+ if (this.querySelector(".aparte-ci-editor")) return;
2883
+ const disabled = this.hasAttribute("disabled") || this._getRoot()?.disabled || false;
2884
+ const placeholder = this._escapeAttr(this._getPlaceholder());
2885
+ this.innerHTML = `<div
2886
+ class="aparte-ci-editor"
2887
+ contenteditable="${!disabled}"
2888
+ role="textbox"
2889
+ aria-multiline="true"
2890
+ aria-label="${placeholder}"
2891
+ tabindex="0"
2892
+ aria-disabled="${disabled}"
2893
+ data-placeholder="${placeholder}"
2894
+ ></div>`;
2895
+ this._editor = this.querySelector(".aparte-ci-editor");
2896
+ this._editor?.addEventListener("input", this._onInput);
2897
+ this._editor?.addEventListener("keydown", this._onKeydown);
2898
+ this._editor?.addEventListener("focus", this._onFocus);
2899
+ this._editor?.addEventListener("blur", this._onBlur);
2900
+ this._editor?.addEventListener("paste", this._onPaste);
2901
+ this._adjustHeight();
2902
+ }
2903
+ _connectToRoot() {
2904
+ const root = this._getRoot();
2905
+ if (!root) return;
2906
+ this._unsubscribes.push(
2907
+ root._on("disabled-change", ({ disabled }) => this._updateDisabled(disabled))
2908
+ );
2909
+ this._unsubscribes.push(
2910
+ root._on("value-change", ({ value }) => {
2911
+ if (value === "" && this.getValue() !== "") this.clear();
2912
+ })
2913
+ );
2914
+ this._unsubscribes.push(
2915
+ root._on("streaming-change", ({ streaming }) => {
2916
+ this._updateDisabled(streaming || root.disabled);
2917
+ })
2918
+ );
2919
+ }
2920
+ _handleInput() {
2921
+ this._adjustHeight();
2922
+ if (this._editor && !this._editor.textContent?.trim() && this._editor.innerHTML !== "") {
2923
+ this._editor.innerHTML = "";
2924
+ }
2925
+ this._updatePlaceholderVisibility();
2926
+ const value = this.getValue();
2927
+ this._getRoot()?.setValue(value);
2928
+ }
2929
+ _handleKeydown(e2) {
2930
+ if (e2.isComposing || e2.keyCode === 229) return;
2931
+ if (e2.key !== "Enter") return;
2932
+ const submitOnEnter = this._getRoot()?.submitOnEnter ?? true;
2933
+ const submits = submitOnEnter ? !e2.shiftKey : e2.shiftKey;
2934
+ if (submits) {
2935
+ e2.preventDefault();
2936
+ const root = this._getRoot();
2937
+ if (root) {
2938
+ root.submit();
2939
+ } else {
2940
+ this.dispatchEvent(new CustomEvent("aparte-composer-submit", { bubbles: true }));
2941
+ }
2942
+ } else {
2943
+ e2.preventDefault();
2944
+ if (!this._editor?.textContent) return;
2945
+ this.ownerDocument?.execCommand("insertLineBreak");
2946
+ }
2947
+ }
2948
+ _handleFocus() {
2949
+ this.classList.add("is-focused");
2950
+ }
2951
+ _handleBlur() {
2952
+ this.classList.remove("is-focused");
2953
+ }
2954
+ _handlePaste(e2) {
2955
+ e2.preventDefault();
2956
+ const cd = e2.clipboardData;
2957
+ if (!cd) return;
2958
+ const imageFile = Array.from(cd.items).find((i2) => i2.type.startsWith("image/"))?.getAsFile();
2959
+ if (imageFile) {
2960
+ this._getRoot()?.addAttachments([imageFile]);
2961
+ return;
2962
+ }
2963
+ const text = cd.getData("text/plain");
2964
+ if (text) {
2965
+ document.execCommand("insertText", false, text);
2966
+ }
2967
+ }
2968
+ _adjustHeight() {
2969
+ if (!this._editor) return;
2970
+ this._editor.style.height = "0px";
2971
+ const floor = this.hasAttribute("min-height") ? this._minHeight : 0;
2972
+ const contentHeight = this._editor.scrollHeight;
2973
+ const h2 = Math.min(Math.max(contentHeight, floor), this._maxHeight);
2974
+ this._editor.style.height = `${h2}px`;
2975
+ this._editor.style.overflowY = contentHeight > this._maxHeight ? "auto" : "hidden";
2976
+ }
2977
+ /**
2978
+ * The first `_adjustHeight()` runs synchronously in `_render()` on connect —
2979
+ * before the stylesheet, flex layout and web fonts have necessarily settled.
2980
+ * On an unstabilized layout `scrollHeight` can read inflated, leaving the
2981
+ * editor stuck tall (misaligned with the composer controls) until the first
2982
+ * keystroke re-measures it. Re-measure once the layout is ready so it's
2983
+ * correct from the first paint.
2984
+ */
2985
+ _scheduleInitialReflow() {
2986
+ if (typeof requestAnimationFrame === "function") {
2987
+ requestAnimationFrame(() => this._adjustHeight());
2988
+ }
2989
+ const fonts = document.fonts;
2990
+ fonts?.ready?.then(() => this._adjustHeight()).catch(() => {
2991
+ });
2992
+ }
2993
+ _updatePlaceholder() {
2994
+ if (this._editor) {
2995
+ const p = this._getPlaceholder();
2996
+ this._editor.setAttribute("data-placeholder", p);
2997
+ this._editor.setAttribute("aria-label", p);
2998
+ }
2999
+ }
3000
+ _updatePlaceholderVisibility() {
3001
+ }
3002
+ _updateDisabled(disabled) {
3003
+ if (!this._editor) return;
3004
+ this._editor.setAttribute("contenteditable", String(!disabled));
3005
+ this._editor.setAttribute("aria-disabled", String(disabled));
3006
+ }
3007
+ /** Escape a value before it lands in a double-quoted HTML attribute. */
3008
+ _escapeAttr(str) {
3009
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
3010
+ }
3011
+ }
3012
+ if (!customElements.get("aparte-composer-input")) {
3013
+ customElements.define("aparte-composer-input", AparteComposerInput);
3014
+ }
3015
+ class AparteComposerSend extends HTMLElement {
3016
+ _button = null;
3017
+ _unsubscribes = [];
3018
+ // Bound handler
3019
+ _onClick = this._handleClick.bind(this);
3020
+ connectedCallback() {
3021
+ this._render();
3022
+ this._connectToRoot();
3023
+ }
3024
+ disconnectedCallback() {
3025
+ this._button?.removeEventListener("click", this._onClick);
3026
+ this._unsubscribes.forEach((fn) => fn());
3027
+ this._unsubscribes = [];
3028
+ }
3029
+ // ── Private ─────────────────────────────────────────────────────────────
3030
+ _getRoot() {
3031
+ return this.closest("aparte-composer");
3032
+ }
3033
+ _render() {
3034
+ if (this.querySelector(".aparte-cs-button")) return;
3035
+ const label = resolveConfig(this).t("sendButton") || "Send";
3036
+ const icon = this._getSendIcon();
3037
+ const root = this._getRoot();
3038
+ const disabled = !root || root.disabled || root.value.trim() === "";
3039
+ this.innerHTML = `<button
3040
+ class="aparte-cs-button aparte-send-button"
3041
+ aria-label="${label}"
3042
+ title="${label}"
3043
+ ${disabled ? "disabled" : ""}
3044
+ >${icon}</button>`;
3045
+ this._button = this.querySelector(".aparte-cs-button");
3046
+ this._button?.addEventListener("click", this._onClick);
3047
+ }
3048
+ _connectToRoot() {
3049
+ const root = this._getRoot();
3050
+ if (!root) return;
3051
+ this._unsubscribes.push(
3052
+ root._on("value-change", () => this._syncState())
3053
+ );
3054
+ this._unsubscribes.push(
3055
+ root._on("disabled-change", () => this._syncState())
3056
+ );
3057
+ this._unsubscribes.push(
3058
+ root._on("streaming-change", ({ streaming }) => {
3059
+ if (this._getRoot()?.panelActive) return;
3060
+ this._syncStreamingState(streaming);
3061
+ })
3062
+ );
3063
+ this._unsubscribes.push(
3064
+ root._on("attachments-change", () => this._syncState())
3065
+ );
3066
+ this._unsubscribes.push(
3067
+ root._on("panel-change", ({ active, submitEnabled }) => {
3068
+ if (!this._button) return;
3069
+ if (active) {
3070
+ this._button.disabled = !submitEnabled;
3071
+ this._button.innerHTML = this._getSendIcon();
3072
+ const label = resolveConfig(this).t("submitButton") || "Submit";
3073
+ this._button.setAttribute("aria-label", label);
3074
+ this._button.setAttribute("title", label);
3075
+ this._button.classList.remove("is-streaming");
3076
+ } else {
3077
+ const root2 = this._getRoot();
3078
+ if (root2?.streaming) {
3079
+ this._syncStreamingState(true);
3080
+ } else {
3081
+ this._syncState();
3082
+ }
3083
+ }
3084
+ })
3085
+ );
3086
+ }
3087
+ _handleClick(e2) {
3088
+ e2.preventDefault();
3089
+ this._getRoot()?.submit();
3090
+ }
3091
+ _syncState() {
3092
+ const root = this._getRoot();
3093
+ if (!root || !this._button) return;
3094
+ if (root.streaming) return;
3095
+ const isEmpty = root.value.trim() === "" && root.attachments.length === 0;
3096
+ this._button.disabled = root.disabled || isEmpty;
3097
+ this._button.innerHTML = this._getSendIcon();
3098
+ const label = resolveConfig(this).t("sendButton") || "Send";
3099
+ this._button.setAttribute("aria-label", label);
3100
+ this._button.setAttribute("title", label);
3101
+ this._button.classList.remove("is-streaming");
3102
+ }
3103
+ _syncStreamingState(streaming) {
3104
+ if (!this._button) return;
3105
+ if (streaming) {
3106
+ this._button.disabled = false;
3107
+ this._button.innerHTML = this._getStopIcon();
3108
+ this._button.setAttribute("aria-label", "Stop");
3109
+ this._button.setAttribute("title", "Stop");
3110
+ this._button.classList.add("is-streaming");
3111
+ } else {
3112
+ this._syncState();
3113
+ }
3114
+ }
3115
+ _getSendIcon() {
3116
+ return resolveConfig(this).getIcon("send") || "Send";
3117
+ }
3118
+ _getStopIcon() {
3119
+ return resolveConfig(this).getIcon("stop");
3120
+ }
3121
+ }
3122
+ if (!customElements.get("aparte-composer-send")) {
3123
+ customElements.define("aparte-composer-send", AparteComposerSend);
3124
+ }
3125
+ class AparteComposerCancel extends HTMLElement {
3126
+ _button = null;
3127
+ _unsubscribes = [];
3128
+ // Bound handler
3129
+ _onClick = this._handleClick.bind(this);
3130
+ connectedCallback() {
3131
+ this._render();
3132
+ this._connectToRoot();
3133
+ }
3134
+ disconnectedCallback() {
3135
+ this._button?.removeEventListener("click", this._onClick);
3136
+ this._unsubscribes.forEach((fn) => fn());
3137
+ this._unsubscribes = [];
3138
+ }
3139
+ // ── Private ─────────────────────────────────────────────────────────────
3140
+ _getRoot() {
3141
+ return this.closest("aparte-composer");
3142
+ }
3143
+ _render() {
3144
+ if (this.querySelector(".aparte-cc-button")) return;
3145
+ const label = resolveConfig(this).t("stopButton") || "Stop";
3146
+ const icon = this._getStopIcon();
3147
+ this.innerHTML = `<button
3148
+ class="aparte-cc-button"
3149
+ aria-label="${label}"
3150
+ title="${label}"
3151
+ hidden
3152
+ >${icon}</button>`;
3153
+ this._button = this.querySelector(".aparte-cc-button");
3154
+ this._button?.addEventListener("click", this._onClick);
3155
+ }
3156
+ _connectToRoot() {
3157
+ const root = this._getRoot();
3158
+ if (!root) return;
3159
+ this._unsubscribes.push(
3160
+ root._on("streaming-change", ({ streaming }) => {
3161
+ if (this._button) this._button.hidden = !streaming;
3162
+ })
3163
+ );
3164
+ if (root.streaming && this._button) this._button.hidden = false;
3165
+ }
3166
+ _handleClick(e2) {
3167
+ e2.preventDefault();
3168
+ this._getRoot()?.cancel();
3169
+ }
3170
+ _getStopIcon() {
3171
+ return resolveConfig(this).getIcon("stop");
3172
+ }
3173
+ }
3174
+ if (!customElements.get("aparte-composer-cancel")) {
3175
+ customElements.define("aparte-composer-cancel", AparteComposerCancel);
3176
+ }
3177
+ const REMOVE_ICON = '<svg width="10" height="10" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"><path d="M9.5 3.205 8.795 2.5 6 5.295 3.205 2.5 2.5 3.205 5.295 6 2.5 8.795l.705.705L6 6.705l2.795 2.795.705-.705L6.705 6z"/></svg>';
3178
+ class AparteComposerAttachments extends HTMLElement {
3179
+ _unsubscribes = [];
3180
+ /** Object URLs minted for image previews — revoked on re-render/disconnect. */
3181
+ _objectUrls = [];
3182
+ connectedCallback() {
3183
+ this._render([]);
3184
+ this._connectToRoot();
3185
+ }
3186
+ disconnectedCallback() {
3187
+ this._unsubscribes.forEach((fn) => fn());
3188
+ this._unsubscribes = [];
3189
+ this._revokeUrls();
3190
+ }
3191
+ // ── Private ─────────────────────────────────────────────────────────────
3192
+ _getRoot() {
3193
+ return this.closest("aparte-composer");
3194
+ }
3195
+ _connectToRoot() {
3196
+ const root = this._getRoot();
3197
+ if (!root) return;
3198
+ this._unsubscribes.push(
3199
+ root._on("attachments-change", ({ attachments }) => this._render(attachments))
3200
+ );
3201
+ this._render(root.attachments);
3202
+ }
3203
+ /** Release the previous render's blob URLs so they don't leak. */
3204
+ _revokeUrls() {
3205
+ this._objectUrls.forEach((url) => URL.revokeObjectURL(url));
3206
+ this._objectUrls = [];
3207
+ }
3208
+ _render(files) {
3209
+ this.hidden = files.length === 0;
3210
+ this._revokeUrls();
3211
+ this.innerHTML = files.map((file) => {
3212
+ const name = this._escape(file.name);
3213
+ const remove = `<button class="aparte-thumb__remove" type="button" aria-label="Remove ${name}">${REMOVE_ICON}</button>`;
3214
+ if (file.type.startsWith("image/")) {
3215
+ const url = URL.createObjectURL(file);
3216
+ this._objectUrls.push(url);
3217
+ return `<div class="aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumb__img" src="${url}" alt="${name}" /><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
3218
+ }
3219
+ return `<div class="aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumb__ext">${this._escape(this._ext(file.name))}</span><span class="aparte-thumb__name">${name}</span>${remove}</div>`;
3220
+ }).join("");
3221
+ this.querySelectorAll(".aparte-thumb__remove").forEach((btn, i2) => {
3222
+ btn.addEventListener("click", (e2) => {
3223
+ e2.stopPropagation();
3224
+ const root = this._getRoot();
3225
+ if (root) root.removeAttachment(root.attachments[i2]);
3226
+ });
3227
+ });
3228
+ this.querySelectorAll(".aparte-thumb--image").forEach((tile) => {
3229
+ tile.addEventListener("click", () => {
3230
+ const img = tile.querySelector(".aparte-thumb__img");
3231
+ if (!img) return;
3232
+ this.dispatchEvent(new CustomEvent("aparte-attachment-preview", {
3233
+ bubbles: true,
3234
+ composed: true,
3235
+ detail: { url: img.src, name: tile.getAttribute("title") ?? "" }
3236
+ }));
3237
+ });
3238
+ });
3239
+ }
3240
+ /** Uppercased file extension (≤4 chars), or 'FILE' when there is none. */
3241
+ _ext(filename) {
3242
+ const dot = filename.lastIndexOf(".");
3243
+ return dot > 0 ? filename.slice(dot + 1).toUpperCase().slice(0, 4) : "FILE";
3244
+ }
3245
+ _escape(str) {
3246
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
3247
+ }
3248
+ }
3249
+ if (!customElements.get("aparte-composer-attachments")) {
3250
+ customElements.define("aparte-composer-attachments", AparteComposerAttachments);
3251
+ }
3252
+ class AparteComposerAddAttachment extends HTMLElement {
3253
+ _button = null;
3254
+ _dragCleanup = null;
3255
+ _unsubscribes = [];
3256
+ // Bound handler
3257
+ _onClick = this._handleClick.bind(this);
3258
+ static get observedAttributes() {
3259
+ return ["accept", "multiple", "disabled"];
3260
+ }
3261
+ connectedCallback() {
3262
+ this._render();
3263
+ this._connectToRoot();
3264
+ this._setupDragDrop();
3265
+ }
3266
+ disconnectedCallback() {
3267
+ this._button?.removeEventListener("click", this._onClick);
3268
+ this._dragCleanup?.();
3269
+ this._unsubscribes.forEach((fn) => fn());
3270
+ this._unsubscribes = [];
3271
+ }
3272
+ attributeChangedCallback(name, _old, value) {
3273
+ if (name === "disabled" && this._button) {
3274
+ this._button.disabled = value !== null;
3275
+ }
3276
+ }
3277
+ // ── Private ─────────────────────────────────────────────────────────────
3278
+ _getRoot() {
3279
+ return this.closest("aparte-composer");
3280
+ }
3281
+ _render() {
3282
+ if (this.querySelector(".aparte-caa-button")) return;
3283
+ const label = resolveConfig(this).t("actionUpload") || "Attach file";
3284
+ const icon = resolveConfig(this).getIcon("paperclip") || this._defaultIcon();
3285
+ const disabled = this.hasAttribute("disabled") || this._getRoot()?.disabled || false;
3286
+ this.innerHTML = `<button
3287
+ class="aparte-caa-button aparte-action-button"
3288
+ aria-label="${label}"
3289
+ title="${label}"
3290
+ type="button"
3291
+ ${disabled ? "disabled" : ""}
3292
+ >${icon}</button>`;
3293
+ this._button = this.querySelector(".aparte-caa-button");
3294
+ this._button?.addEventListener("click", this._onClick);
3295
+ }
3296
+ _connectToRoot() {
3297
+ const root = this._getRoot();
3298
+ if (!root) return;
3299
+ this._unsubscribes.push(
3300
+ root._on("disabled-change", ({ disabled }) => {
3301
+ if (this._button) this._button.disabled = disabled;
3302
+ })
3303
+ );
3304
+ this._unsubscribes.push(
3305
+ root._on("streaming-change", ({ streaming }) => {
3306
+ if (this._button) this._button.disabled = streaming || root.disabled;
3307
+ })
3308
+ );
3309
+ }
3310
+ _handleClick() {
3311
+ const input = document.createElement("input");
3312
+ input.type = "file";
3313
+ input.multiple = !this.hasAttribute("multiple") || this.getAttribute("multiple") !== "false";
3314
+ const accept = this.getAttribute("accept");
3315
+ if (accept) input.accept = accept;
3316
+ input.style.display = "none";
3317
+ document.body.appendChild(input);
3318
+ input.addEventListener("change", () => {
3319
+ if (input.files?.length) this._getRoot()?.addAttachments(input.files);
3320
+ document.body.removeChild(input);
3321
+ }, { once: true });
3322
+ input.click();
3323
+ }
3324
+ _setupDragDrop() {
3325
+ const root = this._getRoot();
3326
+ if (!root) return;
3327
+ const prevent = (e2) => {
3328
+ e2.preventDefault();
3329
+ e2.stopPropagation();
3330
+ };
3331
+ const onDragOver = (e2) => {
3332
+ if (root.disabled) return;
3333
+ prevent(e2);
3334
+ root.classList.add("is-dragover");
3335
+ };
3336
+ const onDragLeave = (e2) => {
3337
+ prevent(e2);
3338
+ root.classList.remove("is-dragover");
3339
+ };
3340
+ const onDrop = (e2) => {
3341
+ prevent(e2);
3342
+ root.classList.remove("is-dragover");
3343
+ if (root.disabled) return;
3344
+ const files = e2.dataTransfer?.files;
3345
+ if (files?.length) this._getRoot()?.addAttachments(files);
3346
+ };
3347
+ root.addEventListener("dragover", onDragOver);
3348
+ root.addEventListener("dragleave", onDragLeave);
3349
+ root.addEventListener("drop", onDrop);
3350
+ this._dragCleanup = () => {
3351
+ root.removeEventListener("dragover", onDragOver);
3352
+ root.removeEventListener("dragleave", onDragLeave);
3353
+ root.removeEventListener("drop", onDrop);
3354
+ };
3355
+ }
3356
+ _defaultIcon() {
3357
+ return `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>`;
3358
+ }
3359
+ }
3360
+ if (!customElements.get("aparte-composer-add-attachment")) {
3361
+ customElements.define("aparte-composer-add-attachment", AparteComposerAddAttachment);
3362
+ }
3363
+ class AparteComposerAction extends HTMLElement {
3364
+ _button = null;
3365
+ _unsubscribes = [];
3366
+ // Bound handler
3367
+ _onClick = this._handleClick.bind(this);
3368
+ static get observedAttributes() {
3369
+ return ["icon", "label", "disabled"];
3370
+ }
3371
+ connectedCallback() {
3372
+ this._render();
3373
+ this._connectToRoot();
3374
+ }
3375
+ disconnectedCallback() {
3376
+ this._button?.removeEventListener("click", this._onClick);
3377
+ this._unsubscribes.forEach((fn) => fn());
3378
+ this._unsubscribes = [];
3379
+ }
3380
+ attributeChangedCallback(name, _old, value) {
3381
+ if (!this._button) return;
3382
+ if (name === "disabled") {
3383
+ this._button.disabled = value !== null;
3384
+ }
3385
+ if (name === "label") {
3386
+ this._button.setAttribute("aria-label", value ?? "");
3387
+ this._button.setAttribute("title", value ?? "");
3388
+ }
3389
+ if (name === "icon") {
3390
+ this._button.innerHTML = this._resolveIcon(value ?? "");
3391
+ }
3392
+ }
3393
+ // ── Private ─────────────────────────────────────────────────────────────
3394
+ _getRoot() {
3395
+ return this.closest("aparte-composer");
3396
+ }
3397
+ _render() {
3398
+ if (this.querySelector(".aparte-cact-button")) return;
3399
+ const label = this._escapeAttr(this.getAttribute("label") ?? "");
3400
+ const icon = this._resolveIcon(this.getAttribute("icon") ?? "");
3401
+ const disabled = this.hasAttribute("disabled") || this._getRoot()?.disabled || false;
3402
+ this.innerHTML = `<button
3403
+ class="aparte-cact-button aparte-action-button"
3404
+ aria-label="${label}"
3405
+ title="${label}"
3406
+ type="button"
3407
+ ${disabled ? "disabled" : ""}
3408
+ >${icon}</button>`;
3409
+ this._button = this.querySelector(".aparte-cact-button");
3410
+ this._button?.addEventListener("click", this._onClick);
3411
+ }
3412
+ _connectToRoot() {
3413
+ const root = this._getRoot();
3414
+ if (!root) return;
3415
+ this._unsubscribes.push(
3416
+ root._on("disabled-change", ({ disabled }) => {
3417
+ if (this._button) this._button.disabled = disabled || this.hasAttribute("disabled");
3418
+ })
3419
+ );
3420
+ this._unsubscribes.push(
3421
+ root._on("streaming-change", ({ streaming }) => {
3422
+ if (this._button) this._button.disabled = streaming || root.disabled || this.hasAttribute("disabled");
3423
+ })
3424
+ );
3425
+ }
3426
+ _handleClick(_e) {
3427
+ this.dispatchEvent(new CustomEvent("aparte-action-click", {
3428
+ bubbles: true,
3429
+ composed: true,
3430
+ detail: { actionId: this.getAttribute("action-id") ?? "", composer: this._getRoot() }
3431
+ }));
3432
+ }
3433
+ _resolveIcon(icon) {
3434
+ if (!icon) return "";
3435
+ if (icon.trimStart().startsWith("<")) return icon;
3436
+ return resolveConfig(this).getIcon(icon) ?? icon;
3437
+ }
3438
+ /** Escape a value before it lands in a double-quoted HTML attribute. */
3439
+ _escapeAttr(str) {
3440
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
3441
+ }
3442
+ }
3443
+ if (!customElements.get("aparte-composer-action")) {
3444
+ customElements.define("aparte-composer-action", AparteComposerAction);
3445
+ }
3446
+ class AparteConversationList extends HTMLElement {
3447
+ _conversations = [];
3448
+ _activeId = null;
3449
+ static get observedAttributes() {
3450
+ return ["active-id"];
3451
+ }
3452
+ // ─── Lifecycle ────────────────────────────────────────────────────────
3453
+ connectedCallback() {
3454
+ if (!this.classList.contains("aparte-conv-list")) {
3455
+ this.classList.add("aparte-conv-list");
3456
+ }
3457
+ if (!this.getAttribute("role")) {
3458
+ this.setAttribute("role", "navigation");
3459
+ }
3460
+ this._render();
3461
+ }
3462
+ attributeChangedCallback(name, oldValue, newValue) {
3463
+ if (oldValue === newValue) return;
3464
+ if (name === "active-id") {
3465
+ this._activeId = newValue;
3466
+ this._updateActiveState();
3467
+ }
3468
+ }
3469
+ // ─── Public API ───────────────────────────────────────────────────────
3470
+ /** Set the list of conversations to display. Triggers a re-render. */
3471
+ set conversations(items) {
3472
+ this._conversations = Array.isArray(items) ? items : [];
3473
+ this._render();
3474
+ }
3475
+ get conversations() {
3476
+ return this._conversations;
3477
+ }
3478
+ // ─── Rendering ────────────────────────────────────────────────────────
3479
+ _render() {
3480
+ this.innerHTML = this._conversations.map((conv) => this._renderItem(conv)).join("");
3481
+ this._bindEvents();
3482
+ }
3483
+ _renderItem(conv) {
3484
+ const locale = resolveConfig(this).getLocale();
3485
+ const isActive = conv.id === this._activeId;
3486
+ const isArchived = !!conv.archivedAt;
3487
+ const activeClass = isActive ? " aparte-conv-item--active" : "";
3488
+ const archivedClass = isArchived ? " aparte-conv-item--archived" : "";
3489
+ const escapedId = this._esc(conv.id);
3490
+ const escapedTitle = this._esc(conv.title || locale.newChat);
3491
+ const deleteLabel = this._esc(locale.deleteConversation);
3492
+ const archiveLabel = this._esc(locale["archiveConversation"] ?? "Archive conversation");
3493
+ const unarchiveLabel = this._esc(locale["unarchiveConversation"] ?? "Unarchive conversation");
3494
+ const archiveAction = isArchived ? "unarchive" : "archive";
3495
+ const archiveAriaLabel = isArchived ? unarchiveLabel : archiveLabel;
3496
+ const archiveIcon = isArchived ? `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 8 21 8"/><path d="M5 8v11a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8"/><polyline points="9 14 12 11 15 14"/><line x1="12" y1="11" x2="12" y2="19"/></svg>` : `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 8 21 8"/><path d="M5 8v11a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8"/><polyline points="9 14 12 17 15 14"/><line x1="12" y1="11" x2="12" y2="17"/></svg>`;
3497
+ return `
3498
+ <div
3499
+ class="aparte-conv-item${activeClass}${archivedClass}"
3500
+ role="button"
3501
+ tabindex="0"
3502
+ data-conv-id="${escapedId}"
3503
+ aria-current="${isActive ? "page" : "false"}"
3504
+ >
3505
+ <span class="aparte-conv-item__title">${escapedTitle}</span>
3506
+ <button
3507
+ class="aparte-conv-item__archive"
3508
+ type="button"
3509
+ data-archive-id="${escapedId}"
3510
+ data-archive-action="${archiveAction}"
3511
+ aria-label="${archiveAriaLabel}"
3512
+ tabindex="0"
3513
+ >${archiveIcon}</button>
3514
+ <button
3515
+ class="aparte-conv-item__delete"
3516
+ type="button"
3517
+ data-delete-id="${escapedId}"
3518
+ aria-label="${deleteLabel}"
3519
+ tabindex="0"
3520
+ >
3521
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
3522
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3523
+ <path d="M18 6L6 18M6 6l12 12"/>
3524
+ </svg>
3525
+ </button>
3526
+ </div>`;
3527
+ }
3528
+ _bindEvents() {
3529
+ this.addEventListener("click", this._onClick);
3530
+ this.addEventListener("keydown", this._onKeydown);
3531
+ }
3532
+ _onClick = (e2) => {
3533
+ const target = e2.target;
3534
+ const archiveBtn = target.closest("[data-archive-id]");
3535
+ if (archiveBtn) {
3536
+ e2.stopPropagation();
3537
+ const id = archiveBtn.dataset["archiveId"];
3538
+ const action = archiveBtn.dataset["archiveAction"];
3539
+ const eventName = action === "unarchive" ? "aparte-unarchive-conversation" : "aparte-archive-conversation";
3540
+ this.dispatchEvent(new CustomEvent(
3541
+ eventName,
3542
+ { detail: { id }, bubbles: true, composed: true }
3543
+ ));
3544
+ return;
3545
+ }
3546
+ const deleteBtn = target.closest("[data-delete-id]");
3547
+ if (deleteBtn) {
3548
+ e2.stopPropagation();
3549
+ const id = deleteBtn.dataset["deleteId"];
3550
+ this.dispatchEvent(new CustomEvent(
3551
+ "aparte-delete-conversation",
3552
+ { detail: { id }, bubbles: true, composed: true }
3553
+ ));
3554
+ return;
3555
+ }
3556
+ const item = target.closest("[data-conv-id]");
3557
+ if (item) {
3558
+ const id = item.dataset["convId"];
3559
+ this.dispatchEvent(new CustomEvent(
3560
+ "aparte-select-conversation",
3561
+ { detail: { id }, bubbles: true, composed: true }
3562
+ ));
3563
+ }
3564
+ };
3565
+ _onKeydown = (e2) => {
3566
+ if (e2.key !== "Enter" && e2.key !== " ") return;
3567
+ const item = e2.target.closest("[data-conv-id]");
3568
+ if (item) {
3569
+ e2.preventDefault();
3570
+ item.click();
3571
+ }
3572
+ };
3573
+ /** Update active class without full re-render (perf optimisation). */
3574
+ _updateActiveState() {
3575
+ const items = this.querySelectorAll("[data-conv-id]");
3576
+ items.forEach((el) => {
3577
+ const isActive = el.dataset["convId"] === this._activeId;
3578
+ el.classList.toggle("aparte-conv-item--active", isActive);
3579
+ el.setAttribute("aria-current", isActive ? "page" : "false");
3580
+ });
3581
+ }
3582
+ // ─── Helpers ──────────────────────────────────────────────────────────
3583
+ _esc(str) {
3584
+ return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/'/g, "&#039;");
3585
+ }
3586
+ }
3587
+ if (!customElements.get("aparte-conversation-list")) customElements.define("aparte-conversation-list", AparteConversationList);
3588
+ class AparteElicitation extends HTMLElement {
3589
+ _pending = null;
3590
+ _onTurnEnd = () => this._cancelPending();
3591
+ connectedCallback() {
3592
+ this.style.display = "none";
3593
+ this._injectStyles();
3594
+ resolveConfig(this).setElicitationPresenter(this._present);
3595
+ window.addEventListener("aparte-message-aborted", this._onTurnEnd);
3596
+ window.addEventListener("aparte-message-error", this._onTurnEnd);
3597
+ }
3598
+ disconnectedCallback() {
3599
+ const cfg = resolveConfig(this);
3600
+ if (cfg.getElicitationPresenter() === this._present) cfg.setElicitationPresenter(null);
3601
+ window.removeEventListener("aparte-message-aborted", this._onTurnEnd);
3602
+ window.removeEventListener("aparte-message-error", this._onTurnEnd);
3603
+ this._cancelPending();
3604
+ }
3605
+ _present = (request) => {
3606
+ if (this._pending) return Promise.resolve({ action: "cancel" });
3607
+ const composer = this._getComposer();
3608
+ if (!composer) return Promise.resolve({ action: "cancel" });
3609
+ return new Promise((resolve) => {
3610
+ let done = false;
3611
+ const settle = (result) => {
3612
+ if (done) return;
3613
+ done = true;
3614
+ this._pending = null;
3615
+ composer.hidePanel();
3616
+ resolve(result);
3617
+ };
3618
+ if (request.signal) {
3619
+ if (request.signal.aborted) {
3620
+ settle({ action: "cancel" });
3621
+ return;
3622
+ }
3623
+ request.signal.addEventListener("abort", () => settle({ action: "cancel" }), { once: true });
3624
+ }
3625
+ const panel = buildElicitationPanel(request.message, request.schema, () => {
3626
+ composer.setPanelSubmitEnabled(panel.isComplete());
3627
+ });
3628
+ const footer = document.createElement("div");
3629
+ footer.className = "aparte-elic-footer";
3630
+ const skip = document.createElement("button");
3631
+ skip.type = "button";
3632
+ skip.className = "aparte-elic-skip";
3633
+ skip.textContent = "Skip";
3634
+ skip.addEventListener("click", () => settle({ action: "decline" }));
3635
+ footer.appendChild(skip);
3636
+ panel.el.appendChild(footer);
3637
+ this._pending = { settle, composer };
3638
+ composer.showPanel(panel.el, {
3639
+ submitEnabled: panel.isComplete(),
3640
+ onSubmit: () => {
3641
+ if (panel.isComplete()) settle({ action: "accept", content: panel.getContent() });
3642
+ }
3643
+ });
3644
+ panel.focus();
3645
+ });
3646
+ };
3647
+ _cancelPending() {
3648
+ this._pending?.settle({ action: "cancel" });
3649
+ }
3650
+ _getComposer() {
3651
+ let node = this.parentElement;
3652
+ while (node) {
3653
+ const composer = node.querySelector("aparte-composer");
3654
+ if (composer && typeof composer.showPanel === "function") return composer;
3655
+ node = node.parentElement;
3656
+ }
3657
+ const doc = document.querySelector("aparte-composer");
3658
+ return doc && typeof doc.showPanel === "function" ? doc : null;
3659
+ }
3660
+ _injectStyles() {
3661
+ const id = "aparte-elicitation-styles";
3662
+ if (document.getElementById(id)) return;
3663
+ const style = document.createElement("style");
3664
+ style.id = id;
3665
+ style.textContent = `
3666
+ .aparte-elic-panel { display:flex; flex-direction:column; gap:6px; padding:6px 4px; width:100%; box-sizing:border-box; max-height:50vh; overflow-y:auto; }
3667
+ .aparte-elic-message { margin:0 6px 6px; font-size:.82rem; font-weight:600; color:var(--aparte-text); line-height:1.4; }
3668
+ .aparte-elic-field + .aparte-elic-field { margin-top:8px; padding-top:8px; border-top:1px solid var(--aparte-border, rgba(128,128,128,0.12)); }
3669
+ .aparte-elic-title { margin:0 6px 4px; font-size:.8rem; font-weight:600; color:var(--aparte-text); }
3670
+ .aparte-elic-desc { margin:0 6px 6px; font-size:.76rem; color:var(--aparte-text-muted, rgba(128,128,128,0.75)); }
3671
+ .aparte-elic-options { display:flex; flex-direction:column; gap:1px; }
3672
+ .aparte-elic-option { display:flex; align-items:flex-start; gap:10px; padding:7px 10px; border-radius:8px; cursor:pointer; border:1px solid transparent; transition:background .12s,border-color .12s; }
3673
+ .aparte-elic-option:hover { background:var(--aparte-surface-2, rgba(128,128,128,0.08)); border-color:var(--aparte-border, rgba(128,128,128,0.15)); }
3674
+ .aparte-elic-control { margin-top:3px; flex-shrink:0; accent-color:var(--aparte-primary, #6366f1); width:15px; height:15px; cursor:pointer; }
3675
+ .aparte-elic-option-body { display:flex; flex-direction:column; gap:2px; flex:1; }
3676
+ .aparte-elic-option-title { font-size:.875rem; font-weight:500; color:var(--aparte-text, inherit); line-height:1.4; }
3677
+ .aparte-elic-option-desc { font-size:.78rem; color:var(--aparte-text-muted, rgba(128,128,128,0.75)); line-height:1.4; }
3678
+ .aparte-elic-other-input, .aparte-elic-text { margin-top:4px; width:100%; padding:6px 8px; border:1px solid var(--aparte-border, rgba(128,128,128,0.2)); border-radius:6px; font-size:.875rem; background:var(--aparte-surface-2, rgba(128,128,128,0.06)); color:var(--aparte-text, inherit); outline:none; box-sizing:border-box; font-family:inherit; }
3679
+ textarea.aparte-elic-text { min-height:64px; resize:vertical; }
3680
+ .aparte-elic-other-input:focus, .aparte-elic-text:focus { border-color:var(--aparte-primary, #6366f1); }
3681
+ .aparte-elic-option--recommended { border-color:var(--aparte-primary, #6366f1) !important; background:color-mix(in srgb, var(--aparte-primary, #6366f1) 6%, transparent); }
3682
+ .aparte-elic-footer { display:flex; justify-content:flex-end; margin-top:6px; }
3683
+ .aparte-elic-skip { border:none; background:transparent; color:var(--aparte-text-muted, rgba(128,128,128,0.75)); font-size:.8rem; cursor:pointer; padding:4px 8px; border-radius:6px; font-family:inherit; }
3684
+ .aparte-elic-skip:hover { background:var(--aparte-surface-2, rgba(128,128,128,0.08)); color:var(--aparte-text); }
3685
+ `;
3686
+ document.head.appendChild(style);
3687
+ }
3688
+ }
3689
+ if (typeof customElements !== "undefined" && !customElements.get("aparte-elicitation")) {
3690
+ customElements.define("aparte-elicitation", AparteElicitation);
3691
+ }
3692
+ function registerAllComponents() {
3693
+ const _chat = customElements.get("aparte-chat");
3694
+ const _viewport = customElements.get("aparte-chat-viewport");
3695
+ const _bubble = customElements.get("aparte-chat-bubble");
3696
+ const _status = customElements.get("aparte-chat-status");
3697
+ if (!_chat || !_viewport || !_bubble || !_status) {
3698
+ console.warn("[Aparte] Some components may not be registered. Ensure all component files are imported.");
3699
+ }
3700
+ }
3701
+ export {
3702
+ A as APARTE_CONVERSATION_SCHEMA_VERSION,
3703
+ a as APARTE_HOST_ATTR,
3704
+ AparteChat,
3705
+ AparteChatBubble,
3706
+ b as AparteChatHost,
3707
+ AparteChatStatus,
3708
+ AparteChatViewport,
3709
+ c as AparteClient,
3710
+ AparteComposer,
3711
+ AparteComposerAction,
3712
+ AparteComposerAddAttachment,
3713
+ AparteComposerAttachments,
3714
+ AparteComposerCancel,
3715
+ AparteComposerInput,
3716
+ AparteComposerSend,
3717
+ AparteConfig,
3718
+ e as AparteConfigClass,
3719
+ f as AparteConversationController,
3720
+ AparteConversationList,
3721
+ AparteElicitation,
3722
+ g as AparteError,
3723
+ h as AparteErrorCode,
3724
+ AparteOptgroup,
3725
+ AparteOption,
3726
+ AparteProgressSpinner,
3727
+ AparteSelect,
3728
+ i as AparteStreamParser,
3729
+ B as BackendTransport,
3730
+ C as ConversationManager,
3731
+ D as DEFAULT_ICON_FALLBACKS,
3732
+ j as DEFAULT_LOCALE,
3733
+ k as DEFAULT_SKELETON_FALLBACKS,
3734
+ l as DEFAULT_UI_EVENTS,
3735
+ m as DirectTransport,
3736
+ MessageRepository,
3737
+ n as applyElementProps,
3738
+ o as attachConfig,
3739
+ buildElicitationPanel,
3740
+ q as collectRendererStyles,
3741
+ r as contentToText,
3742
+ s as contextConfig,
3743
+ t as createAparteChatHandler,
3744
+ u as createStreamAdapter,
3745
+ v as defaultSanitizer,
3746
+ w as deriveArtifactKind,
3747
+ x as detachConfig,
3748
+ getSegmentRenderer,
3749
+ z as isFormatAdapter,
3750
+ E as isSafeUrl,
3751
+ F as parseAparteEventStream,
3752
+ G as parseMarkdownToSegments,
3753
+ populateBubbleFromMessage,
3754
+ I as readableToAsyncIterable,
3755
+ registerAllComponents,
3756
+ J as registerDefaultRenderers,
3757
+ K as registerSegmentRenderer,
3758
+ L as requestUserInput,
3759
+ resolveConfig,
3760
+ runWithConfig,
3761
+ P as unregisterSegmentRenderer
3762
+ };
3763
+ //# sourceMappingURL=index.js.map